From 7064c38e53714edd76a9c2f80fed67b38a0b7c54 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 12 Oct 2025 19:39:32 +0100 Subject: [PATCH 001/113] [ty] Filter out `revealed-type` and `undefined-reveal` diagnostics from mdtest snapshots (#20820) --- .../resources/mdtest/call/overloads.md | 2 - .../mdtest/dataclasses/dataclasses.md | 1 - .../mdtest/exception/invalid_syntax.md | 2 - .../mdtest/generics/legacy/functions.md | 2 - .../mdtest/ide_support/all_members.md | 6 +- .../resources/mdtest/loops/async_for.md | 14 - .../resources/mdtest/loops/for.md | 30 -- .../resources/mdtest/mro.md | 4 - .../resources/mdtest/narrow/type_guards.md | 2 +- .../resources/mdtest/protocols.md | 10 +- ...asic_functionality_(6b9531a70334bfad).snap | 43 -- ...`__aiter__`_metho…_(4fbd80e21774cc23).snap | 34 +- ...`__anext__`_metho…_(a0b186714127abee).snap | 42 +- ...sibly_missing_`__…_(33924dbae5117216).snap | 48 +- ...sibly_missing_`__…_(e2600ca4708d9e54).snap | 48 +- ...chronously_iterab…_(80fa705b1c61d982).snap | 46 +- ...ng_signature_for_…_(b614724363eec343).snap | 48 +- ...ong_signature_for_…_(e1f3e9275d0a367).snap | 48 +- ...taclasses.KW_ONLY…_(dd1b8f2f71487f16).snap | 189 +++----- ..._`__getitem__`_me…_(3ffe352bb3a76715).snap | 46 +- ...`__iter__`_method…_(36425dbcbd793d2b).snap | 34 +- ...sibly-not-callabl…_(49a21e4b7fe6e97b).snap | 102 ++-- ...sibly_invalid_`__…_(6388761c90a0555c).snap | 102 ++-- ...sibly_invalid_`__…_(6805a6032e504b63).snap | 94 ++-- ...sibly_invalid_`__…_(c626bde8651b643a).snap | 110 ++--- ...sibly_missing_`__…_(77269542b8e81774).snap | 116 ++--- ...sibly_missing_`__…_(9f781babda99d74b).snap | 56 +-- ...sibly_missing_`__…_(d8a02a0fcbb390a3).snap | 54 +-- ...on_type_as_iterab…_(6177bb6d13a22241).snap | 58 +-- ...on_type_as_iterab…_(ba36fbef63a14969).snap | 48 +- ...h_non-callable_it…_(a1cdf01ad69ac37c).snap | 58 +-- ...iter__`_does_not_…_(92e3fdd69edad63d).snap | 36 +- ...iter__`_method_wi…_(1136c0e783d61ba4).snap | 44 +- ...iter__`_returns_a…_(707bd02a22c4acc8).snap | 88 ++-- ...nferring_a_bound_ty…_(d50204b9d91b7bd1).snap | 80 +-- ...nferring_a_constrai…_(48ab83f977c109b4).snap | 96 +--- ...nferring_a_bound_ty…_(5935d14c26afe407).snap | 39 -- ...nferring_a_constrai…_(d2c475fccc70a8e2).snap | 53 -- ...__bases__`_includes…_(d2532518c44112c8).snap | 70 +-- ...__bases__`_lists_wi…_(ea7ebc83ec359b54).snap | 456 ++++++++---------- ...timization___Limit_…_(cd61048adbc17331).snap | 45 -- ...ls_to_protocol_cl…_(288988036f34ddcf).snap | 72 +-- ...rrowing_of_protoco…_(98257e7c2300373).snap | 141 +----- ...tocol_members_in_…_(21be5d9bdab1c844).snap | 16 +- crates/ty_python_semantic/src/types.rs | 2 +- .../src/types/diagnostic.rs | 2 +- crates/ty_test/src/lib.rs | 12 +- 47 files changed, 849 insertions(+), 1900 deletions(-) delete mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap diff --git a/crates/ty_python_semantic/resources/mdtest/call/overloads.md b/crates/ty_python_semantic/resources/mdtest/call/overloads.md index 13430cf3cf..726d74a630 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/call/overloads.md @@ -99,8 +99,6 @@ If the arity check only matches a single overload, it should be evaluated as a r call should be reported directly and not as a `no-matching-overload` error. ```py -from typing_extensions import reveal_type - from overloaded import f reveal_type(f()) # revealed: None diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 398939bf9f..bb2092aa5c 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -1010,7 +1010,6 @@ python-version = "3.10" ```py from dataclasses import dataclass, field, KW_ONLY -from typing_extensions import reveal_type @dataclass class C: diff --git a/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md b/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md index 19bb7c4377..f9fea8b7cb 100644 --- a/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md +++ b/crates/ty_python_semantic/resources/mdtest/exception/invalid_syntax.md @@ -3,8 +3,6 @@ ## Invalid syntax ```py -from typing_extensions import reveal_type - try: print except as e: # error: [invalid-syntax] diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md index 7da1d19286..9745fdca21 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md @@ -181,7 +181,6 @@ reveal_type(takes_homogeneous_tuple((42, 43))) # revealed: Literal[42, 43] ```py from typing import TypeVar -from typing_extensions import reveal_type T = TypeVar("T", bound=int) @@ -200,7 +199,6 @@ reveal_type(f("string")) # revealed: Unknown ```py from typing import TypeVar -from typing_extensions import reveal_type T = TypeVar("T", int, None) diff --git a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md index a8be450f36..03ea95b4a2 100644 --- a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md +++ b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md @@ -5,8 +5,6 @@ all members available on a given type. This routine is used for autocomplete sug ## Basic functionality - - The `ty_extensions.all_members` and `ty_extensions.has_member` functions expose a Python-level API that can be used to query which attributes `ide_support::all_members` understands as being available on a given object. For example, all member functions of `str` are available on `"a"`. The Python API @@ -45,10 +43,10 @@ The full list of all members is relatively long, but `reveal_type` can be used i `all_members` to see them all: ```py -from typing_extensions import reveal_type from ty_extensions import all_members -reveal_type(all_members("a")) # error: [revealed-type] +# revealed: tuple[Literal["__add__"], Literal["__annotations__"], Literal["__class__"], Literal["__contains__"], Literal["__delattr__"], Literal["__dict__"], Literal["__dir__"], Literal["__doc__"], Literal["__eq__"], Literal["__format__"], Literal["__ge__"], Literal["__getattribute__"], Literal["__getitem__"], Literal["__getnewargs__"], Literal["__gt__"], Literal["__hash__"], Literal["__init__"], Literal["__init_subclass__"], Literal["__iter__"], Literal["__le__"], Literal["__len__"], Literal["__lt__"], Literal["__mod__"], Literal["__module__"], Literal["__mul__"], Literal["__ne__"], Literal["__new__"], Literal["__reduce__"], Literal["__reduce_ex__"], Literal["__repr__"], Literal["__reversed__"], Literal["__rmul__"], Literal["__setattr__"], Literal["__sizeof__"], Literal["__str__"], Literal["__subclasshook__"], Literal["capitalize"], Literal["casefold"], Literal["center"], Literal["count"], Literal["encode"], Literal["endswith"], Literal["expandtabs"], Literal["find"], Literal["format"], Literal["format_map"], Literal["index"], Literal["isalnum"], Literal["isalpha"], Literal["isascii"], Literal["isdecimal"], Literal["isdigit"], Literal["isidentifier"], Literal["islower"], Literal["isnumeric"], Literal["isprintable"], Literal["isspace"], Literal["istitle"], Literal["isupper"], Literal["join"], Literal["ljust"], Literal["lower"], Literal["lstrip"], Literal["maketrans"], Literal["partition"], Literal["removeprefix"], Literal["removesuffix"], Literal["replace"], Literal["rfind"], Literal["rindex"], Literal["rjust"], Literal["rpartition"], Literal["rsplit"], Literal["rstrip"], Literal["split"], Literal["splitlines"], Literal["startswith"], Literal["strip"], Literal["swapcase"], Literal["title"], Literal["translate"], Literal["upper"], Literal["zfill"]] +reveal_type(all_members("a")) ``` ## Kinds of types diff --git a/crates/ty_python_semantic/resources/mdtest/loops/async_for.md b/crates/ty_python_semantic/resources/mdtest/loops/async_for.md index 1271e2b41f..29fba1ce77 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/async_for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/async_for.md @@ -42,8 +42,6 @@ async def foo(): ### No `__aiter__` method ```py -from typing_extensions import reveal_type - class NotAsyncIterable: ... async def foo(): @@ -55,8 +53,6 @@ async def foo(): ### Synchronously iterable, but not asynchronously iterable ```py -from typing_extensions import reveal_type - async def foo(): class Iterator: def __next__(self) -> int: @@ -74,8 +70,6 @@ async def foo(): ### No `__anext__` method ```py -from typing_extensions import reveal_type - class NoAnext: ... class AsyncIterable: @@ -91,8 +85,6 @@ async def foo(): ### Possibly missing `__anext__` method ```py -from typing_extensions import reveal_type - async def foo(flag: bool): class PossiblyUnboundAnext: if flag: @@ -111,8 +103,6 @@ async def foo(flag: bool): ### Possibly missing `__aiter__` method ```py -from typing_extensions import reveal_type - async def foo(flag: bool): class AsyncIterable: async def __anext__(self) -> int: @@ -131,8 +121,6 @@ async def foo(flag: bool): ### Wrong signature for `__aiter__` ```py -from typing_extensions import reveal_type - class AsyncIterator: async def __anext__(self) -> int: return 42 @@ -150,8 +138,6 @@ async def foo(): ### Wrong signature for `__anext__` ```py -from typing_extensions import reveal_type - class AsyncIterator: async def __anext__(self, arg: int) -> int: # wrong return 42 diff --git a/crates/ty_python_semantic/resources/mdtest/loops/for.md b/crates/ty_python_semantic/resources/mdtest/loops/for.md index f6300f5975..4066c8e2e3 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/for.md @@ -108,8 +108,6 @@ reveal_type(x) ```py -from typing_extensions import reveal_type - def _(flag: bool): class NotIterable: if flag: @@ -271,8 +269,6 @@ def g( ```py -from typing_extensions import reveal_type - class TestIter: def __next__(self) -> int: return 42 @@ -292,8 +288,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class TestIter: def __next__(self) -> int: return 42 @@ -370,8 +364,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class Iterator: def __next__(self) -> int: return 42 @@ -390,8 +382,6 @@ for x in Iterable(): ```py -from typing_extensions import reveal_type - class Bad: def __iter__(self) -> int: return 42 @@ -424,8 +414,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class Iterator1: def __next__(self, extra_arg) -> int: return 42 @@ -455,8 +443,6 @@ for y in Iterable2(): ```py -from typing_extensions import reveal_type - def _(flag: bool): class Iterator: def __next__(self) -> int: @@ -509,8 +495,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class Iterator: def __next__(self) -> int: return 42 @@ -534,8 +518,6 @@ def _(flag1: bool, flag2: bool): ```py -from typing_extensions import reveal_type - class Bad: __getitem__: None = None @@ -549,8 +531,6 @@ for x in Bad(): ```py -from typing_extensions import reveal_type - def _(flag: bool): class CustomCallable: if flag: @@ -585,8 +565,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class Iterable: # invalid because it will implicitly be passed an `int` # by the interpreter @@ -626,8 +604,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class Iterator: def __next__(self) -> int: return 42 @@ -663,8 +639,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - def _(flag: bool): class Iterator1: if flag: @@ -704,8 +678,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - def _(flag: bool): class Iterable1: if flag: @@ -737,8 +709,6 @@ def _(flag: bool): ```py -from typing_extensions import reveal_type - class Iterator: def __next__(self) -> bytes: return b"foo" diff --git a/crates/ty_python_semantic/resources/mdtest/mro.md b/crates/ty_python_semantic/resources/mdtest/mro.md index a4a9368aa0..e27bc761dd 100644 --- a/crates/ty_python_semantic/resources/mdtest/mro.md +++ b/crates/ty_python_semantic/resources/mdtest/mro.md @@ -241,8 +241,6 @@ find a union type in a class's bases, we infer the class's `__mro__` as being `[, Unknown, object]`, the same as for MROs that cause errors at runtime. ```py -from typing_extensions import reveal_type - def returns_bool() -> bool: return True @@ -391,8 +389,6 @@ class BadSub2(Bad2()): ... # error: [invalid-base] ```py -from typing_extensions import reveal_type - class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md index bd5fc0705d..0d94326c89 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md @@ -281,7 +281,7 @@ def _(x: Foo | Bar, flag: bool) -> None: The `TypeIs` type remains effective across generic boundaries: ```py -from typing_extensions import TypeVar, reveal_type +from typing_extensions import TypeVar T = TypeVar("T") diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 4efb4ebdf0..5a939e4880 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -347,7 +347,7 @@ python-version = "3.12" ``` ```py -from typing_extensions import Protocol, reveal_type +from typing_extensions import Protocol # error: [call-non-callable] reveal_type(Protocol()) # revealed: Unknown @@ -381,9 +381,7 @@ And as a corollary, `type[MyProtocol]` can also be called: ```py def f(x: type[MyProtocol]): - # TODO: add a `reveal_type` call here once it's no longer a `Todo` type - # (which doesn't work well with snapshots) - x() + reveal_type(x()) # revealed: @Todo(type[T] for protocols) ``` ## Members of a protocol @@ -534,7 +532,7 @@ python-version = "3.9" ```py import sys -from typing_extensions import Protocol, get_protocol_members, reveal_type +from typing_extensions import Protocol, get_protocol_members class Foo(Protocol): if sys.version_info >= (3, 10): @@ -2393,7 +2391,7 @@ By default, a protocol class cannot be used as the second argument to `isinstanc type inside these branches (this matches the behavior of other type checkers): ```py -from typing_extensions import Protocol, reveal_type +from typing_extensions import Protocol class HasX(Protocol): x: int diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap deleted file mode 100644 index fb31b51fe8..0000000000 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/all_members.md_-_List_all_members_-_Basic_functionality_(6b9531a70334bfad).snap +++ /dev/null @@ -1,43 +0,0 @@ ---- -source: crates/ty_test/src/lib.rs -expression: snapshot ---- ---- -mdtest name: all_members.md - List all members - Basic functionality -mdtest path: crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md ---- - -# Python source files - -## mdtest_snippet.py - -``` - 1 | from ty_extensions import static_assert, has_member - 2 | - 3 | static_assert(has_member("a", "replace")) - 4 | static_assert(has_member("a", "startswith")) - 5 | static_assert(has_member("a", "isupper")) - 6 | static_assert(has_member("a", "__add__")) - 7 | static_assert(has_member("a", "__gt__")) - 8 | static_assert(has_member("a", "__doc__")) - 9 | static_assert(has_member("a", "__repr__")) -10 | static_assert(not has_member("a", "non_existent")) -11 | from typing_extensions import reveal_type -12 | from ty_extensions import all_members -13 | -14 | reveal_type(all_members("a")) # error: [revealed-type] -``` - -# Diagnostics - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:13 - | -12 | from ty_extensions import all_members -13 | -14 | reveal_type(all_members("a")) # error: [revealed-type] - | ^^^^^^^^^^^^^^^^ `tuple[Literal["__add__"], Literal["__annotations__"], Literal["__class__"], Literal["__contains__"], Literal["__delattr__"], Literal["__dict__"], Literal["__dir__"], Literal["__doc__"], Literal["__eq__"], Literal["__format__"], Literal["__ge__"], Literal["__getattribute__"], Literal["__getitem__"], Literal["__getnewargs__"], Literal["__gt__"], Literal["__hash__"], Literal["__init__"], Literal["__init_subclass__"], Literal["__iter__"], Literal["__le__"], Literal["__len__"], Literal["__lt__"], Literal["__mod__"], Literal["__module__"], Literal["__mul__"], Literal["__ne__"], Literal["__new__"], Literal["__reduce__"], Literal["__reduce_ex__"], Literal["__repr__"], Literal["__reversed__"], Literal["__rmul__"], Literal["__setattr__"], Literal["__sizeof__"], Literal["__str__"], Literal["__subclasshook__"], Literal["capitalize"], Literal["casefold"], Literal["center"], Literal["count"], Literal["encode"], Literal["endswith"], Literal["expandtabs"], Literal["find"], Literal["format"], Literal["format_map"], Literal["index"], Literal["isalnum"], Literal["isalpha"], Literal["isascii"], Literal["isdecimal"], Literal["isdigit"], Literal["isidentifier"], Literal["islower"], Literal["isnumeric"], Literal["isprintable"], Literal["isspace"], Literal["istitle"], Literal["isupper"], Literal["join"], Literal["ljust"], Literal["lower"], Literal["lstrip"], Literal["maketrans"], Literal["partition"], Literal["removeprefix"], Literal["removesuffix"], Literal["replace"], Literal["rfind"], Literal["rindex"], Literal["rjust"], Literal["rpartition"], Literal["rsplit"], Literal["rstrip"], Literal["split"], Literal["splitlines"], Literal["startswith"], Literal["strip"], Literal["swapcase"], Literal["title"], Literal["translate"], Literal["upper"], Literal["zfill"]]` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__aiter__`_metho…_(4fbd80e21774cc23).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__aiter__`_metho…_(4fbd80e21774cc23).snap index 0953c5286e..e5613631f4 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__aiter__`_metho…_(4fbd80e21774cc23).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__aiter__`_metho…_(4fbd80e21774cc23).snap @@ -12,41 +12,27 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` -1 | from typing_extensions import reveal_type +1 | class NotAsyncIterable: ... 2 | -3 | class NotAsyncIterable: ... -4 | -5 | async def foo(): -6 | # error: [not-iterable] "Object of type `NotAsyncIterable` is not async-iterable" -7 | async for x in NotAsyncIterable(): -8 | reveal_type(x) # revealed: Unknown +3 | async def foo(): +4 | # error: [not-iterable] "Object of type `NotAsyncIterable` is not async-iterable" +5 | async for x in NotAsyncIterable(): +6 | reveal_type(x) # revealed: Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `NotAsyncIterable` is not async-iterable - --> src/mdtest_snippet.py:7:20 + --> src/mdtest_snippet.py:5:20 | -5 | async def foo(): -6 | # error: [not-iterable] "Object of type `NotAsyncIterable` is not async-iterable" -7 | async for x in NotAsyncIterable(): +3 | async def foo(): +4 | # error: [not-iterable] "Object of type `NotAsyncIterable` is not async-iterable" +5 | async for x in NotAsyncIterable(): | ^^^^^^^^^^^^^^^^^^ -8 | reveal_type(x) # revealed: Unknown +6 | reveal_type(x) # revealed: Unknown | info: It has no `__aiter__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:8:21 - | -6 | # error: [not-iterable] "Object of type `NotAsyncIterable` is not async-iterable" -7 | async for x in NotAsyncIterable(): -8 | reveal_type(x) # revealed: Unknown - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__anext__`_metho…_(a0b186714127abee).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__anext__`_metho…_(a0b186714127abee).snap index 6994519044..3123e9df2e 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__anext__`_metho…_(a0b186714127abee).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_No_`__anext__`_metho…_(a0b186714127abee).snap @@ -12,45 +12,31 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type + 1 | class NoAnext: ... 2 | - 3 | class NoAnext: ... - 4 | - 5 | class AsyncIterable: - 6 | def __aiter__(self) -> NoAnext: - 7 | return NoAnext() - 8 | - 9 | async def foo(): -10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -11 | async for x in AsyncIterable(): -12 | reveal_type(x) # revealed: Unknown + 3 | class AsyncIterable: + 4 | def __aiter__(self) -> NoAnext: + 5 | return NoAnext() + 6 | + 7 | async def foo(): + 8 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" + 9 | async for x in AsyncIterable(): +10 | reveal_type(x) # revealed: Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `AsyncIterable` is not async-iterable - --> src/mdtest_snippet.py:11:20 + --> src/mdtest_snippet.py:9:20 | - 9 | async def foo(): -10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -11 | async for x in AsyncIterable(): + 7 | async def foo(): + 8 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" + 9 | async for x in AsyncIterable(): | ^^^^^^^^^^^^^^^ -12 | reveal_type(x) # revealed: Unknown +10 | reveal_type(x) # revealed: Unknown | info: Its `__aiter__` method returns an object of type `NoAnext`, which has no `__anext__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:12:21 - | -10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -11 | async for x in AsyncIterable(): -12 | reveal_type(x) # revealed: Unknown - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(33924dbae5117216).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(33924dbae5117216).snap index ddc15768bc..e3197f030d 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(33924dbae5117216).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(33924dbae5117216).snap @@ -12,47 +12,33 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | async def foo(flag: bool): - 4 | class AsyncIterable: - 5 | async def __anext__(self) -> int: - 6 | return 42 - 7 | - 8 | class PossiblyUnboundAiter: - 9 | if flag: -10 | def __aiter__(self) -> AsyncIterable: -11 | return AsyncIterable() -12 | -13 | # error: "Object of type `PossiblyUnboundAiter` may not be async-iterable" -14 | async for x in PossiblyUnboundAiter(): -15 | reveal_type(x) # revealed: int + 1 | async def foo(flag: bool): + 2 | class AsyncIterable: + 3 | async def __anext__(self) -> int: + 4 | return 42 + 5 | + 6 | class PossiblyUnboundAiter: + 7 | if flag: + 8 | def __aiter__(self) -> AsyncIterable: + 9 | return AsyncIterable() +10 | +11 | # error: "Object of type `PossiblyUnboundAiter` may not be async-iterable" +12 | async for x in PossiblyUnboundAiter(): +13 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `PossiblyUnboundAiter` may not be async-iterable - --> src/mdtest_snippet.py:14:20 + --> src/mdtest_snippet.py:12:20 | -13 | # error: "Object of type `PossiblyUnboundAiter` may not be async-iterable" -14 | async for x in PossiblyUnboundAiter(): +11 | # error: "Object of type `PossiblyUnboundAiter` may not be async-iterable" +12 | async for x in PossiblyUnboundAiter(): | ^^^^^^^^^^^^^^^^^^^^^^ -15 | reveal_type(x) # revealed: int +13 | reveal_type(x) # revealed: int | info: Its `__aiter__` attribute (with type `bound method PossiblyUnboundAiter.__aiter__() -> AsyncIterable`) may not be callable info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:15:21 - | -13 | # error: "Object of type `PossiblyUnboundAiter` may not be async-iterable" -14 | async for x in PossiblyUnboundAiter(): -15 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(e2600ca4708d9e54).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(e2600ca4708d9e54).snap index f6e3888116..c563eca460 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(e2600ca4708d9e54).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Possibly_missing_`__…_(e2600ca4708d9e54).snap @@ -12,47 +12,33 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | async def foo(flag: bool): - 4 | class PossiblyUnboundAnext: - 5 | if flag: - 6 | async def __anext__(self) -> int: - 7 | return 42 - 8 | - 9 | class AsyncIterable: -10 | def __aiter__(self) -> PossiblyUnboundAnext: -11 | return PossiblyUnboundAnext() -12 | -13 | # error: [not-iterable] "Object of type `AsyncIterable` may not be async-iterable" -14 | async for x in AsyncIterable(): -15 | reveal_type(x) # revealed: int + 1 | async def foo(flag: bool): + 2 | class PossiblyUnboundAnext: + 3 | if flag: + 4 | async def __anext__(self) -> int: + 5 | return 42 + 6 | + 7 | class AsyncIterable: + 8 | def __aiter__(self) -> PossiblyUnboundAnext: + 9 | return PossiblyUnboundAnext() +10 | +11 | # error: [not-iterable] "Object of type `AsyncIterable` may not be async-iterable" +12 | async for x in AsyncIterable(): +13 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `AsyncIterable` may not be async-iterable - --> src/mdtest_snippet.py:14:20 + --> src/mdtest_snippet.py:12:20 | -13 | # error: [not-iterable] "Object of type `AsyncIterable` may not be async-iterable" -14 | async for x in AsyncIterable(): +11 | # error: [not-iterable] "Object of type `AsyncIterable` may not be async-iterable" +12 | async for x in AsyncIterable(): | ^^^^^^^^^^^^^^^ -15 | reveal_type(x) # revealed: int +13 | reveal_type(x) # revealed: int | info: Its `__aiter__` method returns an object of type `PossiblyUnboundAnext`, which may not have a `__anext__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:15:21 - | -13 | # error: [not-iterable] "Object of type `AsyncIterable` may not be async-iterable" -14 | async for x in AsyncIterable(): -15 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Synchronously_iterab…_(80fa705b1c61d982).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Synchronously_iterab…_(80fa705b1c61d982).snap index 29e0d262df..74d072df7c 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Synchronously_iterab…_(80fa705b1c61d982).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Synchronously_iterab…_(80fa705b1c61d982).snap @@ -12,46 +12,32 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | async def foo(): - 4 | class Iterator: - 5 | def __next__(self) -> int: - 6 | return 42 - 7 | - 8 | class Iterable: - 9 | def __iter__(self) -> Iterator: -10 | return Iterator() -11 | -12 | # error: [not-iterable] "Object of type `Iterator` is not async-iterable" -13 | async for x in Iterator(): -14 | reveal_type(x) # revealed: Unknown + 1 | async def foo(): + 2 | class Iterator: + 3 | def __next__(self) -> int: + 4 | return 42 + 5 | + 6 | class Iterable: + 7 | def __iter__(self) -> Iterator: + 8 | return Iterator() + 9 | +10 | # error: [not-iterable] "Object of type `Iterator` is not async-iterable" +11 | async for x in Iterator(): +12 | reveal_type(x) # revealed: Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterator` is not async-iterable - --> src/mdtest_snippet.py:13:20 + --> src/mdtest_snippet.py:11:20 | -12 | # error: [not-iterable] "Object of type `Iterator` is not async-iterable" -13 | async for x in Iterator(): +10 | # error: [not-iterable] "Object of type `Iterator` is not async-iterable" +11 | async for x in Iterator(): | ^^^^^^^^^^ -14 | reveal_type(x) # revealed: Unknown +12 | reveal_type(x) # revealed: Unknown | info: It has no `__aiter__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:21 - | -12 | # error: [not-iterable] "Object of type `Iterator` is not async-iterable" -13 | async for x in Iterator(): -14 | reveal_type(x) # revealed: Unknown - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(b614724363eec343).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(b614724363eec343).snap index 33e475d546..290aed4af8 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(b614724363eec343).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(b614724363eec343).snap @@ -12,48 +12,34 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class AsyncIterator: - 4 | async def __anext__(self, arg: int) -> int: # wrong - 5 | return 42 - 6 | - 7 | class AsyncIterable: - 8 | def __aiter__(self) -> AsyncIterator: - 9 | return AsyncIterator() -10 | -11 | async def foo(): -12 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -13 | async for x in AsyncIterable(): -14 | reveal_type(x) # revealed: int + 1 | class AsyncIterator: + 2 | async def __anext__(self, arg: int) -> int: # wrong + 3 | return 42 + 4 | + 5 | class AsyncIterable: + 6 | def __aiter__(self) -> AsyncIterator: + 7 | return AsyncIterator() + 8 | + 9 | async def foo(): +10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" +11 | async for x in AsyncIterable(): +12 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `AsyncIterable` is not async-iterable - --> src/mdtest_snippet.py:13:20 + --> src/mdtest_snippet.py:11:20 | -11 | async def foo(): -12 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -13 | async for x in AsyncIterable(): + 9 | async def foo(): +10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" +11 | async for x in AsyncIterable(): | ^^^^^^^^^^^^^^^ -14 | reveal_type(x) # revealed: int +12 | reveal_type(x) # revealed: int | info: Its `__aiter__` method returns an object of type `AsyncIterator`, which has an invalid `__anext__` method info: Expected signature for `__anext__` is `def __anext__(self): ...` info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:21 - | -12 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -13 | async for x in AsyncIterable(): -14 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(e1f3e9275d0a367).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(e1f3e9275d0a367).snap index 3373f0d4ae..6dd3a1bfa0 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(e1f3e9275d0a367).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/async_for.md_-_Async_-_Error_cases_-_Wrong_signature_for_…_(e1f3e9275d0a367).snap @@ -12,48 +12,34 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/async_for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class AsyncIterator: - 4 | async def __anext__(self) -> int: - 5 | return 42 - 6 | - 7 | class AsyncIterable: - 8 | def __aiter__(self, arg: int) -> AsyncIterator: # wrong - 9 | return AsyncIterator() -10 | -11 | async def foo(): -12 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -13 | async for x in AsyncIterable(): -14 | reveal_type(x) # revealed: int + 1 | class AsyncIterator: + 2 | async def __anext__(self) -> int: + 3 | return 42 + 4 | + 5 | class AsyncIterable: + 6 | def __aiter__(self, arg: int) -> AsyncIterator: # wrong + 7 | return AsyncIterator() + 8 | + 9 | async def foo(): +10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" +11 | async for x in AsyncIterable(): +12 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `AsyncIterable` is not async-iterable - --> src/mdtest_snippet.py:13:20 + --> src/mdtest_snippet.py:11:20 | -11 | async def foo(): -12 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -13 | async for x in AsyncIterable(): + 9 | async def foo(): +10 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" +11 | async for x in AsyncIterable(): | ^^^^^^^^^^^^^^^ -14 | reveal_type(x) # revealed: int +12 | reveal_type(x) # revealed: int | info: Its `__aiter__` method has an invalid signature info: Expected signature `def __aiter__(self): ...` info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:21 - | -12 | # error: [not-iterable] "Object of type `AsyncIterable` is not async-iterable" -13 | async for x in AsyncIterable(): -14 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/dataclasses.md_-_Dataclasses_-_`dataclasses.KW_ONLY…_(dd1b8f2f71487f16).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/dataclasses.md_-_Dataclasses_-_`dataclasses.KW_ONLY…_(dd1b8f2f71487f16).snap index 2cd0c99649..02328d725a 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/dataclasses.md_-_Dataclasses_-_`dataclasses.KW_ONLY…_(dd1b8f2f71487f16).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/dataclasses.md_-_Dataclasses_-_`dataclasses.KW_ONLY…_(dd1b8f2f71487f16).snap @@ -13,86 +13,71 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses. ``` 1 | from dataclasses import dataclass, field, KW_ONLY - 2 | from typing_extensions import reveal_type - 3 | - 4 | @dataclass - 5 | class C: - 6 | x: int - 7 | _: KW_ONLY - 8 | y: str - 9 | -10 | reveal_type(C.__init__) # revealed: (self: C, x: int, *, y: str) -> None -11 | -12 | # error: [missing-argument] -13 | # error: [too-many-positional-arguments] -14 | C(3, "") -15 | -16 | C(3, y="") -17 | @dataclass -18 | class Fails: # error: [duplicate-kw-only] -19 | a: int -20 | b: KW_ONLY -21 | c: str -22 | d: KW_ONLY -23 | e: bytes -24 | -25 | reveal_type(Fails.__init__) # revealed: (self: Fails, a: int, *, c: str, e: bytes) -> None -26 | def flag() -> bool: -27 | return True -28 | -29 | @dataclass -30 | class D: # error: [duplicate-kw-only] -31 | x: int -32 | _1: KW_ONLY -33 | -34 | if flag(): -35 | y: str -36 | _2: KW_ONLY -37 | z: float -38 | from dataclasses import dataclass, KW_ONLY -39 | -40 | @dataclass -41 | class D: -42 | x: int -43 | _: KW_ONLY -44 | y: str -45 | -46 | @dataclass -47 | class E(D): -48 | z: bytes -49 | -50 | # This should work: x=1 (positional), z=b"foo" (positional), y="foo" (keyword-only) -51 | E(1, b"foo", y="foo") -52 | -53 | reveal_type(E.__init__) # revealed: (self: E, x: int, z: bytes, *, y: str) -> None + 2 | + 3 | @dataclass + 4 | class C: + 5 | x: int + 6 | _: KW_ONLY + 7 | y: str + 8 | + 9 | reveal_type(C.__init__) # revealed: (self: C, x: int, *, y: str) -> None +10 | +11 | # error: [missing-argument] +12 | # error: [too-many-positional-arguments] +13 | C(3, "") +14 | +15 | C(3, y="") +16 | @dataclass +17 | class Fails: # error: [duplicate-kw-only] +18 | a: int +19 | b: KW_ONLY +20 | c: str +21 | d: KW_ONLY +22 | e: bytes +23 | +24 | reveal_type(Fails.__init__) # revealed: (self: Fails, a: int, *, c: str, e: bytes) -> None +25 | def flag() -> bool: +26 | return True +27 | +28 | @dataclass +29 | class D: # error: [duplicate-kw-only] +30 | x: int +31 | _1: KW_ONLY +32 | +33 | if flag(): +34 | y: str +35 | _2: KW_ONLY +36 | z: float +37 | from dataclasses import dataclass, KW_ONLY +38 | +39 | @dataclass +40 | class D: +41 | x: int +42 | _: KW_ONLY +43 | y: str +44 | +45 | @dataclass +46 | class E(D): +47 | z: bytes +48 | +49 | # This should work: x=1 (positional), z=b"foo" (positional), y="foo" (keyword-only) +50 | E(1, b"foo", y="foo") +51 | +52 | reveal_type(E.__init__) # revealed: (self: E, x: int, z: bytes, *, y: str) -> None ``` # Diagnostics -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:10:13 - | - 8 | y: str - 9 | -10 | reveal_type(C.__init__) # revealed: (self: C, x: int, *, y: str) -> None - | ^^^^^^^^^^ `(self: C, x: int, *, y: str) -> None` -11 | -12 | # error: [missing-argument] - | - -``` - ``` error[missing-argument]: No argument provided for required parameter `y` - --> src/mdtest_snippet.py:14:1 + --> src/mdtest_snippet.py:13:1 | -12 | # error: [missing-argument] -13 | # error: [too-many-positional-arguments] -14 | C(3, "") +11 | # error: [missing-argument] +12 | # error: [too-many-positional-arguments] +13 | C(3, "") | ^^^^^^^^ -15 | -16 | C(3, y="") +14 | +15 | C(3, y="") | info: rule `missing-argument` is enabled by default @@ -100,14 +85,14 @@ info: rule `missing-argument` is enabled by default ``` error[too-many-positional-arguments]: Too many positional arguments: expected 1, got 2 - --> src/mdtest_snippet.py:14:6 + --> src/mdtest_snippet.py:13:6 | -12 | # error: [missing-argument] -13 | # error: [too-many-positional-arguments] -14 | C(3, "") +11 | # error: [missing-argument] +12 | # error: [too-many-positional-arguments] +13 | C(3, "") | ^^ -15 | -16 | C(3, y="") +14 | +15 | C(3, y="") | info: rule `too-many-positional-arguments` is enabled by default @@ -115,57 +100,31 @@ info: rule `too-many-positional-arguments` is enabled by default ``` error[duplicate-kw-only]: Dataclass has more than one field annotated with `KW_ONLY` - --> src/mdtest_snippet.py:18:7 + --> src/mdtest_snippet.py:17:7 | -16 | C(3, y="") -17 | @dataclass -18 | class Fails: # error: [duplicate-kw-only] +15 | C(3, y="") +16 | @dataclass +17 | class Fails: # error: [duplicate-kw-only] | ^^^^^ -19 | a: int -20 | b: KW_ONLY +18 | a: int +19 | b: KW_ONLY | info: `KW_ONLY` fields: `b`, `d` info: rule `duplicate-kw-only` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:25:13 - | -23 | e: bytes -24 | -25 | reveal_type(Fails.__init__) # revealed: (self: Fails, a: int, *, c: str, e: bytes) -> None - | ^^^^^^^^^^^^^^ `(self: Fails, a: int, *, c: str, e: bytes) -> None` -26 | def flag() -> bool: -27 | return True - | - -``` - ``` error[duplicate-kw-only]: Dataclass has more than one field annotated with `KW_ONLY` - --> src/mdtest_snippet.py:30:7 + --> src/mdtest_snippet.py:29:7 | -29 | @dataclass -30 | class D: # error: [duplicate-kw-only] +28 | @dataclass +29 | class D: # error: [duplicate-kw-only] | ^ -31 | x: int -32 | _1: KW_ONLY +30 | x: int +31 | _1: KW_ONLY | info: `KW_ONLY` fields: `_1`, `_2` info: rule `duplicate-kw-only` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:53:13 - | -51 | E(1, b"foo", y="foo") -52 | -53 | reveal_type(E.__init__) # revealed: (self: E, x: int, z: bytes, *, y: str) -> None - | ^^^^^^^^^^ `(self: E, x: int, z: bytes, *, y: str) -> None` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Bad_`__getitem__`_me…_(3ffe352bb3a76715).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Bad_`__getitem__`_me…_(3ffe352bb3a76715).snap index a9104c6526..35b07a080c 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Bad_`__getitem__`_me…_(3ffe352bb3a76715).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Bad_`__getitem__`_me…_(3ffe352bb3a76715).snap @@ -12,44 +12,30 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class Iterable: - 4 | # invalid because it will implicitly be passed an `int` - 5 | # by the interpreter - 6 | def __getitem__(self, key: str) -> int: - 7 | return 42 - 8 | - 9 | # error: [not-iterable] -10 | for x in Iterable(): -11 | reveal_type(x) # revealed: int +1 | class Iterable: +2 | # invalid because it will implicitly be passed an `int` +3 | # by the interpreter +4 | def __getitem__(self, key: str) -> int: +5 | return 42 +6 | +7 | # error: [not-iterable] +8 | for x in Iterable(): +9 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable` is not iterable - --> src/mdtest_snippet.py:10:10 - | - 9 | # error: [not-iterable] -10 | for x in Iterable(): - | ^^^^^^^^^^ -11 | reveal_type(x) # revealed: int - | + --> src/mdtest_snippet.py:8:10 + | +7 | # error: [not-iterable] +8 | for x in Iterable(): + | ^^^^^^^^^^ +9 | reveal_type(x) # revealed: int + | info: It has no `__iter__` method and its `__getitem__` method has an incorrect signature for the old-style iteration protocol info: `__getitem__` must be at least as permissive as `def __getitem__(self, key: int): ...` to satisfy the old-style iteration protocol info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:11:17 - | - 9 | # error: [not-iterable] -10 | for x in Iterable(): -11 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_No_`__iter__`_method…_(36425dbcbd793d2b).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_No_`__iter__`_method…_(36425dbcbd793d2b).snap index ad4ffa4b00..356132982d 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_No_`__iter__`_method…_(36425dbcbd793d2b).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_No_`__iter__`_method…_(36425dbcbd793d2b).snap @@ -12,40 +12,26 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` -1 | from typing_extensions import reveal_type -2 | -3 | class Bad: -4 | __getitem__: None = None -5 | -6 | # error: [not-iterable] -7 | for x in Bad(): -8 | reveal_type(x) # revealed: Unknown +1 | class Bad: +2 | __getitem__: None = None +3 | +4 | # error: [not-iterable] +5 | for x in Bad(): +6 | reveal_type(x) # revealed: Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Bad` is not iterable - --> src/mdtest_snippet.py:7:10 + --> src/mdtest_snippet.py:5:10 | -6 | # error: [not-iterable] -7 | for x in Bad(): +4 | # error: [not-iterable] +5 | for x in Bad(): | ^^^^^ -8 | reveal_type(x) # revealed: Unknown +6 | reveal_type(x) # revealed: Unknown | info: It has no `__iter__` method and its `__getitem__` attribute has type `None`, which is not callable info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:8:17 - | -6 | # error: [not-iterable] -7 | for x in Bad(): -8 | reveal_type(x) # revealed: Unknown - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly-not-callabl…_(49a21e4b7fe6e97b).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly-not-callabl…_(49a21e4b7fe6e97b).snap index 1a20384b0d..58ceae4187 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly-not-callabl…_(49a21e4b7fe6e97b).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly-not-callabl…_(49a21e4b7fe6e97b).snap @@ -12,48 +12,46 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | def _(flag: bool): - 4 | class CustomCallable: - 5 | if flag: - 6 | def __call__(self, *args, **kwargs) -> int: - 7 | return 42 - 8 | else: - 9 | __call__: None = None -10 | -11 | class Iterable1: -12 | __getitem__: CustomCallable = CustomCallable() -13 | -14 | class Iterable2: -15 | if flag: -16 | def __getitem__(self, key: int) -> int: -17 | return 42 -18 | else: -19 | __getitem__: None = None -20 | -21 | # error: [not-iterable] -22 | for x in Iterable1(): -23 | # TODO... `int` might be ideal here? -24 | reveal_type(x) # revealed: int | Unknown -25 | -26 | # error: [not-iterable] -27 | for y in Iterable2(): -28 | # TODO... `int` might be ideal here? -29 | reveal_type(y) # revealed: int | Unknown + 1 | def _(flag: bool): + 2 | class CustomCallable: + 3 | if flag: + 4 | def __call__(self, *args, **kwargs) -> int: + 5 | return 42 + 6 | else: + 7 | __call__: None = None + 8 | + 9 | class Iterable1: +10 | __getitem__: CustomCallable = CustomCallable() +11 | +12 | class Iterable2: +13 | if flag: +14 | def __getitem__(self, key: int) -> int: +15 | return 42 +16 | else: +17 | __getitem__: None = None +18 | +19 | # error: [not-iterable] +20 | for x in Iterable1(): +21 | # TODO... `int` might be ideal here? +22 | reveal_type(x) # revealed: int | Unknown +23 | +24 | # error: [not-iterable] +25 | for y in Iterable2(): +26 | # TODO... `int` might be ideal here? +27 | reveal_type(y) # revealed: int | Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable1` may not be iterable - --> src/mdtest_snippet.py:22:14 + --> src/mdtest_snippet.py:20:14 | -21 | # error: [not-iterable] -22 | for x in Iterable1(): +19 | # error: [not-iterable] +20 | for x in Iterable1(): | ^^^^^^^^^^^ -23 | # TODO... `int` might be ideal here? -24 | reveal_type(x) # revealed: int | Unknown +21 | # TODO... `int` might be ideal here? +22 | reveal_type(x) # revealed: int | Unknown | info: It has no `__iter__` method and its `__getitem__` attribute is invalid info: `__getitem__` has type `CustomCallable`, which is not callable @@ -61,44 +59,18 @@ info: rule `not-iterable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:24:21 - | -22 | for x in Iterable1(): -23 | # TODO... `int` might be ideal here? -24 | reveal_type(x) # revealed: int | Unknown - | ^ `int | Unknown` -25 | -26 | # error: [not-iterable] - | - -``` - ``` error[not-iterable]: Object of type `Iterable2` may not be iterable - --> src/mdtest_snippet.py:27:14 + --> src/mdtest_snippet.py:25:14 | -26 | # error: [not-iterable] -27 | for y in Iterable2(): +24 | # error: [not-iterable] +25 | for y in Iterable2(): | ^^^^^^^^^^^ -28 | # TODO... `int` might be ideal here? -29 | reveal_type(y) # revealed: int | Unknown +26 | # TODO... `int` might be ideal here? +27 | reveal_type(y) # revealed: int | Unknown | info: It has no `__iter__` method and its `__getitem__` attribute is invalid info: `__getitem__` has type `(bound method Iterable2.__getitem__(key: int) -> int) | None`, which is not callable info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:29:21 - | -27 | for y in Iterable2(): -28 | # TODO... `int` might be ideal here? -29 | reveal_type(y) # revealed: int | Unknown - | ^ `int | Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6388761c90a0555c).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6388761c90a0555c).snap index ca18f1184a..d36ae95142 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6388761c90a0555c).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6388761c90a0555c).snap @@ -12,48 +12,46 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class Iterator: - 4 | def __next__(self) -> int: - 5 | return 42 - 6 | - 7 | def _(flag: bool): - 8 | class Iterable1: - 9 | if flag: -10 | def __iter__(self) -> Iterator: -11 | return Iterator() -12 | else: -13 | def __iter__(self, invalid_extra_arg) -> Iterator: -14 | return Iterator() -15 | -16 | # error: [not-iterable] -17 | for x in Iterable1(): -18 | reveal_type(x) # revealed: int -19 | -20 | class Iterable2: -21 | if flag: -22 | def __iter__(self) -> Iterator: -23 | return Iterator() -24 | else: -25 | __iter__: None = None -26 | -27 | # error: [not-iterable] -28 | for x in Iterable2(): -29 | # TODO: `int` would probably be better here: -30 | reveal_type(x) # revealed: int | Unknown + 1 | class Iterator: + 2 | def __next__(self) -> int: + 3 | return 42 + 4 | + 5 | def _(flag: bool): + 6 | class Iterable1: + 7 | if flag: + 8 | def __iter__(self) -> Iterator: + 9 | return Iterator() +10 | else: +11 | def __iter__(self, invalid_extra_arg) -> Iterator: +12 | return Iterator() +13 | +14 | # error: [not-iterable] +15 | for x in Iterable1(): +16 | reveal_type(x) # revealed: int +17 | +18 | class Iterable2: +19 | if flag: +20 | def __iter__(self) -> Iterator: +21 | return Iterator() +22 | else: +23 | __iter__: None = None +24 | +25 | # error: [not-iterable] +26 | for x in Iterable2(): +27 | # TODO: `int` would probably be better here: +28 | reveal_type(x) # revealed: int | Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable1` may not be iterable - --> src/mdtest_snippet.py:17:14 + --> src/mdtest_snippet.py:15:14 | -16 | # error: [not-iterable] -17 | for x in Iterable1(): +14 | # error: [not-iterable] +15 | for x in Iterable1(): | ^^^^^^^^^^^ -18 | reveal_type(x) # revealed: int +16 | reveal_type(x) # revealed: int | info: Its `__iter__` method may have an invalid signature info: Type of `__iter__` is `(bound method Iterable1.__iter__() -> Iterator) | (bound method Iterable1.__iter__(invalid_extra_arg) -> Iterator)` @@ -62,43 +60,17 @@ info: rule `not-iterable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:18:21 - | -16 | # error: [not-iterable] -17 | for x in Iterable1(): -18 | reveal_type(x) # revealed: int - | ^ `int` -19 | -20 | class Iterable2: - | - -``` - ``` error[not-iterable]: Object of type `Iterable2` may not be iterable - --> src/mdtest_snippet.py:28:14 + --> src/mdtest_snippet.py:26:14 | -27 | # error: [not-iterable] -28 | for x in Iterable2(): +25 | # error: [not-iterable] +26 | for x in Iterable2(): | ^^^^^^^^^^^ -29 | # TODO: `int` would probably be better here: -30 | reveal_type(x) # revealed: int | Unknown +27 | # TODO: `int` would probably be better here: +28 | reveal_type(x) # revealed: int | Unknown | info: Its `__iter__` attribute (with type `(bound method Iterable2.__iter__() -> Iterator) | None`) may not be callable info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:30:21 - | -28 | for x in Iterable2(): -29 | # TODO: `int` would probably be better here: -30 | reveal_type(x) # revealed: int | Unknown - | ^ `int | Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6805a6032e504b63).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6805a6032e504b63).snap index ba0f58ca09..dfa5173a1e 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6805a6032e504b63).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(6805a6032e504b63).snap @@ -12,45 +12,43 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | def _(flag: bool): - 4 | class Iterable1: - 5 | if flag: - 6 | def __getitem__(self, item: int) -> str: - 7 | return "foo" - 8 | else: - 9 | __getitem__: None = None -10 | -11 | class Iterable2: -12 | if flag: -13 | def __getitem__(self, item: int) -> str: -14 | return "foo" -15 | else: -16 | def __getitem__(self, item: str) -> int: -17 | return 42 -18 | -19 | # error: [not-iterable] -20 | for x in Iterable1(): -21 | # TODO: `str` might be better -22 | reveal_type(x) # revealed: str | Unknown -23 | -24 | # error: [not-iterable] -25 | for y in Iterable2(): -26 | reveal_type(y) # revealed: str | int + 1 | def _(flag: bool): + 2 | class Iterable1: + 3 | if flag: + 4 | def __getitem__(self, item: int) -> str: + 5 | return "foo" + 6 | else: + 7 | __getitem__: None = None + 8 | + 9 | class Iterable2: +10 | if flag: +11 | def __getitem__(self, item: int) -> str: +12 | return "foo" +13 | else: +14 | def __getitem__(self, item: str) -> int: +15 | return 42 +16 | +17 | # error: [not-iterable] +18 | for x in Iterable1(): +19 | # TODO: `str` might be better +20 | reveal_type(x) # revealed: str | Unknown +21 | +22 | # error: [not-iterable] +23 | for y in Iterable2(): +24 | reveal_type(y) # revealed: str | int ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable1` may not be iterable - --> src/mdtest_snippet.py:20:14 + --> src/mdtest_snippet.py:18:14 | -19 | # error: [not-iterable] -20 | for x in Iterable1(): +17 | # error: [not-iterable] +18 | for x in Iterable1(): | ^^^^^^^^^^^ -21 | # TODO: `str` might be better -22 | reveal_type(x) # revealed: str | Unknown +19 | # TODO: `str` might be better +20 | reveal_type(x) # revealed: str | Unknown | info: It has no `__iter__` method and its `__getitem__` attribute is invalid info: `__getitem__` has type `(bound method Iterable1.__getitem__(item: int) -> str) | None`, which is not callable @@ -58,43 +56,17 @@ info: rule `not-iterable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:22:21 - | -20 | for x in Iterable1(): -21 | # TODO: `str` might be better -22 | reveal_type(x) # revealed: str | Unknown - | ^ `str | Unknown` -23 | -24 | # error: [not-iterable] - | - -``` - ``` error[not-iterable]: Object of type `Iterable2` may not be iterable - --> src/mdtest_snippet.py:25:14 + --> src/mdtest_snippet.py:23:14 | -24 | # error: [not-iterable] -25 | for y in Iterable2(): +22 | # error: [not-iterable] +23 | for y in Iterable2(): | ^^^^^^^^^^^ -26 | reveal_type(y) # revealed: str | int +24 | reveal_type(y) # revealed: str | int | info: It has no `__iter__` method and its `__getitem__` method (with type `(bound method Iterable2.__getitem__(item: int) -> str) | (bound method Iterable2.__getitem__(item: str) -> int)`) may have an incorrect signature for the old-style iteration protocol info: `__getitem__` must be at least as permissive as `def __getitem__(self, key: int): ...` to satisfy the old-style iteration protocol info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:26:21 - | -24 | # error: [not-iterable] -25 | for y in Iterable2(): -26 | reveal_type(y) # revealed: str | int - | ^ `str | int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(c626bde8651b643a).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(c626bde8651b643a).snap index ddeda6f7f9..e4fb5e4576 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(c626bde8651b643a).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__…_(c626bde8651b643a).snap @@ -12,52 +12,50 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | def _(flag: bool): - 4 | class Iterator1: - 5 | if flag: - 6 | def __next__(self) -> int: - 7 | return 42 - 8 | else: - 9 | def __next__(self, invalid_extra_arg) -> str: -10 | return "foo" -11 | -12 | class Iterator2: -13 | if flag: -14 | def __next__(self) -> int: -15 | return 42 -16 | else: -17 | __next__: None = None -18 | -19 | class Iterable1: -20 | def __iter__(self) -> Iterator1: -21 | return Iterator1() -22 | -23 | class Iterable2: -24 | def __iter__(self) -> Iterator2: -25 | return Iterator2() -26 | -27 | # error: [not-iterable] -28 | for x in Iterable1(): -29 | reveal_type(x) # revealed: int | str -30 | -31 | # error: [not-iterable] -32 | for y in Iterable2(): -33 | # TODO: `int` would probably be better here: -34 | reveal_type(y) # revealed: int | Unknown + 1 | def _(flag: bool): + 2 | class Iterator1: + 3 | if flag: + 4 | def __next__(self) -> int: + 5 | return 42 + 6 | else: + 7 | def __next__(self, invalid_extra_arg) -> str: + 8 | return "foo" + 9 | +10 | class Iterator2: +11 | if flag: +12 | def __next__(self) -> int: +13 | return 42 +14 | else: +15 | __next__: None = None +16 | +17 | class Iterable1: +18 | def __iter__(self) -> Iterator1: +19 | return Iterator1() +20 | +21 | class Iterable2: +22 | def __iter__(self) -> Iterator2: +23 | return Iterator2() +24 | +25 | # error: [not-iterable] +26 | for x in Iterable1(): +27 | reveal_type(x) # revealed: int | str +28 | +29 | # error: [not-iterable] +30 | for y in Iterable2(): +31 | # TODO: `int` would probably be better here: +32 | reveal_type(y) # revealed: int | Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable1` may not be iterable - --> src/mdtest_snippet.py:28:14 + --> src/mdtest_snippet.py:26:14 | -27 | # error: [not-iterable] -28 | for x in Iterable1(): +25 | # error: [not-iterable] +26 | for x in Iterable1(): | ^^^^^^^^^^^ -29 | reveal_type(x) # revealed: int | str +27 | reveal_type(x) # revealed: int | str | info: Its `__iter__` method returns an object of type `Iterator1`, which may have an invalid `__next__` method info: Expected signature for `__next__` is `def __next__(self): ...` @@ -65,43 +63,17 @@ info: rule `not-iterable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:29:21 - | -27 | # error: [not-iterable] -28 | for x in Iterable1(): -29 | reveal_type(x) # revealed: int | str - | ^ `int | str` -30 | -31 | # error: [not-iterable] - | - -``` - ``` error[not-iterable]: Object of type `Iterable2` may not be iterable - --> src/mdtest_snippet.py:32:14 + --> src/mdtest_snippet.py:30:14 | -31 | # error: [not-iterable] -32 | for y in Iterable2(): +29 | # error: [not-iterable] +30 | for y in Iterable2(): | ^^^^^^^^^^^ -33 | # TODO: `int` would probably be better here: -34 | reveal_type(y) # revealed: int | Unknown +31 | # TODO: `int` would probably be better here: +32 | reveal_type(y) # revealed: int | Unknown | info: Its `__iter__` method returns an object of type `Iterator2`, which has a `__next__` attribute that may not be callable info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:34:21 - | -32 | for y in Iterable2(): -33 | # TODO: `int` would probably be better here: -34 | reveal_type(y) # revealed: int | Unknown - | ^ `int | Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(77269542b8e81774).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(77269542b8e81774).snap index dde017803c..16b74d788c 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(77269542b8e81774).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(77269542b8e81774).snap @@ -12,99 +12,71 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class Iterator: - 4 | def __next__(self) -> bytes: - 5 | return b"foo" - 6 | - 7 | def _(flag: bool, flag2: bool): - 8 | class Iterable1: - 9 | if flag: -10 | def __getitem__(self, item: int) -> str: -11 | return "foo" -12 | else: -13 | __getitem__: None = None -14 | -15 | if flag2: -16 | def __iter__(self) -> Iterator: -17 | return Iterator() -18 | -19 | class Iterable2: -20 | if flag: -21 | def __getitem__(self, item: int) -> str: -22 | return "foo" -23 | else: -24 | def __getitem__(self, item: str) -> int: -25 | return 42 -26 | if flag2: -27 | def __iter__(self) -> Iterator: -28 | return Iterator() -29 | -30 | # error: [not-iterable] -31 | for x in Iterable1(): -32 | # TODO: `bytes | str` might be better -33 | reveal_type(x) # revealed: bytes | str | Unknown -34 | -35 | # error: [not-iterable] -36 | for y in Iterable2(): -37 | reveal_type(y) # revealed: bytes | str | int + 1 | class Iterator: + 2 | def __next__(self) -> bytes: + 3 | return b"foo" + 4 | + 5 | def _(flag: bool, flag2: bool): + 6 | class Iterable1: + 7 | if flag: + 8 | def __getitem__(self, item: int) -> str: + 9 | return "foo" +10 | else: +11 | __getitem__: None = None +12 | +13 | if flag2: +14 | def __iter__(self) -> Iterator: +15 | return Iterator() +16 | +17 | class Iterable2: +18 | if flag: +19 | def __getitem__(self, item: int) -> str: +20 | return "foo" +21 | else: +22 | def __getitem__(self, item: str) -> int: +23 | return 42 +24 | if flag2: +25 | def __iter__(self) -> Iterator: +26 | return Iterator() +27 | +28 | # error: [not-iterable] +29 | for x in Iterable1(): +30 | # TODO: `bytes | str` might be better +31 | reveal_type(x) # revealed: bytes | str | Unknown +32 | +33 | # error: [not-iterable] +34 | for y in Iterable2(): +35 | reveal_type(y) # revealed: bytes | str | int ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable1` may not be iterable - --> src/mdtest_snippet.py:31:14 + --> src/mdtest_snippet.py:29:14 | -30 | # error: [not-iterable] -31 | for x in Iterable1(): +28 | # error: [not-iterable] +29 | for x in Iterable1(): | ^^^^^^^^^^^ -32 | # TODO: `bytes | str` might be better -33 | reveal_type(x) # revealed: bytes | str | Unknown +30 | # TODO: `bytes | str` might be better +31 | reveal_type(x) # revealed: bytes | str | Unknown | info: It may not have an `__iter__` method and its `__getitem__` attribute (with type `(bound method Iterable1.__getitem__(item: int) -> str) | None`) may not be callable info: rule `not-iterable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:33:21 - | -31 | for x in Iterable1(): -32 | # TODO: `bytes | str` might be better -33 | reveal_type(x) # revealed: bytes | str | Unknown - | ^ `bytes | str | Unknown` -34 | -35 | # error: [not-iterable] - | - -``` - ``` error[not-iterable]: Object of type `Iterable2` may not be iterable - --> src/mdtest_snippet.py:36:14 + --> src/mdtest_snippet.py:34:14 | -35 | # error: [not-iterable] -36 | for y in Iterable2(): +33 | # error: [not-iterable] +34 | for y in Iterable2(): | ^^^^^^^^^^^ -37 | reveal_type(y) # revealed: bytes | str | int +35 | reveal_type(y) # revealed: bytes | str | int | info: It may not have an `__iter__` method and its `__getitem__` method (with type `(bound method Iterable2.__getitem__(item: int) -> str) | (bound method Iterable2.__getitem__(item: str) -> int)`) may have an incorrect signature for the old-style iteration protocol info: `__getitem__` must be at least as permissive as `def __getitem__(self, key: int): ...` to satisfy the old-style iteration protocol info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:37:21 - | -35 | # error: [not-iterable] -36 | for y in Iterable2(): -37 | reveal_type(y) # revealed: bytes | str | int - | ^ `bytes | str | int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(9f781babda99d74b).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(9f781babda99d74b).snap index b2bfc28b10..2e0457bc4d 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(9f781babda99d74b).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(9f781babda99d74b).snap @@ -12,52 +12,38 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | def _(flag: bool): - 4 | class Iterator: - 5 | def __next__(self) -> int: - 6 | return 42 - 7 | - 8 | class Iterable: - 9 | if flag: -10 | def __iter__(self) -> Iterator: -11 | return Iterator() -12 | # invalid signature because it only accepts a `str`, -13 | # but the old-style iteration protocol will pass it an `int` -14 | def __getitem__(self, key: str) -> bytes: -15 | return bytes() -16 | -17 | # error: [not-iterable] -18 | for x in Iterable(): -19 | reveal_type(x) # revealed: int | bytes + 1 | def _(flag: bool): + 2 | class Iterator: + 3 | def __next__(self) -> int: + 4 | return 42 + 5 | + 6 | class Iterable: + 7 | if flag: + 8 | def __iter__(self) -> Iterator: + 9 | return Iterator() +10 | # invalid signature because it only accepts a `str`, +11 | # but the old-style iteration protocol will pass it an `int` +12 | def __getitem__(self, key: str) -> bytes: +13 | return bytes() +14 | +15 | # error: [not-iterable] +16 | for x in Iterable(): +17 | reveal_type(x) # revealed: int | bytes ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable` may not be iterable - --> src/mdtest_snippet.py:18:14 + --> src/mdtest_snippet.py:16:14 | -17 | # error: [not-iterable] -18 | for x in Iterable(): +15 | # error: [not-iterable] +16 | for x in Iterable(): | ^^^^^^^^^^ -19 | reveal_type(x) # revealed: int | bytes +17 | reveal_type(x) # revealed: int | bytes | info: It may not have an `__iter__` method and its `__getitem__` method has an incorrect signature for the old-style iteration protocol info: `__getitem__` must be at least as permissive as `def __getitem__(self, key: int): ...` to satisfy the old-style iteration protocol info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:19:21 - | -17 | # error: [not-iterable] -18 | for x in Iterable(): -19 | reveal_type(x) # revealed: int | bytes - | ^ `int | bytes` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(d8a02a0fcbb390a3).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(d8a02a0fcbb390a3).snap index 41e7b6a425..b41da99a08 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(d8a02a0fcbb390a3).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_missing_`__…_(d8a02a0fcbb390a3).snap @@ -12,50 +12,36 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class Iterator: - 4 | def __next__(self) -> int: - 5 | return 42 - 6 | - 7 | def _(flag1: bool, flag2: bool): - 8 | class Iterable: - 9 | if flag1: -10 | def __iter__(self) -> Iterator: -11 | return Iterator() -12 | if flag2: -13 | def __getitem__(self, key: int) -> bytes: -14 | return bytes() -15 | -16 | # error: [not-iterable] -17 | for x in Iterable(): -18 | reveal_type(x) # revealed: int | bytes + 1 | class Iterator: + 2 | def __next__(self) -> int: + 3 | return 42 + 4 | + 5 | def _(flag1: bool, flag2: bool): + 6 | class Iterable: + 7 | if flag1: + 8 | def __iter__(self) -> Iterator: + 9 | return Iterator() +10 | if flag2: +11 | def __getitem__(self, key: int) -> bytes: +12 | return bytes() +13 | +14 | # error: [not-iterable] +15 | for x in Iterable(): +16 | reveal_type(x) # revealed: int | bytes ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable` may not be iterable - --> src/mdtest_snippet.py:17:14 + --> src/mdtest_snippet.py:15:14 | -16 | # error: [not-iterable] -17 | for x in Iterable(): +14 | # error: [not-iterable] +15 | for x in Iterable(): | ^^^^^^^^^^ -18 | reveal_type(x) # revealed: int | bytes +16 | reveal_type(x) # revealed: int | bytes | info: It may not have an `__iter__` method or a `__getitem__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:18:21 - | -16 | # error: [not-iterable] -17 | for x in Iterable(): -18 | reveal_type(x) # revealed: int | bytes - | ^ `int | bytes` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(6177bb6d13a22241).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(6177bb6d13a22241).snap index e7ab06ed09..0a5173b109 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(6177bb6d13a22241).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(6177bb6d13a22241).snap @@ -12,52 +12,38 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class TestIter: - 4 | def __next__(self) -> int: - 5 | return 42 - 6 | - 7 | class Test: - 8 | def __iter__(self) -> TestIter: - 9 | return TestIter() -10 | -11 | class Test2: -12 | def __iter__(self) -> int: -13 | return 42 -14 | -15 | def _(flag: bool): -16 | # TODO: Improve error message to state which union variant isn't iterable (https://github.com/astral-sh/ruff/issues/13989) -17 | # error: [not-iterable] -18 | for x in Test() if flag else Test2(): -19 | reveal_type(x) # revealed: int + 1 | class TestIter: + 2 | def __next__(self) -> int: + 3 | return 42 + 4 | + 5 | class Test: + 6 | def __iter__(self) -> TestIter: + 7 | return TestIter() + 8 | + 9 | class Test2: +10 | def __iter__(self) -> int: +11 | return 42 +12 | +13 | def _(flag: bool): +14 | # TODO: Improve error message to state which union variant isn't iterable (https://github.com/astral-sh/ruff/issues/13989) +15 | # error: [not-iterable] +16 | for x in Test() if flag else Test2(): +17 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `Test | Test2` may not be iterable - --> src/mdtest_snippet.py:18:14 + --> src/mdtest_snippet.py:16:14 | -16 | # TODO: Improve error message to state which union variant isn't iterable (https://github.com/astral-sh/ruff/issues/13989) -17 | # error: [not-iterable] -18 | for x in Test() if flag else Test2(): +14 | # TODO: Improve error message to state which union variant isn't iterable (https://github.com/astral-sh/ruff/issues/13989) +15 | # error: [not-iterable] +16 | for x in Test() if flag else Test2(): | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -19 | reveal_type(x) # revealed: int +17 | reveal_type(x) # revealed: int | info: Its `__iter__` method returns an object of type `TestIter | int`, which may not have a `__next__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:19:21 - | -17 | # error: [not-iterable] -18 | for x in Test() if flag else Test2(): -19 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(ba36fbef63a14969).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(ba36fbef63a14969).snap index 8eb966da9d..7a9a395bd4 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(ba36fbef63a14969).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Union_type_as_iterab…_(ba36fbef63a14969).snap @@ -12,47 +12,33 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class TestIter: - 4 | def __next__(self) -> int: - 5 | return 42 - 6 | - 7 | class Test: - 8 | def __iter__(self) -> TestIter: - 9 | return TestIter() -10 | -11 | def _(flag: bool): -12 | # error: [not-iterable] -13 | for x in Test() if flag else 42: -14 | reveal_type(x) # revealed: int + 1 | class TestIter: + 2 | def __next__(self) -> int: + 3 | return 42 + 4 | + 5 | class Test: + 6 | def __iter__(self) -> TestIter: + 7 | return TestIter() + 8 | + 9 | def _(flag: bool): +10 | # error: [not-iterable] +11 | for x in Test() if flag else 42: +12 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `Test | Literal[42]` may not be iterable - --> src/mdtest_snippet.py:13:14 + --> src/mdtest_snippet.py:11:14 | -11 | def _(flag: bool): -12 | # error: [not-iterable] -13 | for x in Test() if flag else 42: + 9 | def _(flag: bool): +10 | # error: [not-iterable] +11 | for x in Test() if flag else 42: | ^^^^^^^^^^^^^^^^^^^^^^ -14 | reveal_type(x) # revealed: int +12 | reveal_type(x) # revealed: int | info: It may not have an `__iter__` method and it doesn't have a `__getitem__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:21 - | -12 | # error: [not-iterable] -13 | for x in Test() if flag else 42: -14 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_With_non-callable_it…_(a1cdf01ad69ac37c).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_With_non-callable_it…_(a1cdf01ad69ac37c).snap index 3e5d3bf4eb..52f88e823a 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_With_non-callable_it…_(a1cdf01ad69ac37c).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_With_non-callable_it…_(a1cdf01ad69ac37c).snap @@ -12,34 +12,32 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | def _(flag: bool): - 4 | class NotIterable: - 5 | if flag: - 6 | __iter__: int = 1 - 7 | else: - 8 | __iter__: None = None - 9 | -10 | # error: [not-iterable] -11 | for x in NotIterable(): -12 | pass -13 | -14 | # revealed: Unknown -15 | # error: [possibly-unresolved-reference] -16 | reveal_type(x) + 1 | def _(flag: bool): + 2 | class NotIterable: + 3 | if flag: + 4 | __iter__: int = 1 + 5 | else: + 6 | __iter__: None = None + 7 | + 8 | # error: [not-iterable] + 9 | for x in NotIterable(): +10 | pass +11 | +12 | # revealed: Unknown +13 | # error: [possibly-unresolved-reference] +14 | reveal_type(x) ``` # Diagnostics ``` error[not-iterable]: Object of type `NotIterable` is not iterable - --> src/mdtest_snippet.py:11:14 + --> src/mdtest_snippet.py:9:14 | -10 | # error: [not-iterable] -11 | for x in NotIterable(): + 8 | # error: [not-iterable] + 9 | for x in NotIterable(): | ^^^^^^^^^^^^^ -12 | pass +10 | pass | info: Its `__iter__` attribute has type `int | None`, which is not callable info: rule `not-iterable` is enabled by default @@ -48,25 +46,13 @@ info: rule `not-iterable` is enabled by default ``` info[possibly-unresolved-reference]: Name `x` used when possibly not defined - --> src/mdtest_snippet.py:16:17 + --> src/mdtest_snippet.py:14:17 | -14 | # revealed: Unknown -15 | # error: [possibly-unresolved-reference] -16 | reveal_type(x) +12 | # revealed: Unknown +13 | # error: [possibly-unresolved-reference] +14 | reveal_type(x) | ^ | info: rule `possibly-unresolved-reference` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:16:17 - | -14 | # revealed: Unknown -15 | # error: [possibly-unresolved-reference] -16 | reveal_type(x) - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_does_not_…_(92e3fdd69edad63d).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_does_not_…_(92e3fdd69edad63d).snap index 25942db89e..05eea5a6ca 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_does_not_…_(92e3fdd69edad63d).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_does_not_…_(92e3fdd69edad63d).snap @@ -12,41 +12,27 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` -1 | from typing_extensions import reveal_type -2 | -3 | class Bad: -4 | def __iter__(self) -> int: -5 | return 42 -6 | -7 | # error: [not-iterable] -8 | for x in Bad(): -9 | reveal_type(x) # revealed: Unknown +1 | class Bad: +2 | def __iter__(self) -> int: +3 | return 42 +4 | +5 | # error: [not-iterable] +6 | for x in Bad(): +7 | reveal_type(x) # revealed: Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Bad` is not iterable - --> src/mdtest_snippet.py:8:10 + --> src/mdtest_snippet.py:6:10 | -7 | # error: [not-iterable] -8 | for x in Bad(): +5 | # error: [not-iterable] +6 | for x in Bad(): | ^^^^^ -9 | reveal_type(x) # revealed: Unknown +7 | reveal_type(x) # revealed: Unknown | info: Its `__iter__` method returns an object of type `int`, which has no `__next__` method info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:9:17 - | -7 | # error: [not-iterable] -8 | for x in Bad(): -9 | reveal_type(x) # revealed: Unknown - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_method_wi…_(1136c0e783d61ba4).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_method_wi…_(1136c0e783d61ba4).snap index e37675bbc2..340c169cde 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_method_wi…_(1136c0e783d61ba4).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_method_wi…_(1136c0e783d61ba4).snap @@ -12,46 +12,32 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class Iterator: - 4 | def __next__(self) -> int: - 5 | return 42 - 6 | - 7 | class Iterable: - 8 | def __iter__(self, extra_arg) -> Iterator: - 9 | return Iterator() -10 | -11 | # error: [not-iterable] -12 | for x in Iterable(): -13 | reveal_type(x) # revealed: int + 1 | class Iterator: + 2 | def __next__(self) -> int: + 3 | return 42 + 4 | + 5 | class Iterable: + 6 | def __iter__(self, extra_arg) -> Iterator: + 7 | return Iterator() + 8 | + 9 | # error: [not-iterable] +10 | for x in Iterable(): +11 | reveal_type(x) # revealed: int ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable` is not iterable - --> src/mdtest_snippet.py:12:10 + --> src/mdtest_snippet.py:10:10 | -11 | # error: [not-iterable] -12 | for x in Iterable(): + 9 | # error: [not-iterable] +10 | for x in Iterable(): | ^^^^^^^^^^ -13 | reveal_type(x) # revealed: int +11 | reveal_type(x) # revealed: int | info: Its `__iter__` method has an invalid signature info: Expected signature `def __iter__(self): ...` info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:13:17 - | -11 | # error: [not-iterable] -12 | for x in Iterable(): -13 | reveal_type(x) # revealed: int - | ^ `int` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_returns_a…_(707bd02a22c4acc8).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_returns_a…_(707bd02a22c4acc8).snap index b5c24e5283..e29a88efa8 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_returns_a…_(707bd02a22c4acc8).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_`__iter__`_returns_a…_(707bd02a22c4acc8).snap @@ -12,42 +12,40 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/loops/for.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | class Iterator1: - 4 | def __next__(self, extra_arg) -> int: - 5 | return 42 - 6 | - 7 | class Iterator2: - 8 | __next__: None = None - 9 | -10 | class Iterable1: -11 | def __iter__(self) -> Iterator1: -12 | return Iterator1() -13 | -14 | class Iterable2: -15 | def __iter__(self) -> Iterator2: -16 | return Iterator2() -17 | -18 | # error: [not-iterable] -19 | for x in Iterable1(): -20 | reveal_type(x) # revealed: int -21 | -22 | # error: [not-iterable] -23 | for y in Iterable2(): -24 | reveal_type(y) # revealed: Unknown + 1 | class Iterator1: + 2 | def __next__(self, extra_arg) -> int: + 3 | return 42 + 4 | + 5 | class Iterator2: + 6 | __next__: None = None + 7 | + 8 | class Iterable1: + 9 | def __iter__(self) -> Iterator1: +10 | return Iterator1() +11 | +12 | class Iterable2: +13 | def __iter__(self) -> Iterator2: +14 | return Iterator2() +15 | +16 | # error: [not-iterable] +17 | for x in Iterable1(): +18 | reveal_type(x) # revealed: int +19 | +20 | # error: [not-iterable] +21 | for y in Iterable2(): +22 | reveal_type(y) # revealed: Unknown ``` # Diagnostics ``` error[not-iterable]: Object of type `Iterable1` is not iterable - --> src/mdtest_snippet.py:19:10 + --> src/mdtest_snippet.py:17:10 | -18 | # error: [not-iterable] -19 | for x in Iterable1(): +16 | # error: [not-iterable] +17 | for x in Iterable1(): | ^^^^^^^^^^^ -20 | reveal_type(x) # revealed: int +18 | reveal_type(x) # revealed: int | info: Its `__iter__` method returns an object of type `Iterator1`, which has an invalid `__next__` method info: Expected signature for `__next__` is `def __next__(self): ...` @@ -55,42 +53,16 @@ info: rule `not-iterable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:20:17 - | -18 | # error: [not-iterable] -19 | for x in Iterable1(): -20 | reveal_type(x) # revealed: int - | ^ `int` -21 | -22 | # error: [not-iterable] - | - -``` - ``` error[not-iterable]: Object of type `Iterable2` is not iterable - --> src/mdtest_snippet.py:23:10 + --> src/mdtest_snippet.py:21:10 | -22 | # error: [not-iterable] -23 | for y in Iterable2(): +20 | # error: [not-iterable] +21 | for y in Iterable2(): | ^^^^^^^^^^^ -24 | reveal_type(y) # revealed: Unknown +22 | reveal_type(y) # revealed: Unknown | info: Its `__iter__` method returns an object of type `Iterator2`, which has a `__next__` attribute that is not callable info: rule `not-iterable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:24:17 - | -22 | # error: [not-iterable] -23 | for y in Iterable2(): -24 | reveal_type(y) # revealed: Unknown - | ^ `Unknown` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_bound_ty…_(d50204b9d91b7bd1).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_bound_ty…_(d50204b9d91b7bd1).snap index f5e6aa220a..f84cd7ec25 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_bound_ty…_(d50204b9d91b7bd1).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_bound_ty…_(d50204b9d91b7bd1).snap @@ -13,78 +13,38 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/legacy/function ``` 1 | from typing import TypeVar - 2 | from typing_extensions import reveal_type - 3 | - 4 | T = TypeVar("T", bound=int) - 5 | - 6 | def f(x: T) -> T: - 7 | return x - 8 | - 9 | reveal_type(f(1)) # revealed: Literal[1] -10 | reveal_type(f(True)) # revealed: Literal[True] -11 | # error: [invalid-argument-type] -12 | reveal_type(f("string")) # revealed: Unknown + 2 | + 3 | T = TypeVar("T", bound=int) + 4 | + 5 | def f(x: T) -> T: + 6 | return x + 7 | + 8 | reveal_type(f(1)) # revealed: Literal[1] + 9 | reveal_type(f(True)) # revealed: Literal[True] +10 | # error: [invalid-argument-type] +11 | reveal_type(f("string")) # revealed: Unknown ``` # Diagnostics -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:9:13 - | - 7 | return x - 8 | - 9 | reveal_type(f(1)) # revealed: Literal[1] - | ^^^^ `Literal[1]` -10 | reveal_type(f(True)) # revealed: Literal[True] -11 | # error: [invalid-argument-type] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:10:13 - | - 9 | reveal_type(f(1)) # revealed: Literal[1] -10 | reveal_type(f(True)) # revealed: Literal[True] - | ^^^^^^^ `Literal[True]` -11 | # error: [invalid-argument-type] -12 | reveal_type(f("string")) # revealed: Unknown - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:12:13 - | -10 | reveal_type(f(True)) # revealed: Literal[True] -11 | # error: [invalid-argument-type] -12 | reveal_type(f("string")) # revealed: Unknown - | ^^^^^^^^^^^ `Unknown` - | - -``` - ``` error[invalid-argument-type]: Argument to function `f` is incorrect - --> src/mdtest_snippet.py:12:15 + --> src/mdtest_snippet.py:11:15 | -10 | reveal_type(f(True)) # revealed: Literal[True] -11 | # error: [invalid-argument-type] -12 | reveal_type(f("string")) # revealed: Unknown + 9 | reveal_type(f(True)) # revealed: Literal[True] +10 | # error: [invalid-argument-type] +11 | reveal_type(f("string")) # revealed: Unknown | ^^^^^^^^ Argument type `Literal["string"]` does not satisfy upper bound `int` of type variable `T` | info: Type variable defined here - --> src/mdtest_snippet.py:4:1 + --> src/mdtest_snippet.py:3:1 | -2 | from typing_extensions import reveal_type -3 | -4 | T = TypeVar("T", bound=int) +1 | from typing import TypeVar +2 | +3 | T = TypeVar("T", bound=int) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -5 | -6 | def f(x: T) -> T: +4 | +5 | def f(x: T) -> T: | info: rule `invalid-argument-type` is enabled by default diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_constrai…_(48ab83f977c109b4).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_constrai…_(48ab83f977c109b4).snap index bcba88dbd9..b0594ecca1 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_constrai…_(48ab83f977c109b4).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___L…_-_Inferring_a_constrai…_(48ab83f977c109b4).snap @@ -13,93 +13,39 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/legacy/function ``` 1 | from typing import TypeVar - 2 | from typing_extensions import reveal_type - 3 | - 4 | T = TypeVar("T", int, None) - 5 | - 6 | def f(x: T) -> T: - 7 | return x - 8 | - 9 | reveal_type(f(1)) # revealed: int -10 | reveal_type(f(True)) # revealed: int -11 | reveal_type(f(None)) # revealed: None -12 | # error: [invalid-argument-type] -13 | reveal_type(f("string")) # revealed: Unknown + 2 | + 3 | T = TypeVar("T", int, None) + 4 | + 5 | def f(x: T) -> T: + 6 | return x + 7 | + 8 | reveal_type(f(1)) # revealed: int + 9 | reveal_type(f(True)) # revealed: int +10 | reveal_type(f(None)) # revealed: None +11 | # error: [invalid-argument-type] +12 | reveal_type(f("string")) # revealed: Unknown ``` # Diagnostics -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:9:13 - | - 7 | return x - 8 | - 9 | reveal_type(f(1)) # revealed: int - | ^^^^ `int` -10 | reveal_type(f(True)) # revealed: int -11 | reveal_type(f(None)) # revealed: None - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:10:13 - | - 9 | reveal_type(f(1)) # revealed: int -10 | reveal_type(f(True)) # revealed: int - | ^^^^^^^ `int` -11 | reveal_type(f(None)) # revealed: None -12 | # error: [invalid-argument-type] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:11:13 - | - 9 | reveal_type(f(1)) # revealed: int -10 | reveal_type(f(True)) # revealed: int -11 | reveal_type(f(None)) # revealed: None - | ^^^^^^^ `None` -12 | # error: [invalid-argument-type] -13 | reveal_type(f("string")) # revealed: Unknown - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:13:13 - | -11 | reveal_type(f(None)) # revealed: None -12 | # error: [invalid-argument-type] -13 | reveal_type(f("string")) # revealed: Unknown - | ^^^^^^^^^^^ `Unknown` - | - -``` - ``` error[invalid-argument-type]: Argument to function `f` is incorrect - --> src/mdtest_snippet.py:13:15 + --> src/mdtest_snippet.py:12:15 | -11 | reveal_type(f(None)) # revealed: None -12 | # error: [invalid-argument-type] -13 | reveal_type(f("string")) # revealed: Unknown +10 | reveal_type(f(None)) # revealed: None +11 | # error: [invalid-argument-type] +12 | reveal_type(f("string")) # revealed: Unknown | ^^^^^^^^ Argument type `Literal["string"]` does not satisfy constraints (`int`, `None`) of type variable `T` | info: Type variable defined here - --> src/mdtest_snippet.py:4:1 + --> src/mdtest_snippet.py:3:1 | -2 | from typing_extensions import reveal_type -3 | -4 | T = TypeVar("T", int, None) +1 | from typing import TypeVar +2 | +3 | T = TypeVar("T", int, None) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -5 | -6 | def f(x: T) -> T: +4 | +5 | def f(x: T) -> T: | info: rule `invalid-argument-type` is enabled by default diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_bound_ty…_(5935d14c26afe407).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_bound_ty…_(5935d14c26afe407).snap index a4dd346d6c..4ee7b9d656 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_bound_ty…_(5935d14c26afe407).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_bound_ty…_(5935d14c26afe407).snap @@ -25,45 +25,6 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/function # Diagnostics -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:6:13 - | -4 | return x -5 | -6 | reveal_type(f(1)) # revealed: Literal[1] - | ^^^^ `Literal[1]` -7 | reveal_type(f(True)) # revealed: Literal[True] -8 | # error: [invalid-argument-type] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:7:13 - | -6 | reveal_type(f(1)) # revealed: Literal[1] -7 | reveal_type(f(True)) # revealed: Literal[True] - | ^^^^^^^ `Literal[True]` -8 | # error: [invalid-argument-type] -9 | reveal_type(f("string")) # revealed: Unknown - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:9:13 - | -7 | reveal_type(f(True)) # revealed: Literal[True] -8 | # error: [invalid-argument-type] -9 | reveal_type(f("string")) # revealed: Unknown - | ^^^^^^^^^^^ `Unknown` - | - -``` - ``` error[invalid-argument-type]: Argument to function `f` is incorrect --> src/mdtest_snippet.py:9:15 diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_constrai…_(d2c475fccc70a8e2).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_constrai…_(d2c475fccc70a8e2).snap index 24ef2ebfb0..36c8170f75 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_constrai…_(d2c475fccc70a8e2).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/functions.md_-_Generic_functions___P…_-_Inferring_a_constrai…_(d2c475fccc70a8e2).snap @@ -26,59 +26,6 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/function # Diagnostics -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:6:13 - | -4 | return x -5 | -6 | reveal_type(f(1)) # revealed: int - | ^^^^ `int` -7 | reveal_type(f(True)) # revealed: int -8 | reveal_type(f(None)) # revealed: None - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:7:13 - | -6 | reveal_type(f(1)) # revealed: int -7 | reveal_type(f(True)) # revealed: int - | ^^^^^^^ `int` -8 | reveal_type(f(None)) # revealed: None -9 | # error: [invalid-argument-type] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:8:13 - | - 6 | reveal_type(f(1)) # revealed: int - 7 | reveal_type(f(True)) # revealed: int - 8 | reveal_type(f(None)) # revealed: None - | ^^^^^^^ `None` - 9 | # error: [invalid-argument-type] -10 | reveal_type(f("string")) # revealed: Unknown - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:10:13 - | - 8 | reveal_type(f(None)) # revealed: None - 9 | # error: [invalid-argument-type] -10 | reveal_type(f("string")) # revealed: Unknown - | ^^^^^^^^^^^ `Unknown` - | - -``` - ``` error[invalid-argument-type]: Argument to function `f` is incorrect --> src/mdtest_snippet.py:10:15 diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_includes…_(d2532518c44112c8).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_includes…_(d2532518c44112c8).snap index 1b60443d4d..ecdcac656c 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_includes…_(d2532518c44112c8).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_includes…_(d2532518c44112c8).snap @@ -12,67 +12,39 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type - 2 | - 3 | def returns_bool() -> bool: - 4 | return True - 5 | - 6 | class A: ... - 7 | class B: ... - 8 | - 9 | if returns_bool(): -10 | x = A -11 | else: -12 | x = B + 1 | def returns_bool() -> bool: + 2 | return True + 3 | + 4 | class A: ... + 5 | class B: ... + 6 | + 7 | if returns_bool(): + 8 | x = A + 9 | else: +10 | x = B +11 | +12 | reveal_type(x) # revealed: | 13 | -14 | reveal_type(x) # revealed: | -15 | -16 | # error: 11 [unsupported-base] "Unsupported class base with type ` | `" -17 | class Foo(x): ... -18 | -19 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] +14 | # error: 11 [unsupported-base] "Unsupported class base with type ` | `" +15 | class Foo(x): ... +16 | +17 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] ``` # Diagnostics -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:13 - | -12 | x = B -13 | -14 | reveal_type(x) # revealed: | - | ^ ` | ` -15 | -16 | # error: 11 [unsupported-base] "Unsupported class base with type ` | `" - | - -``` - ``` warning[unsupported-base]: Unsupported class base with type ` | ` - --> src/mdtest_snippet.py:17:11 + --> src/mdtest_snippet.py:15:11 | -16 | # error: 11 [unsupported-base] "Unsupported class base with type ` | `" -17 | class Foo(x): ... +14 | # error: 11 [unsupported-base] "Unsupported class base with type ` | `" +15 | class Foo(x): ... | ^ -18 | -19 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] +16 | +17 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] | info: ty cannot resolve a consistent MRO for class `Foo` due to this base info: Only class objects or `Any` are supported as class bases info: rule `unsupported-base` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:19:13 - | -17 | class Foo(x): ... -18 | -19 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] - | ^^^^^^^^^^^ `tuple[, Unknown, ]` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_lists_wi…_(ea7ebc83ec359b54).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_lists_wi…_(ea7ebc83ec359b54).snap index fceb6462c8..4f216ab4a8 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_lists_wi…_(ea7ebc83ec359b54).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/mro.md_-_Method_Resolution_Or…_-_`__bases__`_lists_wi…_(ea7ebc83ec359b54).snap @@ -12,167 +12,147 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import reveal_type + 1 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" 2 | - 3 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" + 3 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] 4 | - 5 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] - 6 | - 7 | class Spam: ... - 8 | class Eggs: ... - 9 | class Bar: ... -10 | class Baz: ... + 5 | class Spam: ... + 6 | class Eggs: ... + 7 | class Bar: ... + 8 | class Baz: ... + 9 | +10 | # fmt: off 11 | -12 | # fmt: off -13 | -14 | # error: [duplicate-base] "Duplicate base class `Spam`" -15 | # error: [duplicate-base] "Duplicate base class `Eggs`" -16 | class Ham( -17 | Spam, -18 | Eggs, -19 | Bar, -20 | Baz, -21 | Spam, -22 | Eggs, -23 | ): ... +12 | # error: [duplicate-base] "Duplicate base class `Spam`" +13 | # error: [duplicate-base] "Duplicate base class `Eggs`" +14 | class Ham( +15 | Spam, +16 | Eggs, +17 | Bar, +18 | Baz, +19 | Spam, +20 | Eggs, +21 | ): ... +22 | +23 | # fmt: on 24 | -25 | # fmt: on +25 | reveal_type(Ham.__mro__) # revealed: tuple[, Unknown, ] 26 | -27 | reveal_type(Ham.__mro__) # revealed: tuple[, Unknown, ] -28 | -29 | class Mushrooms: ... -30 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] +27 | class Mushrooms: ... +28 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] +29 | +30 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] 31 | -32 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] +32 | # fmt: off 33 | -34 | # fmt: off -35 | -36 | # error: [duplicate-base] "Duplicate base class `Eggs`" -37 | class VeryEggyOmelette( -38 | Eggs, -39 | Ham, -40 | Spam, -41 | Eggs, -42 | Mushrooms, -43 | Bar, +34 | # error: [duplicate-base] "Duplicate base class `Eggs`" +35 | class VeryEggyOmelette( +36 | Eggs, +37 | Ham, +38 | Spam, +39 | Eggs, +40 | Mushrooms, +41 | Bar, +42 | Eggs, +43 | Baz, 44 | Eggs, -45 | Baz, -46 | Eggs, -47 | ): ... -48 | -49 | # fmt: off -50 | # fmt: off +45 | ): ... +46 | +47 | # fmt: off +48 | # fmt: off +49 | +50 | class A: ... 51 | -52 | class A: ... -53 | -54 | class B( # type: ignore[duplicate-base] -55 | A, -56 | A, -57 | ): ... -58 | -59 | class C( -60 | A, -61 | A -62 | ): # type: ignore[duplicate-base] -63 | x: int -64 | -65 | # fmt: on -66 | # fmt: off -67 | -68 | # error: [duplicate-base] -69 | class D( -70 | A, -71 | # error: [unused-ignore-comment] -72 | A, # type: ignore[duplicate-base] -73 | ): ... -74 | -75 | # error: [duplicate-base] -76 | class E( -77 | A, -78 | A -79 | ): -80 | # error: [unused-ignore-comment] -81 | x: int # type: ignore[duplicate-base] -82 | -83 | # fmt: on +52 | class B( # type: ignore[duplicate-base] +53 | A, +54 | A, +55 | ): ... +56 | +57 | class C( +58 | A, +59 | A +60 | ): # type: ignore[duplicate-base] +61 | x: int +62 | +63 | # fmt: on +64 | # fmt: off +65 | +66 | # error: [duplicate-base] +67 | class D( +68 | A, +69 | # error: [unused-ignore-comment] +70 | A, # type: ignore[duplicate-base] +71 | ): ... +72 | +73 | # error: [duplicate-base] +74 | class E( +75 | A, +76 | A +77 | ): +78 | # error: [unused-ignore-comment] +79 | x: int # type: ignore[duplicate-base] +80 | +81 | # fmt: on ``` # Diagnostics ``` error[duplicate-base]: Duplicate base class `str` - --> src/mdtest_snippet.py:3:7 + --> src/mdtest_snippet.py:1:7 | -1 | from typing_extensions import reveal_type -2 | -3 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" +1 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" | ^^^^^^^^^^^^^ -4 | -5 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] +2 | +3 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] | info: The definition of class `Foo` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:3:11 + --> src/mdtest_snippet.py:1:11 | -1 | from typing_extensions import reveal_type -2 | -3 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" +1 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" | --- ^^^ Class `str` later repeated here | | | Class `str` first included in bases list here -4 | -5 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] +2 | +3 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] | info: rule `duplicate-base` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:5:13 - | -3 | class Foo(str, str): ... # error: [duplicate-base] "Duplicate base class `str`" -4 | -5 | reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] - | ^^^^^^^^^^^ `tuple[, Unknown, ]` -6 | -7 | class Spam: ... - | - -``` - ``` error[duplicate-base]: Duplicate base class `Spam` - --> src/mdtest_snippet.py:16:7 + --> src/mdtest_snippet.py:14:7 | -14 | # error: [duplicate-base] "Duplicate base class `Spam`" -15 | # error: [duplicate-base] "Duplicate base class `Eggs`" -16 | class Ham( +12 | # error: [duplicate-base] "Duplicate base class `Spam`" +13 | # error: [duplicate-base] "Duplicate base class `Eggs`" +14 | class Ham( | _______^ -17 | | Spam, -18 | | Eggs, -19 | | Bar, -20 | | Baz, -21 | | Spam, -22 | | Eggs, -23 | | ): ... +15 | | Spam, +16 | | Eggs, +17 | | Bar, +18 | | Baz, +19 | | Spam, +20 | | Eggs, +21 | | ): ... | |_^ -24 | -25 | # fmt: on +22 | +23 | # fmt: on | info: The definition of class `Ham` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:17:5 + --> src/mdtest_snippet.py:15:5 | -15 | # error: [duplicate-base] "Duplicate base class `Eggs`" -16 | class Ham( -17 | Spam, +13 | # error: [duplicate-base] "Duplicate base class `Eggs`" +14 | class Ham( +15 | Spam, | ---- Class `Spam` first included in bases list here -18 | Eggs, -19 | Bar, -20 | Baz, -21 | Spam, +16 | Eggs, +17 | Bar, +18 | Baz, +19 | Spam, | ^^^^ Class `Spam` later repeated here -22 | Eggs, -23 | ): ... +20 | Eggs, +21 | ): ... | info: rule `duplicate-base` is enabled by default @@ -180,134 +160,106 @@ info: rule `duplicate-base` is enabled by default ``` error[duplicate-base]: Duplicate base class `Eggs` - --> src/mdtest_snippet.py:16:7 + --> src/mdtest_snippet.py:14:7 | -14 | # error: [duplicate-base] "Duplicate base class `Spam`" -15 | # error: [duplicate-base] "Duplicate base class `Eggs`" -16 | class Ham( +12 | # error: [duplicate-base] "Duplicate base class `Spam`" +13 | # error: [duplicate-base] "Duplicate base class `Eggs`" +14 | class Ham( | _______^ -17 | | Spam, -18 | | Eggs, -19 | | Bar, -20 | | Baz, -21 | | Spam, -22 | | Eggs, -23 | | ): ... +15 | | Spam, +16 | | Eggs, +17 | | Bar, +18 | | Baz, +19 | | Spam, +20 | | Eggs, +21 | | ): ... | |_^ -24 | -25 | # fmt: on +22 | +23 | # fmt: on | info: The definition of class `Ham` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:18:5 + --> src/mdtest_snippet.py:16:5 | -16 | class Ham( -17 | Spam, -18 | Eggs, +14 | class Ham( +15 | Spam, +16 | Eggs, | ---- Class `Eggs` first included in bases list here -19 | Bar, -20 | Baz, -21 | Spam, -22 | Eggs, +17 | Bar, +18 | Baz, +19 | Spam, +20 | Eggs, | ^^^^ Class `Eggs` later repeated here -23 | ): ... +21 | ): ... | info: rule `duplicate-base` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:27:13 - | -25 | # fmt: on -26 | -27 | reveal_type(Ham.__mro__) # revealed: tuple[, Unknown, ] - | ^^^^^^^^^^^ `tuple[, Unknown, ]` -28 | -29 | class Mushrooms: ... - | - -``` - ``` error[duplicate-base]: Duplicate base class `Mushrooms` - --> src/mdtest_snippet.py:30:7 + --> src/mdtest_snippet.py:28:7 | -29 | class Mushrooms: ... -30 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] +27 | class Mushrooms: ... +28 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -31 | -32 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] +29 | +30 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] | info: The definition of class `Omelette` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:30:28 + --> src/mdtest_snippet.py:28:28 | -29 | class Mushrooms: ... -30 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] +27 | class Mushrooms: ... +28 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] | --------- ^^^^^^^^^ Class `Mushrooms` later repeated here | | | Class `Mushrooms` first included in bases list here -31 | -32 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] +29 | +30 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] | info: rule `duplicate-base` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:32:13 - | -30 | class Omelette(Spam, Eggs, Mushrooms, Mushrooms): ... # error: [duplicate-base] -31 | -32 | reveal_type(Omelette.__mro__) # revealed: tuple[, Unknown, ] - | ^^^^^^^^^^^^^^^^ `tuple[, Unknown, ]` -33 | -34 | # fmt: off - | - -``` - ``` error[duplicate-base]: Duplicate base class `Eggs` - --> src/mdtest_snippet.py:37:7 + --> src/mdtest_snippet.py:35:7 | -36 | # error: [duplicate-base] "Duplicate base class `Eggs`" -37 | class VeryEggyOmelette( +34 | # error: [duplicate-base] "Duplicate base class `Eggs`" +35 | class VeryEggyOmelette( | _______^ -38 | | Eggs, -39 | | Ham, -40 | | Spam, -41 | | Eggs, -42 | | Mushrooms, -43 | | Bar, +36 | | Eggs, +37 | | Ham, +38 | | Spam, +39 | | Eggs, +40 | | Mushrooms, +41 | | Bar, +42 | | Eggs, +43 | | Baz, 44 | | Eggs, -45 | | Baz, -46 | | Eggs, -47 | | ): ... +45 | | ): ... | |_^ -48 | -49 | # fmt: off +46 | +47 | # fmt: off | info: The definition of class `VeryEggyOmelette` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:38:5 + --> src/mdtest_snippet.py:36:5 | -36 | # error: [duplicate-base] "Duplicate base class `Eggs`" -37 | class VeryEggyOmelette( -38 | Eggs, +34 | # error: [duplicate-base] "Duplicate base class `Eggs`" +35 | class VeryEggyOmelette( +36 | Eggs, | ---- Class `Eggs` first included in bases list here -39 | Ham, -40 | Spam, -41 | Eggs, +37 | Ham, +38 | Spam, +39 | Eggs, | ^^^^ Class `Eggs` later repeated here -42 | Mushrooms, -43 | Bar, +40 | Mushrooms, +41 | Bar, +42 | Eggs, + | ^^^^ Class `Eggs` later repeated here +43 | Baz, 44 | Eggs, | ^^^^ Class `Eggs` later repeated here -45 | Baz, -46 | Eggs, - | ^^^^ Class `Eggs` later repeated here -47 | ): ... +45 | ): ... | info: rule `duplicate-base` is enabled by default @@ -315,30 +267,30 @@ info: rule `duplicate-base` is enabled by default ``` error[duplicate-base]: Duplicate base class `A` - --> src/mdtest_snippet.py:69:7 + --> src/mdtest_snippet.py:67:7 | -68 | # error: [duplicate-base] -69 | class D( +66 | # error: [duplicate-base] +67 | class D( | _______^ -70 | | A, -71 | | # error: [unused-ignore-comment] -72 | | A, # type: ignore[duplicate-base] -73 | | ): ... +68 | | A, +69 | | # error: [unused-ignore-comment] +70 | | A, # type: ignore[duplicate-base] +71 | | ): ... | |_^ -74 | -75 | # error: [duplicate-base] +72 | +73 | # error: [duplicate-base] | info: The definition of class `D` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:70:5 + --> src/mdtest_snippet.py:68:5 | -68 | # error: [duplicate-base] -69 | class D( -70 | A, +66 | # error: [duplicate-base] +67 | class D( +68 | A, | - Class `A` first included in bases list here -71 | # error: [unused-ignore-comment] -72 | A, # type: ignore[duplicate-base] +69 | # error: [unused-ignore-comment] +70 | A, # type: ignore[duplicate-base] | ^ Class `A` later repeated here -73 | ): ... +71 | ): ... | info: rule `duplicate-base` is enabled by default @@ -346,42 +298,42 @@ info: rule `duplicate-base` is enabled by default ``` info[unused-ignore-comment] - --> src/mdtest_snippet.py:72:9 + --> src/mdtest_snippet.py:70:9 | -70 | A, -71 | # error: [unused-ignore-comment] -72 | A, # type: ignore[duplicate-base] +68 | A, +69 | # error: [unused-ignore-comment] +70 | A, # type: ignore[duplicate-base] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unused blanket `type: ignore` directive -73 | ): ... +71 | ): ... | ``` ``` error[duplicate-base]: Duplicate base class `A` - --> src/mdtest_snippet.py:76:7 + --> src/mdtest_snippet.py:74:7 | -75 | # error: [duplicate-base] -76 | class E( +73 | # error: [duplicate-base] +74 | class E( | _______^ -77 | | A, -78 | | A -79 | | ): +75 | | A, +76 | | A +77 | | ): | |_^ -80 | # error: [unused-ignore-comment] -81 | x: int # type: ignore[duplicate-base] +78 | # error: [unused-ignore-comment] +79 | x: int # type: ignore[duplicate-base] | info: The definition of class `E` will raise `TypeError` at runtime - --> src/mdtest_snippet.py:77:5 + --> src/mdtest_snippet.py:75:5 | -75 | # error: [duplicate-base] -76 | class E( -77 | A, +73 | # error: [duplicate-base] +74 | class E( +75 | A, | - Class `A` first included in bases list here -78 | A +76 | A | ^ Class `A` later repeated here -79 | ): -80 | # error: [unused-ignore-comment] +77 | ): +78 | # error: [unused-ignore-comment] | info: rule `duplicate-base` is enabled by default @@ -389,14 +341,14 @@ info: rule `duplicate-base` is enabled by default ``` info[unused-ignore-comment] - --> src/mdtest_snippet.py:81:13 + --> src/mdtest_snippet.py:79:13 | -79 | ): -80 | # error: [unused-ignore-comment] -81 | x: int # type: ignore[duplicate-base] +77 | ): +78 | # error: [unused-ignore-comment] +79 | x: int # type: ignore[duplicate-base] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unused blanket `type: ignore` directive -82 | -83 | # fmt: on +80 | +81 | # fmt: on | ``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Argument_type_expans…_-_Optimization___Limit_…_(cd61048adbc17331).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Argument_type_expans…_-_Optimization___Limit_…_(cd61048adbc17331).snap index cf278f4328..ae7b142691 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Argument_type_expans…_-_Optimization___Limit_…_(cd61048adbc17331).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Argument_type_expans…_-_Optimization___Limit_…_(cd61048adbc17331).snap @@ -136,48 +136,3 @@ info: (x: B, /, **kwargs: int) -> B info: rule `no-matching-overload` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:8:9 - | - 6 | # error: [no-matching-overload] - 7 | # revealed: Unknown - 8 | / f( - 9 | | A(), -10 | | a1=a, -11 | | a2=a, -12 | | a3=a, -13 | | a4=a, -14 | | a5=a, -15 | | a6=a, -16 | | a7=a, -17 | | a8=a, -18 | | a9=a, -19 | | a10=a, -20 | | a11=a, -21 | | a12=a, -22 | | a13=a, -23 | | a14=a, -24 | | a15=a, -25 | | a16=a, -26 | | a17=a, -27 | | a18=a, -28 | | a19=a, -29 | | a20=a, -30 | | a21=a, -31 | | a22=a, -32 | | a23=a, -33 | | a24=a, -34 | | a25=a, -35 | | a26=a, -36 | | a27=a, -37 | | a28=a, -38 | | a29=a, -39 | | a30=a, -40 | | ) - | |_________^ `Unknown` -41 | ) - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl…_(288988036f34ddcf).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl…_(288988036f34ddcf).snap index 923dac0803..4e18a85fd6 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl…_(288988036f34ddcf).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl…_(288988036f34ddcf).snap @@ -12,7 +12,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import Protocol, reveal_type + 1 | from typing_extensions import Protocol 2 | 3 | # error: [call-non-callable] 4 | reveal_type(Protocol()) # revealed: Unknown @@ -36,9 +36,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md 22 | 23 | reveal_type(SubclassOfGenericProtocol[int]()) # revealed: SubclassOfGenericProtocol[int] 24 | def f(x: type[MyProtocol]): -25 | # TODO: add a `reveal_type` call here once it's no longer a `Todo` type -26 | # (which doesn't work well with snapshots) -27 | x() +25 | reveal_type(x()) # revealed: @Todo(type[T] for protocols) ``` # Diagnostics @@ -57,19 +55,6 @@ info: rule `call-non-callable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:4:13 - | -3 | # error: [call-non-callable] -4 | reveal_type(Protocol()) # revealed: Unknown - | ^^^^^^^^^^ `Unknown` -5 | -6 | class MyProtocol(Protocol): - | - -``` - ``` error[call-non-callable]: Cannot instantiate class `MyProtocol` --> src/mdtest_snippet.py:10:13 @@ -93,19 +78,6 @@ info: rule `call-non-callable` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:10:13 - | - 9 | # error: [call-non-callable] "Cannot instantiate class `MyProtocol`" -10 | reveal_type(MyProtocol()) # revealed: MyProtocol - | ^^^^^^^^^^^^ `MyProtocol` -11 | -12 | class GenericProtocol[T](Protocol): - | - -``` - ``` error[call-non-callable]: Cannot instantiate class `GenericProtocol` --> src/mdtest_snippet.py:16:13 @@ -127,43 +99,3 @@ info: Protocol classes cannot be instantiated info: rule `call-non-callable` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:16:13 - | -15 | # error: [call-non-callable] "Cannot instantiate class `GenericProtocol`" -16 | reveal_type(GenericProtocol[int]()) # revealed: GenericProtocol[int] - | ^^^^^^^^^^^^^^^^^^^^^^ `GenericProtocol[int]` -17 | class SubclassOfMyProtocol(MyProtocol): ... - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:19:13 - | -17 | class SubclassOfMyProtocol(MyProtocol): ... -18 | -19 | reveal_type(SubclassOfMyProtocol()) # revealed: SubclassOfMyProtocol - | ^^^^^^^^^^^^^^^^^^^^^^ `SubclassOfMyProtocol` -20 | -21 | class SubclassOfGenericProtocol[T](GenericProtocol[T]): ... - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:23:13 - | -21 | class SubclassOfGenericProtocol[T](GenericProtocol[T]): ... -22 | -23 | reveal_type(SubclassOfGenericProtocol[int]()) # revealed: SubclassOfGenericProtocol[int] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `SubclassOfGenericProtocol[int]` -24 | def f(x: type[MyProtocol]): -25 | # TODO: add a `reveal_type` call here once it's no longer a `Todo` type - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco…_(98257e7c2300373).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco…_(98257e7c2300373).snap index 65263835c3..1247edf089 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco…_(98257e7c2300373).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco…_(98257e7c2300373).snap @@ -12,7 +12,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md ## mdtest_snippet.py ``` - 1 | from typing_extensions import Protocol, reveal_type + 1 | from typing_extensions import Protocol 2 | 3 | class HasX(Protocol): 4 | x: int @@ -69,7 +69,7 @@ error[invalid-argument-type]: Class `HasX` cannot be used as the second argument info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable --> src/mdtest_snippet.py:3:7 | -1 | from typing_extensions import Protocol, reveal_type +1 | from typing_extensions import Protocol 2 | 3 | class HasX(Protocol): | ^^^^^^^^^^^^^^ `HasX` declared here @@ -81,34 +81,6 @@ info: rule `invalid-argument-type` is enabled by default ``` -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:8:21 - | - 6 | def f(arg: object, arg2: type): - 7 | if isinstance(arg, HasX): # error: [invalid-argument-type] - 8 | reveal_type(arg) # revealed: HasX - | ^^^ `HasX` - 9 | else: -10 | reveal_type(arg) # revealed: ~HasX - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:10:21 - | - 8 | reveal_type(arg) # revealed: HasX - 9 | else: -10 | reveal_type(arg) # revealed: ~HasX - | ^^^ `~HasX` -11 | -12 | if issubclass(arg2, HasX): # error: [invalid-argument-type] - | - -``` - ``` error[invalid-argument-type]: Class `HasX` cannot be used as the second argument to `issubclass` --> src/mdtest_snippet.py:12:8 @@ -123,7 +95,7 @@ error[invalid-argument-type]: Class `HasX` cannot be used as the second argument info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable --> src/mdtest_snippet.py:3:7 | -1 | from typing_extensions import Protocol, reveal_type +1 | from typing_extensions import Protocol 2 | 3 | class HasX(Protocol): | ^^^^^^^^^^^^^^ `HasX` declared here @@ -134,110 +106,3 @@ info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable info: rule `invalid-argument-type` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:13:21 - | -12 | if issubclass(arg2, HasX): # error: [invalid-argument-type] -13 | reveal_type(arg2) # revealed: type[HasX] - | ^^^^ `type[HasX]` -14 | else: -15 | reveal_type(arg2) # revealed: type & ~type[HasX] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:15:21 - | -13 | reveal_type(arg2) # revealed: type[HasX] -14 | else: -15 | reveal_type(arg2) # revealed: type & ~type[HasX] - | ^^^^ `type & ~type[HasX]` -16 | from typing import runtime_checkable - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:24:21 - | -22 | def f(arg: object): -23 | if isinstance(arg, RuntimeCheckableHasX): # no error! -24 | reveal_type(arg) # revealed: RuntimeCheckableHasX - | ^^^ `RuntimeCheckableHasX` -25 | else: -26 | reveal_type(arg) # revealed: ~RuntimeCheckableHasX - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:26:21 - | -24 | reveal_type(arg) # revealed: RuntimeCheckableHasX -25 | else: -26 | reveal_type(arg) # revealed: ~RuntimeCheckableHasX - | ^^^ `~RuntimeCheckableHasX` -27 | @runtime_checkable -28 | class OnlyMethodMembers(Protocol): - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:33:21 - | -31 | def f(arg1: type, arg2: type): -32 | if issubclass(arg1, RuntimeCheckableHasX): # TODO: should emit an error here (has non-method members) -33 | reveal_type(arg1) # revealed: type[RuntimeCheckableHasX] - | ^^^^ `type[RuntimeCheckableHasX]` -34 | else: -35 | reveal_type(arg1) # revealed: type & ~type[RuntimeCheckableHasX] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:35:21 - | -33 | reveal_type(arg1) # revealed: type[RuntimeCheckableHasX] -34 | else: -35 | reveal_type(arg1) # revealed: type & ~type[RuntimeCheckableHasX] - | ^^^^ `type & ~type[RuntimeCheckableHasX]` -36 | -37 | if issubclass(arg2, OnlyMethodMembers): # no error! - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:38:21 - | -37 | if issubclass(arg2, OnlyMethodMembers): # no error! -38 | reveal_type(arg2) # revealed: type[OnlyMethodMembers] - | ^^^^ `type[OnlyMethodMembers]` -39 | else: -40 | reveal_type(arg2) # revealed: type & ~type[OnlyMethodMembers] - | - -``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:40:21 - | -38 | reveal_type(arg2) # revealed: type[OnlyMethodMembers] -39 | else: -40 | reveal_type(arg2) # revealed: type & ~type[OnlyMethodMembers] - | ^^^^ `type & ~type[OnlyMethodMembers]` - | - -``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Protocol_members_in_…_(21be5d9bdab1c844).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Protocol_members_in_…_(21be5d9bdab1c844).snap index d7436cbec7..28599faa0c 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Protocol_members_in_…_(21be5d9bdab1c844).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Protocol_members_in_…_(21be5d9bdab1c844).snap @@ -13,7 +13,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md ``` 1 | import sys - 2 | from typing_extensions import Protocol, get_protocol_members, reveal_type + 2 | from typing_extensions import Protocol, get_protocol_members 3 | 4 | class Foo(Protocol): 5 | if sys.version_info >= (3, 10): @@ -43,7 +43,7 @@ warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the info: Assigning to an undeclared variable in a protocol class leads to an ambiguous interface --> src/mdtest_snippet.py:4:7 | -2 | from typing_extensions import Protocol, get_protocol_members, reveal_type +2 | from typing_extensions import Protocol, get_protocol_members 3 | 4 | class Foo(Protocol): | ^^^^^^^^^^^^^ `Foo` declared as a protocol here @@ -54,15 +54,3 @@ info: No declarations found for `e` in the body of `Foo` or any of its superclas info: rule `ambiguous-protocol-member` is enabled by default ``` - -``` -info[revealed-type]: Revealed type - --> src/mdtest_snippet.py:14:13 - | -12 | def f(self) -> None: ... -13 | -14 | reveal_type(get_protocol_members(Foo)) # revealed: frozenset[Literal["d", "e", "f"]] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ `frozenset[Literal["d", "e", "f"]]` - | - -``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c8c1965bcc..894ef87710 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -21,8 +21,8 @@ use type_ordering::union_or_intersection_elements_ordering; pub(crate) use self::builder::{IntersectionBuilder, UnionBuilder}; pub use self::cyclic::CycleDetector; pub(crate) use self::cyclic::{PairVisitor, TypeTransformer}; -pub use self::diagnostic::TypeCheckDiagnostics; pub(crate) use self::diagnostic::register_lints; +pub use self::diagnostic::{TypeCheckDiagnostics, UNDEFINED_REVEAL}; pub(crate) use self::infer::{ TypeContext, infer_deferred_types, infer_definition_types, infer_expression_type, infer_expression_types, infer_isolated_expression, infer_scope_types, diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 1b38ecd1f3..f208ae3f5d 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -1568,7 +1568,7 @@ declare_lint! { /// ```python /// reveal_type(1) # NameError: name 'reveal_type' is not defined /// ``` - pub(crate) static UNDEFINED_REVEAL = { + pub static UNDEFINED_REVEAL = { summary: "detects usages of `reveal_type` without importing it", status: LintStatus::preview("1.0.0"), default_level: Level::Warn, diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index 8b03002b20..611fbbd6bf 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -6,7 +6,7 @@ use colored::Colorize; use config::SystemKind; use parser as test_parser; use ruff_db::Db as _; -use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig}; +use ruff_db::diagnostic::{Diagnostic, DiagnosticId, DisplayDiagnosticConfig}; use ruff_db::files::{File, FileRootKind, system_path_to_file}; use ruff_db::panic::catch_unwind; use ruff_db::parsed::parsed_module; @@ -16,7 +16,7 @@ use ruff_source_file::{LineIndex, OneIndexed}; use std::backtrace::BacktraceStatus; use std::fmt::Write; use ty_python_semantic::pull_types::pull_types; -use ty_python_semantic::types::check_types; +use ty_python_semantic::types::{UNDEFINED_REVEAL, check_types}; use ty_python_semantic::{ Module, Program, ProgramSettings, PythonEnvironment, PythonPlatform, PythonVersionSource, PythonVersionWithSource, SearchPath, SearchPathSettings, SysPrefixPathOrigin, list_modules, @@ -377,8 +377,14 @@ fn run_test( by_line: line_failures, }), }; + + // Filter out `revealed-type` and `undefined-reveal` diagnostics from snapshots, + // since they make snapshots very noisy! if test.should_snapshot_diagnostics() { - snapshot_diagnostics.extend(diagnostics); + snapshot_diagnostics.extend(diagnostics.into_iter().filter(|diagnostic| { + diagnostic.id() != DiagnosticId::RevealedType + && !diagnostic.id().is_lint_named(&UNDEFINED_REVEAL.name()) + })); } failure From 1935896e6b4e3da618dbeed46a8ed43a555b28c6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:42:06 -0400 Subject: [PATCH 002/113] Update Rust crate anstyle to v1.0.13 (#20830) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d823971f5b..dd2c365414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,9 +65,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-lossy" From 89f9dd6b43b642f0a8446fc4c5259061264a1ab5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:42:19 -0400 Subject: [PATCH 003/113] Update Rust crate camino to v1.2.1 (#20831) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd2c365414..0a6ff2c22d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,9 +327,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" dependencies = [ "serde_core", ] From 6be344af65c951874be2871b3abf2297fe82ef60 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:42:38 -0400 Subject: [PATCH 004/113] Update Rust crate libcst to v1.8.5 (#20833) --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a6ff2c22d..790af86883 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,9 +1843,9 @@ checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libcst" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052ef5d9fc958a51aeebdf3713573b36c6fd6eed0bf0e60e204d2c0f8cf19b9f" +checksum = "9d56bcd52d9b5e5f43e7fba20eb1f423ccb18c84cdf1cb506b8c1b95776b0b49" dependencies = [ "annotate-snippets", "libcst_derive", @@ -1858,9 +1858,9 @@ dependencies = [ [[package]] name = "libcst_derive" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91a751afee92cbdd59d4bc6754c7672712eec2d30a308f23de4e3287b2929cb" +checksum = "3fcf5a725c4db703660124fe0edb98285f1605d0b87b7ee8684b699764a4f01a" dependencies = [ "quote", "syn", From 89b67a2448d4a85f1a0e87d5533f6efe0c0c1a5f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:42:52 -0400 Subject: [PATCH 005/113] Update Rust crate memchr to v2.7.6 (#20834) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 790af86883..0ee15f1b7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2017,9 +2017,9 @@ checksum = "2f926ade0c4e170215ae43342bf13b9310a437609c81f29f86c5df6657582ef9" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" From 74b2c4c2e4f9c45456469cbe9c8617f95a03aec1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:43:10 -0400 Subject: [PATCH 006/113] Update Rust crate libc to v0.2.177 (#20832) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ee15f1b7b..3c53799af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1837,9 +1837,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcst" From 0e8c02aea68e75e8d86030bc182b98613f86824b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:43:39 -0400 Subject: [PATCH 007/113] Update Rust crate anstream to v0.6.21 (#20829) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c53799af6..8f11ca976c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,9 +50,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", From e3b910c41a6f6d5b89fb56cf91991b75a8f55c79 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 08:28:06 +0200 Subject: [PATCH 008/113] Update Rust crate pyproject-toml to v0.13.7 (#20835) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f11ca976c..df05a29f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,7 +243,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -1563,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -2569,9 +2569,9 @@ dependencies = [ [[package]] name = "pyproject-toml" -version = "0.13.6" +version = "0.13.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec768e063102b426e8962989758115e8659485124de9207bc365fab524125d65" +checksum = "f6d755483ad14b49e76713b52285235461a5b4f73f17612353e11a5de36a5fd2" dependencies = [ "indexmap", "pep440_rs", From e02cdd350e88521cacb263824bd8986b2179eb83 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 08:28:37 +0200 Subject: [PATCH 009/113] Update CodSpeedHQ/action action to v4.1.1 (#20828) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9091ae3907..db25effed3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -953,7 +953,7 @@ jobs: run: cargo codspeed build --features "codspeed,instrumented" --no-default-features -p ruff_benchmark --bench formatter --bench lexer --bench linter --bench parser - name: "Run benchmarks" - uses: CodSpeedHQ/action@3959e9e296ef25296e93e32afcc97196f966e57f # v4.1.0 + uses: CodSpeedHQ/action@6b43a0cd438f6ca5ad26f9ed03ed159ed2df7da9 # v4.1.1 with: mode: instrumentation run: cargo codspeed run @@ -988,7 +988,7 @@ jobs: run: cargo codspeed build --features "codspeed,instrumented" --no-default-features -p ruff_benchmark --bench ty - name: "Run benchmarks" - uses: CodSpeedHQ/action@3959e9e296ef25296e93e32afcc97196f966e57f # v4.1.0 + uses: CodSpeedHQ/action@6b43a0cd438f6ca5ad26f9ed03ed159ed2df7da9 # v4.1.1 with: mode: instrumentation run: cargo codspeed run @@ -1026,7 +1026,7 @@ jobs: run: cargo codspeed build --features "codspeed,walltime" --no-default-features -p ruff_benchmark - name: "Run benchmarks" - uses: CodSpeedHQ/action@3959e9e296ef25296e93e32afcc97196f966e57f # v4.1.0 + uses: CodSpeedHQ/action@6b43a0cd438f6ca5ad26f9ed03ed159ed2df7da9 # v4.1.1 env: # enabling walltime flamegraphs adds ~6 minutes to the CI time, and they don't # appear to provide much useful insight for our walltime benchmarks right now From 350042b801b4b74470a2fe5c91480cc881f2d4fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 08:28:55 +0200 Subject: [PATCH 010/113] Update cargo-bins/cargo-binstall action to v1.15.7 (#20827) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index db25effed3..87252c9fd9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -452,7 +452,7 @@ jobs: - name: "Install Rust toolchain" run: rustup show - name: "Install cargo-binstall" - uses: cargo-bins/cargo-binstall@38e8f5e4c386b611d51e8aa997b9a06a3c8eb67a # v1.15.6 + uses: cargo-bins/cargo-binstall@a66119fbb1c952daba62640c2609111fe0803621 # v1.15.7 - name: "Install cargo-fuzz" # Download the latest version from quick install and not the github releases because github releases only has MUSL targets. run: cargo binstall cargo-fuzz --force --disable-strategies crate-meta-data --no-confirm @@ -703,7 +703,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - - uses: cargo-bins/cargo-binstall@38e8f5e4c386b611d51e8aa997b9a06a3c8eb67a # v1.15.6 + - uses: cargo-bins/cargo-binstall@a66119fbb1c952daba62640c2609111fe0803621 # v1.15.7 - run: cargo binstall --no-confirm cargo-shear - run: cargo shear From c80ee1a50bf1b408e484a98d976055de66eb764d Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 13 Oct 2025 09:15:54 +0200 Subject: [PATCH 011/113] [ty] Log files that are slow to type check (#20836) --- crates/ty_python_semantic/src/types.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 894ef87710..faed47aacf 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3,6 +3,7 @@ use itertools::{Either, Itertools}; use ruff_db::parsed::parsed_module; use std::borrow::Cow; +use std::time::Duration; use bitflags::bitflags; use call::{CallDunderError, CallError, CallErrorKind}; @@ -11,11 +12,13 @@ use diagnostic::{ INVALID_CONTEXT_MANAGER, INVALID_SUPER_ARGUMENT, NOT_ITERABLE, POSSIBLY_MISSING_IMPLICIT_CALL, UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS, }; +use ruff_db::Instant; use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; use ruff_db::files::File; use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_text_size::{Ranged, TextRange}; + use type_ordering::union_or_intersection_elements_ordering; pub(crate) use self::builder::{IntersectionBuilder, UnionBuilder}; @@ -106,9 +109,10 @@ mod property_tests; pub fn check_types(db: &dyn Db, file: File) -> Vec { let _span = tracing::trace_span!("check_types", ?file).entered(); - tracing::debug!("Checking file '{path}'", path = file.path(db)); + let start = Instant::now(); + let index = semantic_index(db, file); let mut diagnostics = TypeCheckDiagnostics::default(); @@ -129,6 +133,14 @@ pub fn check_types(db: &dyn Db, file: File) -> Vec { check_suppressions(db, file, &mut diagnostics); + let elapsed = start.elapsed(); + if elapsed >= Duration::from_millis(100) { + tracing::info!( + "Checking file `{path}` took more than 100ms ({elapsed:?})", + path = file.path(db) + ); + } + diagnostics.into_diagnostics() } From 9b9c9ae0923762fdea90d1ddd01ee8e7e87064dc Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Oct 2025 09:28:57 +0200 Subject: [PATCH 012/113] [ty] Prefer declared base class attribute over inferred attribute on subclass (#20764) ## Summary When accessing an (instance) attribute on a given class, we were previously traversing its MRO, and building a union of types (if the attribute was available on multiple classes in the MRO) until we found a *definitely bound* symbol. The idea was that possibly unbound symbols in a subclass might only partially shadow the underlying base class attribute. This behavior was problematic for two reasons: * if the attribute was definitely bound on a class (e.g. `self.x = None`), we would have stopped iterating, even if there might be a `x: str | None` declaration in a base class (the bug reported in https://github.com/astral-sh/ty/issues/1067). * if the attribute originated from an implicit instance attribute assignment (e.g. `self.x = 1` in method `Sub.foo`), we might stop looking and miss another implicit instance attribute assignment in a base class method (e.g. `self.x = 2` in method `Base.bar`). With this fix, we still iterate the MRO of the class, but we only stop iterating if we find a *definitely declared* symbol. In this case, we only return the declared attribute type. Otherwise, we keep building a union of inferred attribute types. The implementation here seemed to be the easiest fix for https://github.com/astral-sh/ty/issues/1067 that also kept the ecosystem impact low (the changes that I see all look correct). However, as the Markdown tests show, there are other things to fix in this area. For example, we should do a similar thing for *class attributes*. This is more involved, though (affects many different areas and probably involves a change to our descriptor protocol implementation), so I'd like to postpone this to a follow-up. closes https://github.com/astral-sh/ty/issues/1067 ## Test Plan Updated Markdown tests, including a regression test for https://github.com/astral-sh/ty/issues/1067. --- .../resources/mdtest/attributes.md | 9 +- crates/ty_python_semantic/src/lib.rs | 1 + crates/ty_python_semantic/src/member.rs | 73 +++++++++ crates/ty_python_semantic/src/place.rs | 17 +- crates/ty_python_semantic/src/types/class.rs | 155 ++++++++++-------- 5 files changed, 169 insertions(+), 86 deletions(-) create mode 100644 crates/ty_python_semantic/src/member.rs diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 60aaa68306..bff72953e2 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -901,24 +901,21 @@ reveal_type(Derived().redeclared_with_wider_type) # revealed: str | int | None reveal_type(Derived.overwritten_in_subclass_body) # revealed: Unknown | None reveal_type(Derived().overwritten_in_subclass_body) # revealed: Unknown | None | str -# TODO: Both of these should be `str` reveal_type(Derived.overwritten_in_subclass_method) # revealed: str -reveal_type(Derived().overwritten_in_subclass_method) # revealed: str | Unknown | None +reveal_type(Derived().overwritten_in_subclass_method) # revealed: str reveal_type(Derived().pure_attribute) # revealed: str | None # TODO: This should be `str` reveal_type(Derived().pure_overwritten_in_subclass_body) # revealed: Unknown | None | str -# TODO: This should be `str` -reveal_type(Derived().pure_overwritten_in_subclass_method) # revealed: Unknown | None +reveal_type(Derived().pure_overwritten_in_subclass_method) # revealed: str # TODO: Both of these should be `Unknown | Literal["intermediate", "base"]` reveal_type(Derived.undeclared) # revealed: Unknown | Literal["intermediate"] reveal_type(Derived().undeclared) # revealed: Unknown | Literal["intermediate"] -# TODO: This should be `Unknown | Literal["intermediate", "base"]` -reveal_type(Derived().pure_undeclared) # revealed: Unknown | Literal["intermediate"] +reveal_type(Derived().pure_undeclared) # revealed: Unknown | Literal["intermediate", "base"] ``` ## Accessing attributes on class objects diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index dbe07aa600..e0619d9f20 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -34,6 +34,7 @@ mod db; mod dunder_all; pub mod lint; pub(crate) mod list; +mod member; mod module_name; mod module_resolver; mod node_key; diff --git a/crates/ty_python_semantic/src/member.rs b/crates/ty_python_semantic/src/member.rs new file mode 100644 index 0000000000..0c078d1fb1 --- /dev/null +++ b/crates/ty_python_semantic/src/member.rs @@ -0,0 +1,73 @@ +use crate::{ + place::{Place, PlaceAndQualifiers}, + types::Type, +}; + +/// The return type of certain member-lookup operations. Contains information +/// about the type, type qualifiers, boundness/declaredness, and additional +/// metadata (e.g. whether or not the member was declared) +#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +pub(crate) struct Member<'db> { + /// Type, qualifiers, and boundness information of this member + pub(crate) inner: PlaceAndQualifiers<'db>, + + /// Whether or not this member was explicitly declared (e.g. `attr: int = 1` + /// on the class body or `self.attr: int = 1` in a class method), or if the + /// type was inferred (e.g. `attr = 1` on the class body or `self.attr = 1` + /// in a class method). + pub(crate) is_declared: bool, +} + +impl Default for Member<'_> { + fn default() -> Self { + Member::inferred(PlaceAndQualifiers::default()) + } +} + +impl<'db> Member<'db> { + /// Create a new [`Member`] whose type was inferred (rather than explicitly declared). + pub(crate) fn inferred(inner: PlaceAndQualifiers<'db>) -> Self { + Self { + inner, + is_declared: false, + } + } + + /// Create a new [`Member`] whose type was explicitly declared (rather than inferred). + pub(crate) fn declared(inner: PlaceAndQualifiers<'db>) -> Self { + Self { + inner, + is_declared: true, + } + } + + /// Create a new [`Member`] whose type was explicitly and definitively declared, i.e. + /// there is no control flow path in which it might be possibly undeclared. + pub(crate) fn definitely_declared(ty: Type<'db>) -> Self { + Self::declared(Place::bound(ty).into()) + } + + /// Represents the absence of a member. + pub(crate) fn unbound() -> Self { + Self::inferred(PlaceAndQualifiers::default()) + } + + /// Returns `true` if the inner place is unbound (i.e. there is no such member). + pub(crate) fn is_unbound(&self) -> bool { + self.inner.place.is_unbound() + } + + /// Returns the inner type, unless it is definitely unbound. + pub(crate) fn ignore_possibly_unbound(&self) -> Option> { + self.inner.place.ignore_possibly_unbound() + } + + /// Map a type transformation function over the type of this member. + #[must_use] + pub(crate) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + Self { + inner: self.inner.map_type(f), + is_declared: self.is_declared, + } + } +} diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index bf3388de61..7495373b09 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1,6 +1,7 @@ use ruff_db::files::File; use crate::dunder_all::dunder_all_names; +use crate::member::Member; use crate::module_resolver::{KnownModule, file_to_module}; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::place::{PlaceExprRef, ScopedPlaceId}; @@ -232,13 +233,9 @@ pub(crate) fn place<'db>( ) } -/// Infer the public type of a class symbol (its type as seen from outside its scope) in the given +/// Infer the public type of a class member/symbol (its type as seen from outside its scope) in the given /// `scope`. -pub(crate) fn class_symbol<'db>( - db: &'db dyn Db, - scope: ScopeId<'db>, - name: &str, -) -> PlaceAndQualifiers<'db> { +pub(crate) fn class_member<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Member<'db> { place_table(db, scope) .symbol_id(name) .map(|symbol_id| { @@ -252,7 +249,7 @@ pub(crate) fn class_symbol<'db>( if !place_and_quals.place.is_unbound() && !place_and_quals.is_init_var() { // Trust the declared type if we see a class-level declaration - return place_and_quals; + return Member::declared(place_and_quals); } if let PlaceAndQualifiers { @@ -267,14 +264,14 @@ pub(crate) fn class_symbol<'db>( // TODO: we should not need to calculate inferred type second time. This is a temporary // solution until the notion of Boundness and Declaredness is split. See #16036, #16264 - match inferred { + Member::inferred(match inferred { Place::Unbound => Place::Unbound.with_qualifiers(qualifiers), Place::Type(_, boundness) => { Place::Type(ty, boundness).with_qualifiers(qualifiers) } - } + }) } else { - Place::Unbound.into() + Member::unbound() } }) .unwrap_or_default() diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 8136f93184..fd03dba47b 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -7,6 +7,7 @@ use super::{ function::FunctionType, infer_expression_type, infer_unpack_types, }; use crate::FxOrderMap; +use crate::member::Member; use crate::module_resolver::KnownModule; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::scope::{NodeWithScopeKind, Scope}; @@ -36,7 +37,7 @@ use crate::{ Db, FxIndexMap, FxOrderSet, Program, module_resolver::file_to_module, place::{ - Boundness, LookupError, LookupResult, Place, PlaceAndQualifiers, class_symbol, + Boundness, LookupError, LookupResult, Place, PlaceAndQualifiers, class_member, known_module_symbol, place_from_bindings, place_from_declarations, }, semantic_index::{ @@ -96,12 +97,12 @@ fn inheritance_cycle_initial<'db>( fn implicit_attribute_recover<'db>( _db: &'db dyn Db, - _value: &PlaceAndQualifiers<'db>, + _value: &Member<'db>, _count: u32, _class_body_scope: ScopeId<'db>, _name: String, _target_method_decorator: MethodDecorator, -) -> salsa::CycleRecoveryAction> { +) -> salsa::CycleRecoveryAction> { salsa::CycleRecoveryAction::Iterate } @@ -110,8 +111,8 @@ fn implicit_attribute_initial<'db>( _class_body_scope: ScopeId<'db>, _name: String, _target_method_decorator: MethodDecorator, -) -> PlaceAndQualifiers<'db> { - Place::Unbound.into() +) -> Member<'db> { + Member::unbound() } fn try_mro_cycle_recover<'db>( @@ -744,7 +745,7 @@ impl<'db> ClassType<'db> { db: &'db dyn Db, inherited_generic_context: Option>, name: &str, - ) -> PlaceAndQualifiers<'db> { + ) -> Member<'db> { fn synthesize_getitem_overload_signature<'db>( index_annotation: Type<'db>, return_annotation: Type<'db>, @@ -782,7 +783,7 @@ impl<'db> ClassType<'db> { let synthesized_dunder_method = CallableType::function_like(db, Signature::new(parameters, Some(return_type))); - Place::bound(synthesized_dunder_method).into() + Member::definitely_declared(synthesized_dunder_method) }; match name { @@ -956,7 +957,7 @@ impl<'db> ClassType<'db> { CallableSignature::from_overloads(overload_signatures); let getitem_type = Type::Callable(CallableType::new(db, getitem_signature, true)); - Place::bound(getitem_type).into() + Member::definitely_declared(getitem_type) }) .unwrap_or_else(fallback_member_lookup) } @@ -1028,7 +1029,7 @@ impl<'db> ClassType<'db> { Signature::new_generic(inherited_generic_context, parameters, None), ); - Place::bound(synthesized_dunder).into() + Member::definitely_declared(synthesized_dunder) } _ => fallback_member_lookup(), @@ -1052,7 +1053,7 @@ impl<'db> ClassType<'db> { /// A helper function for `instance_member` that looks up the `name` attribute only on /// this class, not on its superclasses. - fn own_instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { let (class_literal, specialization) = self.class_literal(db); class_literal .own_instance_member(db, name) @@ -2017,7 +2018,9 @@ impl<'db> ClassLiteral<'db> { lookup_result = lookup_result.or_else(|lookup_error| { lookup_error.or_fall_back_to( db, - class.own_class_member(db, self.inherited_generic_context(db), name), + class + .own_class_member(db, self.inherited_generic_context(db), name) + .inner, ) }); } @@ -2089,17 +2092,19 @@ impl<'db> ClassLiteral<'db> { inherited_generic_context: Option>, specialization: Option>, name: &str, - ) -> PlaceAndQualifiers<'db> { + ) -> Member<'db> { if name == "__dataclass_fields__" && self.dataclass_params(db).is_some() { // Make this class look like a subclass of the `DataClassInstance` protocol - return Place::bound(KnownClass::Dict.to_specialized_instance( - db, - [ - KnownClass::Str.to_instance(db), - KnownClass::Field.to_specialized_instance(db, [Type::any()]), - ], - )) - .with_qualifiers(TypeQualifiers::CLASS_VAR); + return Member::declared( + Place::bound(KnownClass::Dict.to_specialized_instance( + db, + [ + KnownClass::Str.to_instance(db), + KnownClass::Field.to_specialized_instance(db, [Type::any()]), + ], + )) + .with_qualifiers(TypeQualifiers::CLASS_VAR), + ); } if CodeGeneratorKind::NamedTuple.matches(db, self) { @@ -2113,12 +2118,12 @@ impl<'db> ClassLiteral<'db> { ); let property_getter = CallableType::single(db, property_getter_signature); let property = PropertyInstanceType::new(db, Some(property_getter), None); - return Place::bound(Type::PropertyInstance(property)).into(); + return Member::definitely_declared(Type::PropertyInstance(property)); } } let body_scope = self.body_scope(db); - let symbol = class_symbol(db, body_scope, name).map_type(|ty| { + let member = class_member(db, body_scope, name).map_type(|ty| { // The `__new__` and `__init__` members of a non-specialized generic class are handled // specially: they inherit the generic context of their class. That lets us treat them // as generic functions when constructing the class, and infer the specialization of @@ -2143,15 +2148,15 @@ impl<'db> ClassLiteral<'db> { } }); - if symbol.place.is_unbound() { + if member.is_unbound() { if let Some(synthesized_member) = self.own_synthesized_member(db, specialization, name) { - return Place::bound(synthesized_member).into(); + return Member::definitely_declared(synthesized_member); } // The symbol was not found in the class scope. It might still be implicitly defined in `@classmethod`s. return Self::implicit_attribute(db, body_scope, name, MethodDecorator::ClassMethod); } - symbol + member } /// Returns the type of a synthesized dataclass member like `__init__` or `__lt__`, or @@ -2329,7 +2334,6 @@ impl<'db> ClassLiteral<'db> { .to_class_literal(db) .into_class_literal()? .own_class_member(db, self.inherited_generic_context(db), None, name) - .place .ignore_possibly_unbound() .map(|ty| { ty.apply_type_mapping( @@ -2883,6 +2887,7 @@ impl<'db> ClassLiteral<'db> { let mut union = UnionBuilder::new(db); let mut union_qualifiers = TypeQualifiers::empty(); + let mut is_definitely_bound = false; for superclass in self.iter_mro(db, specialization) { match superclass { @@ -2895,27 +2900,32 @@ impl<'db> ClassLiteral<'db> { ); } ClassBase::Class(class) => { - if let member @ PlaceAndQualifiers { - place: Place::Type(ty, boundness), - qualifiers, + if let Member { + inner: + member @ PlaceAndQualifiers { + place: Place::Type(ty, boundness), + qualifiers, + }, + is_declared, } = class.own_instance_member(db, name) { - // TODO: We could raise a diagnostic here if there are conflicting type qualifiers - union_qualifiers |= qualifiers; - if boundness == Boundness::Bound { - if union.is_empty() { - // Short-circuit, no need to allocate inside the union builder + if is_declared { + // We found a definitely-declared attribute. Discard possibly collected + // inferred types from subclasses and return the declared type. return member; } - return Place::bound(union.add(ty).build()) - .with_qualifiers(union_qualifiers); + is_definitely_bound = true; } - // If we see a possibly-unbound symbol, we need to keep looking - // higher up in the MRO. + // If the attribute is not definitely declared on this class, keep looking higher + // up in the MRO, and build a union of all inferred types (and possibly-declared + // types): union = union.add(ty); + + // TODO: We could raise a diagnostic here if there are conflicting type qualifiers + union_qualifiers |= qualifiers; } } ClassBase::TypedDict => { @@ -2941,10 +2951,13 @@ impl<'db> ClassLiteral<'db> { if union.is_empty() { Place::Unbound.with_qualifiers(TypeQualifiers::empty()) } else { - // If we have reached this point, we know that we have only seen possibly-unbound places. - // This means that the final result is still possibly-unbound. + let boundness = if is_definitely_bound { + Boundness::Bound + } else { + Boundness::PossiblyUnbound + }; - Place::Type(union.build(), Boundness::PossiblyUnbound).with_qualifiers(union_qualifiers) + Place::Type(union.build(), boundness).with_qualifiers(union_qualifiers) } } @@ -2957,7 +2970,7 @@ impl<'db> ClassLiteral<'db> { class_body_scope: ScopeId<'db>, name: &str, target_method_decorator: MethodDecorator, - ) -> PlaceAndQualifiers<'db> { + ) -> Member<'db> { Self::implicit_attribute_inner( db, class_body_scope, @@ -2976,7 +2989,7 @@ impl<'db> ClassLiteral<'db> { class_body_scope: ScopeId<'db>, name: String, target_method_decorator: MethodDecorator, - ) -> PlaceAndQualifiers<'db> { + ) -> Member<'db> { // If we do not see any declarations of an attribute, neither in the class body nor in // any method, we build a union of `Unknown` with the inferred types of all bindings of // that attribute. We include `Unknown` in that union to account for the fact that the @@ -2995,8 +3008,8 @@ impl<'db> ClassLiteral<'db> { let is_valid_scope = |method_scope: &Scope| { if let Some(method_def) = method_scope.node().as_function() { let method_name = method_def.node(&module).name.as_str(); - if let Place::Type(Type::FunctionLiteral(method_type), _) = - class_symbol(db, class_body_scope, method_name).place + if let Some(Type::FunctionLiteral(method_type)) = + class_member(db, class_body_scope, method_name).ignore_possibly_unbound() { let method_decorator = MethodDecorator::try_from_fn_type(db, method_type); if method_decorator != Ok(target_method_decorator) { @@ -3048,7 +3061,9 @@ impl<'db> ClassLiteral<'db> { index.expression(value), TypeContext::default(), ); - return Place::bound(inferred_ty).with_qualifiers(all_qualifiers); + return Member::inferred( + Place::bound(inferred_ty).with_qualifiers(all_qualifiers), + ); } // If there is no right-hand side, just record that we saw a `Final` qualifier @@ -3056,7 +3071,7 @@ impl<'db> ClassLiteral<'db> { continue; } - return annotation; + return Member::declared(annotation); } } @@ -3248,20 +3263,16 @@ impl<'db> ClassLiteral<'db> { } } - if is_attribute_bound { + Member::inferred(if is_attribute_bound { Place::bound(union_of_inferred_types.build()).with_qualifiers(qualifiers) } else { Place::Unbound.with_qualifiers(qualifiers) - } + }) } /// A helper function for `instance_member` that looks up the `name` attribute only on /// this class, not on its superclasses. - pub(crate) fn own_instance_member( - self, - db: &'db dyn Db, - name: &str, - ) -> PlaceAndQualifiers<'db> { + pub(crate) fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { // TODO: There are many things that are not yet implemented here: // - `typing.Final` // - Proper diagnostics @@ -3291,10 +3302,9 @@ impl<'db> ClassLiteral<'db> { // We ignore `InitVar` declarations on the class body, unless that attribute is overwritten // by an implicit assignment in a method if Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) - .place .is_unbound() { - return Place::Unbound.into(); + return Member::unbound(); } } @@ -3309,20 +3319,21 @@ impl<'db> ClassLiteral<'db> { if let Some(implicit_ty) = Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) - .place .ignore_possibly_unbound() { if declaredness == Boundness::Bound { // If a symbol is definitely declared, and we see // attribute assignments in methods of the class, // we trust the declared type. - declared.with_qualifiers(qualifiers) + Member::declared(declared.with_qualifiers(qualifiers)) } else { - Place::Type( - UnionType::from_elements(db, [declared_ty, implicit_ty]), - declaredness, + Member::declared( + Place::Type( + UnionType::from_elements(db, [declared_ty, implicit_ty]), + declaredness, + ) + .with_qualifiers(qualifiers), ) - .with_qualifiers(qualifiers) } } else { // The symbol is declared and bound in the class body, @@ -3331,7 +3342,7 @@ impl<'db> ClassLiteral<'db> { // has a class-level default value, but it would not be // found in a `__dict__` lookup. - Place::Unbound.into() + Member::unbound() } } else { // The attribute is declared but not bound in the class body. @@ -3341,7 +3352,7 @@ impl<'db> ClassLiteral<'db> { // union with the inferred type from attribute assignments. if declaredness == Boundness::Bound { - declared.with_qualifiers(qualifiers) + Member::declared(declared.with_qualifiers(qualifiers)) } else { if let Some(implicit_ty) = Self::implicit_attribute( db, @@ -3349,16 +3360,19 @@ impl<'db> ClassLiteral<'db> { name, MethodDecorator::None, ) + .inner .place .ignore_possibly_unbound() { - Place::Type( - UnionType::from_elements(db, [declared_ty, implicit_ty]), - declaredness, + Member::declared( + Place::Type( + UnionType::from_elements(db, [declared_ty, implicit_ty]), + declaredness, + ) + .with_qualifiers(qualifiers), ) - .with_qualifiers(qualifiers) } else { - declared.with_qualifiers(qualifiers) + Member::declared(declared.with_qualifiers(qualifiers)) } } } @@ -3563,7 +3577,7 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { let attribute_variances = attribute_names .map(|name| { - let place_and_quals = self.own_instance_member(db, &name); + let place_and_quals = self.own_instance_member(db, &name).inner; (name, place_and_quals) }) .chain(attribute_places_and_qualifiers) @@ -5327,6 +5341,7 @@ impl SlotsKind { fn from(db: &dyn Db, base: ClassLiteral) -> Self { let Place::Type(slots_ty, bound) = base .own_class_member(db, base.inherited_generic_context(db), None, "__slots__") + .inner .place else { return Self::NotSpecified; From f715d70be16d5d0b887e446405641d069132cdbb Mon Sep 17 00:00:00 2001 From: Takayuki Maeda Date: Mon, 13 Oct 2025 17:29:15 +0900 Subject: [PATCH 013/113] [`ruff`] Use DiagnosticTag for more flake8 and numpy rules (#20758) --- .../flake8_bandit/rules/suspicious_function_call.rs | 9 ++++++--- .../src/rules/flake8_pyi/rules/bytestring_usage.rs | 7 +++++-- .../src/rules/flake8_pytest_style/rules/fixture.rs | 4 +++- .../flake8_unused_arguments/rules/unused_arguments.rs | 3 ++- .../src/rules/numpy/rules/deprecated_function.rs | 1 + .../src/rules/numpy/rules/deprecated_type_alias.rs | 1 + 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs index 4e2a1dc35c..fed37def9a 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/suspicious_function_call.rs @@ -1091,9 +1091,12 @@ fn suspicious_function( ] => checker.report_diagnostic_if_enabled(SuspiciousInsecureCipherModeUsage, range), // Mktemp - ["tempfile", "mktemp"] => { - checker.report_diagnostic_if_enabled(SuspiciousMktempUsage, range) - } + ["tempfile", "mktemp"] => checker + .report_diagnostic_if_enabled(SuspiciousMktempUsage, range) + .map(|mut diagnostic| { + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); + diagnostic + }), // Eval ["" | "builtins", "eval"] => { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs index 264d650548..59d9173860 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/bytestring_usage.rs @@ -74,7 +74,8 @@ pub(crate) fn bytestring_attribute(checker: &Checker, attribute: &Expr) { ["collections", "abc", "ByteString"] => ByteStringOrigin::CollectionsAbc, _ => return, }; - checker.report_diagnostic(ByteStringUsage { origin }, attribute.range()); + let mut diagnostic = checker.report_diagnostic(ByteStringUsage { origin }, attribute.range()); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); } /// PYI057 @@ -94,7 +95,9 @@ pub(crate) fn bytestring_import(checker: &Checker, import_from: &ast::StmtImport for name in names { if name.name.as_str() == "ByteString" { - checker.report_diagnostic(ByteStringUsage { origin }, name.range()); + let mut diagnostic = + checker.report_diagnostic(ByteStringUsage { origin }, name.range()); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); } } } diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs index 128b3b06e3..6c0558636c 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/fixture.rs @@ -898,7 +898,9 @@ fn check_test_function_args(checker: &Checker, parameters: &Parameters, decorato /// PT020 fn check_fixture_decorator_name(checker: &Checker, decorator: &Decorator) { if is_pytest_yield_fixture(decorator, checker.semantic()) { - checker.report_diagnostic(PytestDeprecatedYieldFixture, decorator.range()); + let mut diagnostic = + checker.report_diagnostic(PytestDeprecatedYieldFixture, decorator.range()); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); } } diff --git a/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs b/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs index fb0ea5a004..2aff59aa32 100644 --- a/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs +++ b/crates/ruff_linter/src/rules/flake8_unused_arguments/rules/unused_arguments.rs @@ -223,7 +223,7 @@ enum Argumentable { impl Argumentable { fn check_for(self, checker: &Checker, name: String, range: TextRange) { - match self { + let mut diagnostic = match self { Self::Function => checker.report_diagnostic(UnusedFunctionArgument { name }, range), Self::Method => checker.report_diagnostic(UnusedMethodArgument { name }, range), Self::ClassMethod => { @@ -234,6 +234,7 @@ impl Argumentable { } Self::Lambda => checker.report_diagnostic(UnusedLambdaArgument { name }, range), }; + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Unnecessary); } const fn rule_code(self) -> Rule { diff --git a/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs b/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs index b34f4b917d..1cbae5066d 100644 --- a/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs +++ b/crates/ruff_linter/src/rules/numpy/rules/deprecated_function.rs @@ -80,6 +80,7 @@ pub(crate) fn deprecated_function(checker: &Checker, expr: &Expr) { }, expr.range(), ); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); diagnostic.try_set_fix(|| { let (import_edit, binding) = checker.importer().get_or_import_symbol( &ImportRequest::import_from("numpy", replacement), diff --git a/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs b/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs index daa445642a..60967e12b4 100644 --- a/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs +++ b/crates/ruff_linter/src/rules/numpy/rules/deprecated_type_alias.rs @@ -80,6 +80,7 @@ pub(crate) fn deprecated_type_alias(checker: &Checker, expr: &Expr) { }, expr.range(), ); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); let type_name = match type_name { "unicode" => "str", _ => type_name, From 565dbf3c9d5e0e8993fb423ddc7919bcf7fca909 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Oct 2025 10:58:37 +0200 Subject: [PATCH 014/113] [ty] Move `class_member` to `member` module (#20837) ## Summary Move the `class_member` function to the `member` module. This allows us to move the `member` module into the `types` module and to reduce the visibility of its contents to `pub(super)`. The drawback is that we need to make `place::place_by_id` public. ## Test Plan Pure refactoring. --- crates/ty_python_semantic/src/lib.rs | 1 - crates/ty_python_semantic/src/member.rs | 73 ----------- crates/ty_python_semantic/src/place.rs | 47 +------ crates/ty_python_semantic/src/types.rs | 1 + crates/ty_python_semantic/src/types/class.rs | 8 +- crates/ty_python_semantic/src/types/member.rs | 120 ++++++++++++++++++ 6 files changed, 126 insertions(+), 124 deletions(-) delete mode 100644 crates/ty_python_semantic/src/member.rs create mode 100644 crates/ty_python_semantic/src/types/member.rs diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index e0619d9f20..dbe07aa600 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -34,7 +34,6 @@ mod db; mod dunder_all; pub mod lint; pub(crate) mod list; -mod member; mod module_name; mod module_resolver; mod node_key; diff --git a/crates/ty_python_semantic/src/member.rs b/crates/ty_python_semantic/src/member.rs deleted file mode 100644 index 0c078d1fb1..0000000000 --- a/crates/ty_python_semantic/src/member.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::{ - place::{Place, PlaceAndQualifiers}, - types::Type, -}; - -/// The return type of certain member-lookup operations. Contains information -/// about the type, type qualifiers, boundness/declaredness, and additional -/// metadata (e.g. whether or not the member was declared) -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] -pub(crate) struct Member<'db> { - /// Type, qualifiers, and boundness information of this member - pub(crate) inner: PlaceAndQualifiers<'db>, - - /// Whether or not this member was explicitly declared (e.g. `attr: int = 1` - /// on the class body or `self.attr: int = 1` in a class method), or if the - /// type was inferred (e.g. `attr = 1` on the class body or `self.attr = 1` - /// in a class method). - pub(crate) is_declared: bool, -} - -impl Default for Member<'_> { - fn default() -> Self { - Member::inferred(PlaceAndQualifiers::default()) - } -} - -impl<'db> Member<'db> { - /// Create a new [`Member`] whose type was inferred (rather than explicitly declared). - pub(crate) fn inferred(inner: PlaceAndQualifiers<'db>) -> Self { - Self { - inner, - is_declared: false, - } - } - - /// Create a new [`Member`] whose type was explicitly declared (rather than inferred). - pub(crate) fn declared(inner: PlaceAndQualifiers<'db>) -> Self { - Self { - inner, - is_declared: true, - } - } - - /// Create a new [`Member`] whose type was explicitly and definitively declared, i.e. - /// there is no control flow path in which it might be possibly undeclared. - pub(crate) fn definitely_declared(ty: Type<'db>) -> Self { - Self::declared(Place::bound(ty).into()) - } - - /// Represents the absence of a member. - pub(crate) fn unbound() -> Self { - Self::inferred(PlaceAndQualifiers::default()) - } - - /// Returns `true` if the inner place is unbound (i.e. there is no such member). - pub(crate) fn is_unbound(&self) -> bool { - self.inner.place.is_unbound() - } - - /// Returns the inner type, unless it is definitely unbound. - pub(crate) fn ignore_possibly_unbound(&self) -> Option> { - self.inner.place.ignore_possibly_unbound() - } - - /// Map a type transformation function over the type of this member. - #[must_use] - pub(crate) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { - Self { - inner: self.inner.map_type(f), - is_declared: self.is_declared, - } - } -} diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 7495373b09..7e8820cb77 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1,7 +1,6 @@ use ruff_db::files::File; use crate::dunder_all::dunder_all_names; -use crate::member::Member; use crate::module_resolver::{KnownModule, file_to_module}; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::place::{PlaceExprRef, ScopedPlaceId}; @@ -233,50 +232,6 @@ pub(crate) fn place<'db>( ) } -/// Infer the public type of a class member/symbol (its type as seen from outside its scope) in the given -/// `scope`. -pub(crate) fn class_member<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Member<'db> { - place_table(db, scope) - .symbol_id(name) - .map(|symbol_id| { - let place_and_quals = place_by_id( - db, - scope, - symbol_id.into(), - RequiresExplicitReExport::No, - ConsideredDefinitions::EndOfScope, - ); - - if !place_and_quals.place.is_unbound() && !place_and_quals.is_init_var() { - // Trust the declared type if we see a class-level declaration - return Member::declared(place_and_quals); - } - - if let PlaceAndQualifiers { - place: Place::Type(ty, _), - qualifiers, - } = place_and_quals - { - // Otherwise, we need to check if the symbol has bindings - let use_def = use_def_map(db, scope); - let bindings = use_def.end_of_scope_symbol_bindings(symbol_id); - let inferred = place_from_bindings_impl(db, bindings, RequiresExplicitReExport::No); - - // TODO: we should not need to calculate inferred type second time. This is a temporary - // solution until the notion of Boundness and Declaredness is split. See #16036, #16264 - Member::inferred(match inferred { - Place::Unbound => Place::Unbound.with_qualifiers(qualifiers), - Place::Type(_, boundness) => { - Place::Type(ty, boundness).with_qualifiers(qualifiers) - } - }) - } else { - Member::unbound() - } - }) - .unwrap_or_default() -} - /// Infers the public type of an explicit module-global symbol as seen from within the same file. /// /// Note that all global scopes also include various "implicit globals" such as `__name__`, @@ -701,7 +656,7 @@ fn place_cycle_initial<'db>( } #[salsa::tracked(cycle_fn=place_cycle_recover, cycle_initial=place_cycle_initial, heap_size=ruff_memory_usage::heap_size)] -fn place_by_id<'db>( +pub(crate) fn place_by_id<'db>( db: &'db dyn Db, scope: ScopeId<'db>, place_id: ScopedPlaceId, diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index faed47aacf..1c3aef00c5 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -89,6 +89,7 @@ mod generics; pub mod ide_support; mod infer; mod instance; +mod member; mod mro; mod narrow; mod protocol_class; diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index fd03dba47b..3fe625397a 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -7,7 +7,6 @@ use super::{ function::FunctionType, infer_expression_type, infer_unpack_types, }; use crate::FxOrderMap; -use crate::member::Member; use crate::module_resolver::KnownModule; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::scope::{NodeWithScopeKind, Scope}; @@ -22,6 +21,7 @@ use crate::types::enums::enum_metadata; use crate::types::function::{DataclassTransformerParams, KnownFunction}; use crate::types::generics::{GenericContext, Specialization, walk_specialization}; use crate::types::infer::nearest_enclosing_class; +use crate::types::member::{Member, class_member}; use crate::types::signatures::{CallableSignature, Parameter, Parameters, Signature}; use crate::types::tuple::{TupleSpec, TupleType}; use crate::types::typed_dict::typed_dict_params_from_class_def; @@ -37,8 +37,8 @@ use crate::{ Db, FxIndexMap, FxOrderSet, Program, module_resolver::file_to_module, place::{ - Boundness, LookupError, LookupResult, Place, PlaceAndQualifiers, class_member, - known_module_symbol, place_from_bindings, place_from_declarations, + Boundness, LookupError, LookupResult, Place, PlaceAndQualifiers, known_module_symbol, + place_from_bindings, place_from_declarations, }, semantic_index::{ attribute_assignments, @@ -3272,7 +3272,7 @@ impl<'db> ClassLiteral<'db> { /// A helper function for `instance_member` that looks up the `name` attribute only on /// this class, not on its superclasses. - pub(crate) fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { // TODO: There are many things that are not yet implemented here: // - `typing.Final` // - Proper diagnostics diff --git a/crates/ty_python_semantic/src/types/member.rs b/crates/ty_python_semantic/src/types/member.rs new file mode 100644 index 0000000000..3c541b1035 --- /dev/null +++ b/crates/ty_python_semantic/src/types/member.rs @@ -0,0 +1,120 @@ +use super::Type; +use crate::Db; +use crate::place::{ + ConsideredDefinitions, Place, PlaceAndQualifiers, RequiresExplicitReExport, place_by_id, + place_from_bindings, +}; +use crate::semantic_index::{place_table, scope::ScopeId, use_def_map}; + +/// The return type of certain member-lookup operations. Contains information +/// about the type, type qualifiers, boundness/declaredness, and additional +/// metadata (e.g. whether or not the member was declared) +#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +pub(super) struct Member<'db> { + /// Type, qualifiers, and boundness information of this member + pub(super) inner: PlaceAndQualifiers<'db>, + + /// Whether or not this member was explicitly declared (e.g. `attr: int = 1` + /// on the class body or `self.attr: int = 1` in a class method), or if the + /// type was inferred (e.g. `attr = 1` on the class body or `self.attr = 1` + /// in a class method). + pub(super) is_declared: bool, +} + +impl Default for Member<'_> { + fn default() -> Self { + Member::inferred(PlaceAndQualifiers::default()) + } +} + +impl<'db> Member<'db> { + /// Create a new [`Member`] whose type was inferred (rather than explicitly declared). + pub(super) fn inferred(inner: PlaceAndQualifiers<'db>) -> Self { + Self { + inner, + is_declared: false, + } + } + + /// Create a new [`Member`] whose type was explicitly declared (rather than inferred). + pub(super) fn declared(inner: PlaceAndQualifiers<'db>) -> Self { + Self { + inner, + is_declared: true, + } + } + + /// Create a new [`Member`] whose type was explicitly and definitively declared, i.e. + /// there is no control flow path in which it might be possibly undeclared. + pub(super) fn definitely_declared(ty: Type<'db>) -> Self { + Self::declared(Place::bound(ty).into()) + } + + /// Represents the absence of a member. + pub(super) fn unbound() -> Self { + Self::inferred(PlaceAndQualifiers::default()) + } + + /// Returns `true` if the inner place is unbound (i.e. there is no such member). + pub(super) fn is_unbound(&self) -> bool { + self.inner.place.is_unbound() + } + + /// Returns the inner type, unless it is definitely unbound. + pub(super) fn ignore_possibly_unbound(&self) -> Option> { + self.inner.place.ignore_possibly_unbound() + } + + /// Map a type transformation function over the type of this member. + #[must_use] + pub(super) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + Self { + inner: self.inner.map_type(f), + is_declared: self.is_declared, + } + } +} + +/// Infer the public type of a class member/symbol (its type as seen from outside its scope) in the given +/// `scope`. +pub(super) fn class_member<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Member<'db> { + place_table(db, scope) + .symbol_id(name) + .map(|symbol_id| { + let place_and_quals = place_by_id( + db, + scope, + symbol_id.into(), + RequiresExplicitReExport::No, + ConsideredDefinitions::EndOfScope, + ); + + if !place_and_quals.place.is_unbound() && !place_and_quals.is_init_var() { + // Trust the declared type if we see a class-level declaration + return Member::declared(place_and_quals); + } + + if let PlaceAndQualifiers { + place: Place::Type(ty, _), + qualifiers, + } = place_and_quals + { + // Otherwise, we need to check if the symbol has bindings + let use_def = use_def_map(db, scope); + let bindings = use_def.end_of_scope_symbol_bindings(symbol_id); + let inferred = place_from_bindings(db, bindings); + + // TODO: we should not need to calculate inferred type second time. This is a temporary + // solution until the notion of Boundness and Declaredness is split. See #16036, #16264 + Member::inferred(match inferred { + Place::Unbound => Place::Unbound.with_qualifiers(qualifiers), + Place::Type(_, boundness) => { + Place::Type(ty, boundness).with_qualifiers(qualifiers) + } + }) + } else { + Member::unbound() + } + }) + .unwrap_or_default() +} From d83d7a0dcdbeb8873f5b42bdf26b6fe46d2ba8ba Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 13 Oct 2025 11:57:46 +0100 Subject: [PATCH 015/113] [ty] Fix false-positive diagnostics on `super()` calls (#20814) --- .../resources/mdtest/class/super.md | 221 +++++++++- ...licit_Super_Objec…_(b753048091f275c0).snap | 217 ++++++++++ ...licit_Super_Objec…_(f9e5e48e3a4a4c12).snap | 214 ++++++++++ crates/ty_python_semantic/src/types.rs | 384 ++++++++++++++---- .../ty_python_semantic/src/types/display.rs | 4 +- .../ty_python_semantic/src/types/instance.rs | 58 +-- 6 files changed, 975 insertions(+), 123 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap diff --git a/crates/ty_python_semantic/resources/mdtest/class/super.md b/crates/ty_python_semantic/resources/mdtest/class/super.md index b4befc0e44..b943e4f21b 100644 --- a/crates/ty_python_semantic/resources/mdtest/class/super.md +++ b/crates/ty_python_semantic/resources/mdtest/class/super.md @@ -14,9 +14,16 @@ common usage. ### Explicit Super Object + + `super(pivot_class, owner)` performs attribute lookup along the MRO, starting immediately after the specified pivot class. +```toml +[environment] +python-version = "3.12" +``` + ```py class A: def a(self): ... @@ -34,21 +41,15 @@ reveal_type(C.__mro__) # revealed: tuple[, , , super(C, C()).a super(C, C()).b -# error: [unresolved-attribute] "Type `, C>` has no attribute `c`" -super(C, C()).c +super(C, C()).c # error: [unresolved-attribute] super(B, C()).a -# error: [unresolved-attribute] "Type `, C>` has no attribute `b`" -super(B, C()).b -# error: [unresolved-attribute] "Type `, C>` has no attribute `c`" -super(B, C()).c +super(B, C()).b # error: [unresolved-attribute] +super(B, C()).c # error: [unresolved-attribute] -# error: [unresolved-attribute] "Type `, C>` has no attribute `a`" -super(A, C()).a -# error: [unresolved-attribute] "Type `, C>` has no attribute `b`" -super(A, C()).b -# error: [unresolved-attribute] "Type `, C>` has no attribute `c`" -super(A, C()).c +super(A, C()).a # error: [unresolved-attribute] +super(A, C()).b # error: [unresolved-attribute] +super(A, C()).c # error: [unresolved-attribute] reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown reveal_type(super(C, C()).b) # revealed: bound method C.b() -> Unknown @@ -56,12 +57,80 @@ reveal_type(super(C, C()).aa) # revealed: int reveal_type(super(C, C()).bb) # revealed: int ``` +Examples of explicit `super()` with unusual types. We allow almost any type to be passed as the +second argument to `super()` -- the only exceptions are "pure abstract" types such as `Callable` and +synthesized `Protocol`s that cannot be upcast to, or interpreted as, a non-`object` nominal type. + +```py +import types +from typing_extensions import Callable, TypeIs, Literal, TypedDict + +def f(): ... + +class Foo[T]: + def method(self): ... + @property + def some_property(self): ... + +type Alias = int + +class SomeTypedDict(TypedDict): + x: int + y: bytes + +# revealed: , FunctionType> +reveal_type(super(object, f)) +# revealed: , WrapperDescriptorType> +reveal_type(super(object, types.FunctionType.__get__)) +# revealed: , GenericAlias> +reveal_type(super(object, Foo[int])) +# revealed: , _SpecialForm> +reveal_type(super(object, Literal)) +# revealed: , TypeAliasType> +reveal_type(super(object, Alias)) +# revealed: , MethodType> +reveal_type(super(object, Foo().method)) +# revealed: , property> +reveal_type(super(object, Foo.some_property)) + +def g(x: object) -> TypeIs[list[object]]: + return isinstance(x, list) + +def _(x: object, y: SomeTypedDict, z: Callable[[int, str], bool]): + if hasattr(x, "bar"): + # revealed: + reveal_type(x) + # error: [invalid-super-argument] + # revealed: Unknown + reveal_type(super(object, x)) + + # error: [invalid-super-argument] + # revealed: Unknown + reveal_type(super(object, z)) + + is_list = g(x) + # revealed: TypeIs[list[object] @ x] + reveal_type(is_list) + # revealed: , bool> + reveal_type(super(object, is_list)) + + # revealed: , dict[Literal["x", "y"], int | bytes]> + reveal_type(super(object, y)) +``` + ### Implicit Super Object + + The implicit form `super()` is same as `super(__class__, )`. The `__class__` refers to the class that contains the function where `super()` is used. The first argument refers to the current method’s first parameter (typically `self` or `cls`). +```toml +[environment] +python-version = "3.12" +``` + ```py from __future__ import annotations @@ -74,6 +143,7 @@ class B(A): def __init__(self, a: int): # TODO: Once `Self` is supported, this should be `, B>` reveal_type(super()) # revealed: , Unknown> + reveal_type(super(object, super())) # revealed: , super> super().__init__(a) @classmethod @@ -86,6 +156,123 @@ super(B, B(42)).__init__(42) super(B, B).f() ``` +Some examples with unusual annotations for `self` or `cls`: + +```py +import enum +from typing import Any, Self, Never, Protocol, Callable +from ty_extensions import Intersection + +class BuilderMeta(type): + def __new__( + cls: type[Any], + name: str, + bases: tuple[type, ...], + dct: dict[str, Any], + ) -> BuilderMeta: + # revealed: , Any> + s = reveal_type(super()) + # revealed: Any + return reveal_type(s.__new__(cls, name, bases, dct)) + +class BuilderMeta2(type): + def __new__( + cls: type[BuilderMeta2], + name: str, + bases: tuple[type, ...], + dct: dict[str, Any], + ) -> BuilderMeta2: + # revealed: , > + s = reveal_type(super()) + # TODO: should be `BuilderMeta2` (needs https://github.com/astral-sh/ty/issues/501) + # revealed: Unknown + return reveal_type(s.__new__(cls, name, bases, dct)) + +class Foo[T]: + x: T + + def method(self: Any): + reveal_type(super()) # revealed: , Any> + + if isinstance(self, Foo): + reveal_type(super()) # revealed: , Any> + + def method2(self: Foo[T]): + # revealed: , Foo[T@Foo]> + reveal_type(super()) + + def method3(self: Foo): + # revealed: , Foo[Unknown]> + reveal_type(super()) + + def method4(self: Self): + # revealed: , Foo[T@Foo]> + reveal_type(super()) + + def method5[S: Foo[int]](self: S, other: S) -> S: + # revealed: , Foo[int]> + reveal_type(super()) + return self + + def method6[S: (Foo[int], Foo[str])](self: S, other: S) -> S: + # revealed: , Foo[int]> | , Foo[str]> + reveal_type(super()) + return self + + def method7[S](self: S, other: S) -> S: + # error: [invalid-super-argument] + # revealed: Unknown + reveal_type(super()) + return self + + def method8[S: int](self: S, other: S) -> S: + # error: [invalid-super-argument] + # revealed: Unknown + reveal_type(super()) + return self + + def method9[S: (int, str)](self: S, other: S) -> S: + # error: [invalid-super-argument] + # revealed: Unknown + reveal_type(super()) + return self + + def method10[S: Callable[..., str]](self: S, other: S) -> S: + # error: [invalid-super-argument] + # revealed: Unknown + reveal_type(super()) + return self + +type Alias = Bar + +class Bar: + def method(self: Alias): + # revealed: , Bar> + reveal_type(super()) + + def pls_dont_call_me(self: Never): + # revealed: , Unknown> + reveal_type(super()) + + def only_call_me_on_callable_subclasses(self: Intersection[Bar, Callable[..., object]]): + # revealed: , Bar> + reveal_type(super()) + +class P(Protocol): + def method(self: P): + # revealed: , P> + reveal_type(super()) + +class E(enum.Enum): + X = 1 + + def method(self: E): + match self: + case E.X: + # revealed: , E> + reveal_type(super()) +``` + ### Unbound Super Object Calling `super(cls)` without a second argument returns an _unbound super object_. This is treated as @@ -167,11 +354,19 @@ class A: ## Built-ins and Literals ```py +from enum import Enum + reveal_type(super(bool, True)) # revealed: , bool> reveal_type(super(bool, bool())) # revealed: , bool> reveal_type(super(int, bool())) # revealed: , bool> reveal_type(super(int, 3)) # revealed: , int> reveal_type(super(str, "")) # revealed: , str> +reveal_type(super(bytes, b"")) # revealed: , bytes> + +class E(Enum): + X = 42 + +reveal_type(super(E, E.X)) # revealed: , E> ``` ## Descriptor Behavior with Super @@ -342,7 +537,7 @@ def f(x: int): # error: [invalid-super-argument] "`typing.TypeAliasType` is not a valid class" super(IntAlias, 0) -# error: [invalid-super-argument] "`Literal[""]` is not an instance or subclass of `` in `super(, Literal[""])` call" +# error: [invalid-super-argument] "`str` is not an instance or subclass of `` in `super(, str)` call" # revealed: Unknown reveal_type(super(int, str())) diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap new file mode 100644 index 0000000000..339c9a59a7 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap @@ -0,0 +1,217 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: super.md - Super - Basic Usage - Explicit Super Object +mdtest path: crates/ty_python_semantic/resources/mdtest/class/super.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | class A: + 2 | def a(self): ... + 3 | aa: int = 1 + 4 | + 5 | class B(A): + 6 | def b(self): ... + 7 | bb: int = 2 + 8 | + 9 | class C(B): +10 | def c(self): ... +11 | cc: int = 3 +12 | +13 | reveal_type(C.__mro__) # revealed: tuple[, , , ] +14 | +15 | super(C, C()).a +16 | super(C, C()).b +17 | super(C, C()).c # error: [unresolved-attribute] +18 | +19 | super(B, C()).a +20 | super(B, C()).b # error: [unresolved-attribute] +21 | super(B, C()).c # error: [unresolved-attribute] +22 | +23 | super(A, C()).a # error: [unresolved-attribute] +24 | super(A, C()).b # error: [unresolved-attribute] +25 | super(A, C()).c # error: [unresolved-attribute] +26 | +27 | reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown +28 | reveal_type(super(C, C()).b) # revealed: bound method C.b() -> Unknown +29 | reveal_type(super(C, C()).aa) # revealed: int +30 | reveal_type(super(C, C()).bb) # revealed: int +31 | import types +32 | from typing_extensions import Callable, TypeIs, Literal, TypedDict +33 | +34 | def f(): ... +35 | +36 | class Foo[T]: +37 | def method(self): ... +38 | @property +39 | def some_property(self): ... +40 | +41 | type Alias = int +42 | +43 | class SomeTypedDict(TypedDict): +44 | x: int +45 | y: bytes +46 | +47 | # revealed: , FunctionType> +48 | reveal_type(super(object, f)) +49 | # revealed: , WrapperDescriptorType> +50 | reveal_type(super(object, types.FunctionType.__get__)) +51 | # revealed: , GenericAlias> +52 | reveal_type(super(object, Foo[int])) +53 | # revealed: , _SpecialForm> +54 | reveal_type(super(object, Literal)) +55 | # revealed: , TypeAliasType> +56 | reveal_type(super(object, Alias)) +57 | # revealed: , MethodType> +58 | reveal_type(super(object, Foo().method)) +59 | # revealed: , property> +60 | reveal_type(super(object, Foo.some_property)) +61 | +62 | def g(x: object) -> TypeIs[list[object]]: +63 | return isinstance(x, list) +64 | +65 | def _(x: object, y: SomeTypedDict, z: Callable[[int, str], bool]): +66 | if hasattr(x, "bar"): +67 | # revealed: +68 | reveal_type(x) +69 | # error: [invalid-super-argument] +70 | # revealed: Unknown +71 | reveal_type(super(object, x)) +72 | +73 | # error: [invalid-super-argument] +74 | # revealed: Unknown +75 | reveal_type(super(object, z)) +76 | +77 | is_list = g(x) +78 | # revealed: TypeIs[list[object] @ x] +79 | reveal_type(is_list) +80 | # revealed: , bool> +81 | reveal_type(super(object, is_list)) +82 | +83 | # revealed: , dict[Literal["x", "y"], int | bytes]> +84 | reveal_type(super(object, y)) +``` + +# Diagnostics + +``` +error[unresolved-attribute]: Type `, C>` has no attribute `c` + --> src/mdtest_snippet.py:17:1 + | +15 | super(C, C()).a +16 | super(C, C()).b +17 | super(C, C()).c # error: [unresolved-attribute] + | ^^^^^^^^^^^^^^^ +18 | +19 | super(B, C()).a + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `, C>` has no attribute `b` + --> src/mdtest_snippet.py:20:1 + | +19 | super(B, C()).a +20 | super(B, C()).b # error: [unresolved-attribute] + | ^^^^^^^^^^^^^^^ +21 | super(B, C()).c # error: [unresolved-attribute] + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `, C>` has no attribute `c` + --> src/mdtest_snippet.py:21:1 + | +19 | super(B, C()).a +20 | super(B, C()).b # error: [unresolved-attribute] +21 | super(B, C()).c # error: [unresolved-attribute] + | ^^^^^^^^^^^^^^^ +22 | +23 | super(A, C()).a # error: [unresolved-attribute] + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `, C>` has no attribute `a` + --> src/mdtest_snippet.py:23:1 + | +21 | super(B, C()).c # error: [unresolved-attribute] +22 | +23 | super(A, C()).a # error: [unresolved-attribute] + | ^^^^^^^^^^^^^^^ +24 | super(A, C()).b # error: [unresolved-attribute] +25 | super(A, C()).c # error: [unresolved-attribute] + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `, C>` has no attribute `b` + --> src/mdtest_snippet.py:24:1 + | +23 | super(A, C()).a # error: [unresolved-attribute] +24 | super(A, C()).b # error: [unresolved-attribute] + | ^^^^^^^^^^^^^^^ +25 | super(A, C()).c # error: [unresolved-attribute] + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `, C>` has no attribute `c` + --> src/mdtest_snippet.py:25:1 + | +23 | super(A, C()).a # error: [unresolved-attribute] +24 | super(A, C()).b # error: [unresolved-attribute] +25 | super(A, C()).c # error: [unresolved-attribute] + | ^^^^^^^^^^^^^^^ +26 | +27 | reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[invalid-super-argument]: `` is an abstract/structural type in `super(, )` call + --> src/mdtest_snippet.py:71:21 + | +69 | # error: [invalid-super-argument] +70 | # revealed: Unknown +71 | reveal_type(super(object, x)) + | ^^^^^^^^^^^^^^^^ +72 | +73 | # error: [invalid-super-argument] + | +info: rule `invalid-super-argument` is enabled by default + +``` + +``` +error[invalid-super-argument]: `(int, str, /) -> bool` is an abstract/structural type in `super(, (int, str, /) -> bool)` call + --> src/mdtest_snippet.py:75:17 + | +73 | # error: [invalid-super-argument] +74 | # revealed: Unknown +75 | reveal_type(super(object, z)) + | ^^^^^^^^^^^^^^^^ +76 | +77 | is_list = g(x) + | +info: rule `invalid-super-argument` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap new file mode 100644 index 0000000000..846e4bdbb7 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap @@ -0,0 +1,214 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: super.md - Super - Basic Usage - Implicit Super Object +mdtest path: crates/ty_python_semantic/resources/mdtest/class/super.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from __future__ import annotations + 2 | + 3 | class A: + 4 | def __init__(self, a: int): ... + 5 | @classmethod + 6 | def f(cls): ... + 7 | + 8 | class B(A): + 9 | def __init__(self, a: int): + 10 | # TODO: Once `Self` is supported, this should be `, B>` + 11 | reveal_type(super()) # revealed: , Unknown> + 12 | reveal_type(super(object, super())) # revealed: , super> + 13 | super().__init__(a) + 14 | + 15 | @classmethod + 16 | def f(cls): + 17 | # TODO: Once `Self` is supported, this should be `, >` + 18 | reveal_type(super()) # revealed: , Unknown> + 19 | super().f() + 20 | + 21 | super(B, B(42)).__init__(42) + 22 | super(B, B).f() + 23 | import enum + 24 | from typing import Any, Self, Never, Protocol, Callable + 25 | from ty_extensions import Intersection + 26 | + 27 | class BuilderMeta(type): + 28 | def __new__( + 29 | cls: type[Any], + 30 | name: str, + 31 | bases: tuple[type, ...], + 32 | dct: dict[str, Any], + 33 | ) -> BuilderMeta: + 34 | # revealed: , Any> + 35 | s = reveal_type(super()) + 36 | # revealed: Any + 37 | return reveal_type(s.__new__(cls, name, bases, dct)) + 38 | + 39 | class BuilderMeta2(type): + 40 | def __new__( + 41 | cls: type[BuilderMeta2], + 42 | name: str, + 43 | bases: tuple[type, ...], + 44 | dct: dict[str, Any], + 45 | ) -> BuilderMeta2: + 46 | # revealed: , > + 47 | s = reveal_type(super()) + 48 | # TODO: should be `BuilderMeta2` (needs https://github.com/astral-sh/ty/issues/501) + 49 | # revealed: Unknown + 50 | return reveal_type(s.__new__(cls, name, bases, dct)) + 51 | + 52 | class Foo[T]: + 53 | x: T + 54 | + 55 | def method(self: Any): + 56 | reveal_type(super()) # revealed: , Any> + 57 | + 58 | if isinstance(self, Foo): + 59 | reveal_type(super()) # revealed: , Any> + 60 | + 61 | def method2(self: Foo[T]): + 62 | # revealed: , Foo[T@Foo]> + 63 | reveal_type(super()) + 64 | + 65 | def method3(self: Foo): + 66 | # revealed: , Foo[Unknown]> + 67 | reveal_type(super()) + 68 | + 69 | def method4(self: Self): + 70 | # revealed: , Foo[T@Foo]> + 71 | reveal_type(super()) + 72 | + 73 | def method5[S: Foo[int]](self: S, other: S) -> S: + 74 | # revealed: , Foo[int]> + 75 | reveal_type(super()) + 76 | return self + 77 | + 78 | def method6[S: (Foo[int], Foo[str])](self: S, other: S) -> S: + 79 | # revealed: , Foo[int]> | , Foo[str]> + 80 | reveal_type(super()) + 81 | return self + 82 | + 83 | def method7[S](self: S, other: S) -> S: + 84 | # error: [invalid-super-argument] + 85 | # revealed: Unknown + 86 | reveal_type(super()) + 87 | return self + 88 | + 89 | def method8[S: int](self: S, other: S) -> S: + 90 | # error: [invalid-super-argument] + 91 | # revealed: Unknown + 92 | reveal_type(super()) + 93 | return self + 94 | + 95 | def method9[S: (int, str)](self: S, other: S) -> S: + 96 | # error: [invalid-super-argument] + 97 | # revealed: Unknown + 98 | reveal_type(super()) + 99 | return self +100 | +101 | def method10[S: Callable[..., str]](self: S, other: S) -> S: +102 | # error: [invalid-super-argument] +103 | # revealed: Unknown +104 | reveal_type(super()) +105 | return self +106 | +107 | type Alias = Bar +108 | +109 | class Bar: +110 | def method(self: Alias): +111 | # revealed: , Bar> +112 | reveal_type(super()) +113 | +114 | def pls_dont_call_me(self: Never): +115 | # revealed: , Unknown> +116 | reveal_type(super()) +117 | +118 | def only_call_me_on_callable_subclasses(self: Intersection[Bar, Callable[..., object]]): +119 | # revealed: , Bar> +120 | reveal_type(super()) +121 | +122 | class P(Protocol): +123 | def method(self: P): +124 | # revealed: , P> +125 | reveal_type(super()) +126 | +127 | class E(enum.Enum): +128 | X = 1 +129 | +130 | def method(self: E): +131 | match self: +132 | case E.X: +133 | # revealed: , E> +134 | reveal_type(super()) +``` + +# Diagnostics + +``` +error[invalid-super-argument]: `S@method7` is not an instance or subclass of `` in `super(, S@method7)` call + --> src/mdtest_snippet.py:86:21 + | +84 | # error: [invalid-super-argument] +85 | # revealed: Unknown +86 | reveal_type(super()) + | ^^^^^^^ +87 | return self + | +info: Type variable `S` has `object` as its implicit upper bound +info: `object` is not an instance or subclass of `` +info: rule `invalid-super-argument` is enabled by default + +``` + +``` +error[invalid-super-argument]: `S@method8` is not an instance or subclass of `` in `super(, S@method8)` call + --> src/mdtest_snippet.py:92:21 + | +90 | # error: [invalid-super-argument] +91 | # revealed: Unknown +92 | reveal_type(super()) + | ^^^^^^^ +93 | return self + | +info: Type variable `S` has upper bound `int` +info: `int` is not an instance or subclass of `` +info: rule `invalid-super-argument` is enabled by default + +``` + +``` +error[invalid-super-argument]: `S@method9` is not an instance or subclass of `` in `super(, S@method9)` call + --> src/mdtest_snippet.py:98:21 + | +96 | # error: [invalid-super-argument] +97 | # revealed: Unknown +98 | reveal_type(super()) + | ^^^^^^^ +99 | return self + | +info: Type variable `S` has constraints `int, str` +info: `int | str` is not an instance or subclass of `` +info: rule `invalid-super-argument` is enabled by default + +``` + +``` +error[invalid-super-argument]: `S@method10` is a type variable with an abstract/structural type as its bounds or constraints, in `super(, S@method10)` call + --> src/mdtest_snippet.py:104:21 + | +102 | # error: [invalid-super-argument] +103 | # revealed: Unknown +104 | reveal_type(super()) + | ^^^^^^^ +105 | return self + | +info: Type variable `S` has upper bound `(...) -> str` +info: rule `invalid-super-argument` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1c3aef00c5..5dd17d692d 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1020,6 +1020,13 @@ impl<'db> Type<'db> { .expect("Expected a Type::Dynamic variant") } + pub(crate) const fn into_protocol_instance(self) -> Option> { + match self { + Type::ProtocolInstance(instance) => Some(instance), + _ => None, + } + } + #[track_caller] pub(crate) fn expect_class_literal(self) -> ClassLiteral<'db> { self.into_class_literal() @@ -11248,21 +11255,62 @@ impl<'db> EnumLiteralType<'db> { } } +/// Enumeration of ways in which a `super()` call can cause us to emit a diagnostic. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum BoundSuperError<'db> { - InvalidPivotClassType { + /// The second argument to `super()` (which may have been implicitly provided by + /// the Python interpreter) has an abstract or structural type. + /// It's impossible to determine whether a `Callable` type or a synthesized protocol + /// type is an instance or subclass of the pivot class, so these are rejected. + AbstractOwnerType { + owner_type: Type<'db>, pivot_class: Type<'db>, + /// If `owner_type` is a type variable, this contains the type variable instance + typevar_context: Option>, }, + /// The first argument to `super()` (which may have been implicitly provided by + /// the Python interpreter) is not a valid class type. + InvalidPivotClassType { pivot_class: Type<'db> }, + /// The second argument to `super()` was not a subclass or instance of the first argument. + /// (Note that both arguments may have been implicitly provided by the Python interpreter.) FailingConditionCheck { pivot_class: Type<'db>, owner: Type<'db>, + /// If `owner_type` is a type variable, this contains the type variable instance + typevar_context: Option>, }, + /// It was a single-argument `super()` call, but we were unable to determine + /// the implicit arguments provided by the Python interpreter. UnavailableImplicitArguments, } -impl BoundSuperError<'_> { - pub(super) fn report_diagnostic(&self, context: &InferContext, node: AnyNodeRef) { +impl<'db> BoundSuperError<'db> { + pub(super) fn report_diagnostic(&self, context: &'db InferContext<'db, '_>, node: AnyNodeRef) { match self { + BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class, + typevar_context, + } => { + if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { + if let Some(typevar_context) = typevar_context { + let mut diagnostic = builder.into_diagnostic(format_args!( + "`{owner}` is a type variable with an abstract/structural type as \ + its bounds or constraints, in `super({pivot_class}, {owner})` call", + pivot_class = pivot_class.display(context.db()), + owner = owner_type.display(context.db()), + )); + Self::describe_typevar(context.db(), &mut diagnostic, *typevar_context); + } else { + builder.into_diagnostic(format_args!( + "`{owner}` is an abstract/structural type in \ + `super({pivot_class}, {owner})` call", + pivot_class = pivot_class.display(context.db()), + owner = owner_type.display(context.db()), + )); + } + } + } BoundSuperError::InvalidPivotClassType { pivot_class } => { if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { builder.into_diagnostic(format_args!( @@ -11271,14 +11319,28 @@ impl BoundSuperError<'_> { )); } } - BoundSuperError::FailingConditionCheck { pivot_class, owner } => { + BoundSuperError::FailingConditionCheck { + pivot_class, + owner, + typevar_context, + } => { if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { - builder.into_diagnostic(format_args!( + let mut diagnostic = builder.into_diagnostic(format_args!( "`{owner}` is not an instance or subclass of \ - `{pivot_class}` in `super({pivot_class}, {owner})` call", + `{pivot_class}` in `super({pivot_class}, {owner})` call", pivot_class = pivot_class.display(context.db()), owner = owner.display(context.db()), )); + if let Some(typevar_context) = typevar_context { + let bound_or_constraints_union = + Self::describe_typevar(context.db(), &mut diagnostic, *typevar_context); + diagnostic.info(format_args!( + "`{bounds_or_constraints}` is not an instance or subclass of `{pivot_class}`", + bounds_or_constraints = + bound_or_constraints_union.display(context.db()), + pivot_class = pivot_class.display(context.db()), + )); + } } } BoundSuperError::UnavailableImplicitArguments => { @@ -11292,6 +11354,44 @@ impl BoundSuperError<'_> { } } } + + /// Add an `info`-level diagnostic describing the bounds or constraints, + /// and return the type variable's upper bound or the union of its constraints. + fn describe_typevar( + db: &'db dyn Db, + diagnostic: &mut Diagnostic, + type_var: TypeVarInstance<'db>, + ) -> Type<'db> { + match type_var.bound_or_constraints(db) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + diagnostic.info(format_args!( + "Type variable `{}` has upper bound `{}`", + type_var.name(db), + bound.display(db) + )); + bound + } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + diagnostic.info(format_args!( + "Type variable `{}` has constraints `{}`", + type_var.name(db), + constraints + .elements(db) + .iter() + .map(|c| c.display(db)) + .join(", ") + )); + Type::Union(constraints) + } + None => { + diagnostic.info(format_args!( + "Type variable `{}` has `object` as its implicit upper bound", + type_var.name(db) + )); + Type::object() + } + } + } } #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)] @@ -11326,14 +11426,6 @@ impl<'db> SuperOwnerKind<'db> { } } - fn into_type(self) -> Type<'db> { - match self { - SuperOwnerKind::Dynamic(dynamic) => Type::Dynamic(dynamic), - SuperOwnerKind::Class(class) => class.into(), - SuperOwnerKind::Instance(instance) => instance.into(), - } - } - fn into_class(self, db: &'db dyn Db) -> Option> { match self { SuperOwnerKind::Dynamic(_) => None, @@ -11341,35 +11433,6 @@ impl<'db> SuperOwnerKind<'db> { SuperOwnerKind::Instance(instance) => Some(instance.class(db)), } } - - fn try_from_type(db: &'db dyn Db, ty: Type<'db>) -> Option { - match ty { - Type::Dynamic(dynamic) => Some(SuperOwnerKind::Dynamic(dynamic)), - Type::ClassLiteral(class_literal) => Some(SuperOwnerKind::Class( - class_literal.apply_optional_specialization(db, None), - )), - Type::NominalInstance(instance) => Some(SuperOwnerKind::Instance(instance)), - Type::BooleanLiteral(_) => { - SuperOwnerKind::try_from_type(db, KnownClass::Bool.to_instance(db)) - } - Type::IntLiteral(_) => { - SuperOwnerKind::try_from_type(db, KnownClass::Int.to_instance(db)) - } - Type::StringLiteral(_) => { - SuperOwnerKind::try_from_type(db, KnownClass::Str.to_instance(db)) - } - Type::LiteralString => { - SuperOwnerKind::try_from_type(db, KnownClass::Str.to_instance(db)) - } - Type::BytesLiteral(_) => { - SuperOwnerKind::try_from_type(db, KnownClass::Bytes.to_instance(db)) - } - Type::SpecialForm(special_form) => { - SuperOwnerKind::try_from_type(db, special_form.instance_fallback(db)) - } - _ => None, - } - } } impl<'db> From> for Type<'db> { @@ -11397,8 +11460,8 @@ fn walk_bound_super_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( bound_super: BoundSuperType<'db>, visitor: &V, ) { - visitor.visit_type(db, bound_super.pivot_class(db).into()); - visitor.visit_type(db, bound_super.owner(db).into_type()); + visitor.visit_type(db, Type::from(bound_super.pivot_class(db))); + visitor.visit_type(db, Type::from(bound_super.owner(db))); } impl<'db> BoundSuperType<'db> { @@ -11414,16 +11477,171 @@ impl<'db> BoundSuperType<'db> { pivot_class_type: Type<'db>, owner_type: Type<'db>, ) -> Result, BoundSuperError<'db>> { - if let Type::Union(union) = owner_type { - return Ok(UnionType::from_elements( - db, - union + let delegate_to = + |type_to_delegate_to| BoundSuperType::build(db, pivot_class_type, type_to_delegate_to); + + let delegate_with_error_mapped = + |type_to_delegate_to, error_context: Option>| { + delegate_to(type_to_delegate_to).map_err(|err| match err { + BoundSuperError::AbstractOwnerType { + owner_type: _, + pivot_class: _, + typevar_context: _, + } => BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: error_context, + }, + BoundSuperError::FailingConditionCheck { + pivot_class, + owner: _, + typevar_context: _, + } => BoundSuperError::FailingConditionCheck { + pivot_class, + owner: owner_type, + typevar_context: error_context, + }, + BoundSuperError::InvalidPivotClassType { pivot_class } => { + BoundSuperError::InvalidPivotClassType { pivot_class } + } + BoundSuperError::UnavailableImplicitArguments => { + BoundSuperError::UnavailableImplicitArguments + } + }) + }; + + let owner = match owner_type { + Type::Never => SuperOwnerKind::Dynamic(DynamicType::Unknown), + Type::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), + Type::ClassLiteral(class) => SuperOwnerKind::Class(ClassType::NonGeneric(class)), + Type::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() { + SubclassOfInner::Class(class) => SuperOwnerKind::Class(class), + SubclassOfInner::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), + }, + Type::NominalInstance(instance) => SuperOwnerKind::Instance(instance), + + Type::ProtocolInstance(protocol) => { + if let Some(nominal_instance) = protocol.as_nominal_type() { + SuperOwnerKind::Instance(nominal_instance) + } else { + return Err(BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: None, + }); + } + } + + Type::Union(union) => { + return Ok(union .elements(db) .iter() - .map(|ty| BoundSuperType::build(db, pivot_class_type, *ty)) - .collect::, _>>()?, - )); - } + .try_fold(UnionBuilder::new(db), |builder, element| { + delegate_to(*element).map(|ty| builder.add(ty)) + })? + .build()); + } + Type::Intersection(intersection) => { + let mut builder = IntersectionBuilder::new(db); + let mut one_good_element_found = false; + for positive in intersection.positive(db) { + if let Ok(good_element) = delegate_to(*positive) { + one_good_element_found = true; + builder = builder.add_positive(good_element); + } + } + if !one_good_element_found { + return Err(BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: None, + }); + } + for negative in intersection.negative(db) { + if let Ok(good_element) = delegate_to(*negative) { + builder = builder.add_negative(good_element); + } + } + return Ok(builder.build()); + } + Type::TypeAlias(alias) => { + return delegate_with_error_mapped(alias.value_type(db), None); + } + Type::TypeVar(type_var) | Type::NonInferableTypeVar(type_var) => { + let type_var = type_var.typevar(db); + return match type_var.bound_or_constraints(db) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + delegate_with_error_mapped(bound, Some(type_var)) + } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + delegate_with_error_mapped(Type::Union(constraints), Some(type_var)) + } + None => delegate_with_error_mapped(Type::object(), Some(type_var)), + }; + } + Type::BooleanLiteral(_) | Type::TypeIs(_) => { + return delegate_to(KnownClass::Bool.to_instance(db)); + } + Type::IntLiteral(_) => return delegate_to(KnownClass::Int.to_instance(db)), + Type::StringLiteral(_) | Type::LiteralString => { + return delegate_to(KnownClass::Str.to_instance(db)); + } + Type::BytesLiteral(_) => { + return delegate_to(KnownClass::Bytes.to_instance(db)); + } + Type::SpecialForm(special_form) => { + return delegate_to(special_form.instance_fallback(db)); + } + Type::KnownInstance(instance) => { + return delegate_to(instance.instance_fallback(db)); + } + Type::FunctionLiteral(_) | Type::DataclassDecorator(_) => { + return delegate_to(KnownClass::FunctionType.to_instance(db)); + } + Type::WrapperDescriptor(_) => { + return delegate_to(KnownClass::WrapperDescriptorType.to_instance(db)); + } + Type::KnownBoundMethod(method) => { + return delegate_to(method.class().to_instance(db)); + } + Type::BoundMethod(_) => return delegate_to(KnownClass::MethodType.to_instance(db)), + Type::ModuleLiteral(_) => { + return delegate_to(KnownClass::ModuleType.to_instance(db)); + } + Type::GenericAlias(_) => return delegate_to(KnownClass::GenericAlias.to_instance(db)), + Type::PropertyInstance(_) => return delegate_to(KnownClass::Property.to_instance(db)), + Type::EnumLiteral(enum_literal_type) => { + return delegate_to(enum_literal_type.enum_class_instance(db)); + } + Type::BoundSuper(_) => return delegate_to(KnownClass::Super.to_instance(db)), + Type::TypedDict(td) => { + // In general it isn't sound to upcast a `TypedDict` to a `dict`, + // but here it seems like it's probably sound? + let mut key_builder = UnionBuilder::new(db); + let mut value_builder = UnionBuilder::new(db); + for (name, field) in td.items(db) { + key_builder = key_builder.add(Type::string_literal(db, &name)); + value_builder = value_builder.add(field.declared_ty); + } + return delegate_to( + KnownClass::Dict + .to_specialized_instance(db, [key_builder.build(), value_builder.build()]), + ); + } + Type::Callable(callable) if callable.is_function_like(db) => { + return delegate_to(KnownClass::FunctionType.to_instance(db)); + } + Type::AlwaysFalsy + | Type::AlwaysTruthy + | Type::Callable(_) + | Type::DataclassTransformer(_) => { + return Err(BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: None, + }); + } + }; // We don't use `Classbase::try_from_type` here because: // - There are objects that may validly be present in a class's bases list @@ -11452,24 +11670,22 @@ impl<'db> BoundSuperType<'db> { } }; - let owner = SuperOwnerKind::try_from_type(db, owner_type) - .and_then(|owner| { - let Some(pivot_class) = pivot_class.into_class() else { - return Some(owner); - }; - let Some(owner_class) = owner.into_class(db) else { - return Some(owner); - }; - if owner_class.is_subclass_of(db, pivot_class) { - Some(owner) - } else { - None - } - }) - .ok_or(BoundSuperError::FailingConditionCheck { - pivot_class: pivot_class_type, - owner: owner_type, - })?; + if let Some(pivot_class) = pivot_class.into_class() + && let Some(owner_class) = owner.into_class(db) + { + let pivot_class = pivot_class.class_literal(db).0; + if !owner_class.iter_mro(db).any(|superclass| match superclass { + ClassBase::Dynamic(_) => true, + ClassBase::Generic | ClassBase::Protocol | ClassBase::TypedDict => false, + ClassBase::Class(superclass) => superclass.class_literal(db).0 == pivot_class, + }) { + return Err(BoundSuperError::FailingConditionCheck { + pivot_class: pivot_class_type, + owner: owner_type, + typevar_context: None, + }); + } + } Ok(Type::BoundSuper(BoundSuperType::new( db, @@ -11525,19 +11741,22 @@ impl<'db> BoundSuperType<'db> { db, attribute, Type::none(db), - owner.into_type(), + Type::from(owner), ) .0, ), - SuperOwnerKind::Instance(_) => Some( - Type::try_call_dunder_get_on_attribute( - db, - attribute, - owner.into_type(), - owner.into_type().to_meta_type(db), + SuperOwnerKind::Instance(_) => { + let owner = Type::from(owner); + Some( + Type::try_call_dunder_get_on_attribute( + db, + attribute, + owner, + owner.to_meta_type(db), + ) + .0, ) - .0, - ), + } } } @@ -11551,9 +11770,8 @@ impl<'db> BoundSuperType<'db> { ) -> PlaceAndQualifiers<'db> { let owner = self.owner(db); let class = match owner { - SuperOwnerKind::Dynamic(_) => { - return owner - .into_type() + SuperOwnerKind::Dynamic(dynamic) => { + return Type::Dynamic(dynamic) .find_name_in_mro_with_policy(db, name, policy) .expect("Calling `find_name_in_mro` on dynamic type should return `Some`"); } diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 1bee509e26..0a25401fe9 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -571,9 +571,7 @@ impl Display for DisplayRepresentation<'_> { "", pivot = Type::from(bound_super.pivot_class(self.db)) .display_with(self.db, self.settings.singleline()), - owner = bound_super - .owner(self.db) - .into_type() + owner = Type::from(bound_super.owner(self.db)) .display_with(self.db, self.settings.singleline()) ) } diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 7d7998530e..a7515898d1 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -156,31 +156,27 @@ impl<'db> Type<'db> { // This matches the behaviour of other type checkers, and is required for us to // recognise `str` as a subtype of `Container[str]`. structurally_satisfied.or(db, || { - if let Protocol::FromClass(class) = protocol.inner { - // if `self` and `other` are *both* protocols, we also need to treat `self` as if it - // were a nominal type, or we won't consider a protocol `P` that explicitly inherits - // from a protocol `Q` to be a subtype of `Q` to be a subtype of `Q` if it overrides - // `Q`'s members in a Liskov-incompatible way. - let type_to_test = if let Type::ProtocolInstance(ProtocolInstanceType { - inner: Protocol::FromClass(class), - .. - }) = self - { - Type::non_tuple_instance(db, class) - } else { - self - }; + let Some(nominal_instance) = protocol.as_nominal_type() else { + return ConstraintSet::from(false); + }; - type_to_test.has_relation_to_impl( - db, - Type::non_tuple_instance(db, class), - relation, - relation_visitor, - disjointness_visitor, - ) - } else { - ConstraintSet::from(false) - } + // if `self` and `other` are *both* protocols, we also need to treat `self` as if it + // were a nominal type, or we won't consider a protocol `P` that explicitly inherits + // from a protocol `Q` to be a subtype of `Q` to be a subtype of `Q` if it overrides + // `Q`'s members in a Liskov-incompatible way. + let type_to_test = self + .into_protocol_instance() + .and_then(ProtocolInstanceType::as_nominal_type) + .map(Type::NominalInstance) + .unwrap_or(self); + + type_to_test.has_relation_to_impl( + db, + Type::NominalInstance(nominal_instance), + relation, + relation_visitor, + disjointness_visitor, + ) }) } } @@ -605,6 +601,20 @@ impl<'db> ProtocolInstanceType<'db> { } } + /// If this is a class-based protocol, convert the protocol-instance into a nominal instance. + /// + /// If this is a synthesized protocol that does not correspond to a class definition + /// in source code, return `None`. These are "pure" abstract types, that cannot be + /// treated in a nominal way. + pub(super) fn as_nominal_type(self) -> Option> { + match self.inner { + Protocol::FromClass(class) => { + Some(NominalInstanceType(NominalInstanceInner::NonTuple(class))) + } + Protocol::Synthesized(_) => None, + } + } + /// Return the meta-type of this protocol-instance type. pub(super) fn to_meta_type(self, db: &'db dyn Db) -> Type<'db> { match self.inner { From 513d2996ec599d3c1990480e0edd3a7328adcd0f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 13 Oct 2025 12:18:13 +0100 Subject: [PATCH 016/113] [ty] Move logic for `super()` inference to a new `types::bound_super` submodule (#20840) --- crates/ty_python_semantic/src/types.rs | 564 +---------------- .../src/types/bound_super.rs | 573 ++++++++++++++++++ crates/ty_python_semantic/src/types/class.rs | 13 +- .../src/types/type_ordering.rs | 5 +- .../ty_python_semantic/src/types/visitor.rs | 9 +- 5 files changed, 591 insertions(+), 573 deletions(-) create mode 100644 crates/ty_python_semantic/src/types/bound_super.rs diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 5dd17d692d..e6c8b2e99d 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -8,15 +8,12 @@ use std::time::Duration; use bitflags::bitflags; use call::{CallDunderError, CallError, CallErrorKind}; use context::InferContext; -use diagnostic::{ - INVALID_CONTEXT_MANAGER, INVALID_SUPER_ARGUMENT, NOT_ITERABLE, POSSIBLY_MISSING_IMPLICIT_CALL, - UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS, -}; +use diagnostic::{INVALID_CONTEXT_MANAGER, NOT_ITERABLE, POSSIBLY_MISSING_IMPLICIT_CALL}; use ruff_db::Instant; use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; use ruff_db::files::File; +use ruff_python_ast as ast; use ruff_python_ast::name::Name; -use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_text_size::{Ranged, TextRange}; use type_ordering::union_or_intersection_elements_ordering; @@ -41,6 +38,7 @@ use crate::semantic_index::place::ScopedPlaceId; use crate::semantic_index::scope::ScopeId; use crate::semantic_index::{imported_modules, place_table, semantic_index}; use crate::suppression::check_suppressions; +use crate::types::bound_super::BoundSuperType; use crate::types::call::{Binding, Bindings, CallArguments, CallableBinding}; pub(crate) use crate::types::class_base::ClassBase; use crate::types::constraints::{ @@ -74,6 +72,7 @@ use instance::Protocol; pub use instance::{NominalInstanceType, ProtocolInstanceType}; pub use special_form::SpecialFormType; +mod bound_super; mod builder; mod call; mod class; @@ -11255,561 +11254,6 @@ impl<'db> EnumLiteralType<'db> { } } -/// Enumeration of ways in which a `super()` call can cause us to emit a diagnostic. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum BoundSuperError<'db> { - /// The second argument to `super()` (which may have been implicitly provided by - /// the Python interpreter) has an abstract or structural type. - /// It's impossible to determine whether a `Callable` type or a synthesized protocol - /// type is an instance or subclass of the pivot class, so these are rejected. - AbstractOwnerType { - owner_type: Type<'db>, - pivot_class: Type<'db>, - /// If `owner_type` is a type variable, this contains the type variable instance - typevar_context: Option>, - }, - /// The first argument to `super()` (which may have been implicitly provided by - /// the Python interpreter) is not a valid class type. - InvalidPivotClassType { pivot_class: Type<'db> }, - /// The second argument to `super()` was not a subclass or instance of the first argument. - /// (Note that both arguments may have been implicitly provided by the Python interpreter.) - FailingConditionCheck { - pivot_class: Type<'db>, - owner: Type<'db>, - /// If `owner_type` is a type variable, this contains the type variable instance - typevar_context: Option>, - }, - /// It was a single-argument `super()` call, but we were unable to determine - /// the implicit arguments provided by the Python interpreter. - UnavailableImplicitArguments, -} - -impl<'db> BoundSuperError<'db> { - pub(super) fn report_diagnostic(&self, context: &'db InferContext<'db, '_>, node: AnyNodeRef) { - match self { - BoundSuperError::AbstractOwnerType { - owner_type, - pivot_class, - typevar_context, - } => { - if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { - if let Some(typevar_context) = typevar_context { - let mut diagnostic = builder.into_diagnostic(format_args!( - "`{owner}` is a type variable with an abstract/structural type as \ - its bounds or constraints, in `super({pivot_class}, {owner})` call", - pivot_class = pivot_class.display(context.db()), - owner = owner_type.display(context.db()), - )); - Self::describe_typevar(context.db(), &mut diagnostic, *typevar_context); - } else { - builder.into_diagnostic(format_args!( - "`{owner}` is an abstract/structural type in \ - `super({pivot_class}, {owner})` call", - pivot_class = pivot_class.display(context.db()), - owner = owner_type.display(context.db()), - )); - } - } - } - BoundSuperError::InvalidPivotClassType { pivot_class } => { - if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { - builder.into_diagnostic(format_args!( - "`{pivot_class}` is not a valid class", - pivot_class = pivot_class.display(context.db()), - )); - } - } - BoundSuperError::FailingConditionCheck { - pivot_class, - owner, - typevar_context, - } => { - if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { - let mut diagnostic = builder.into_diagnostic(format_args!( - "`{owner}` is not an instance or subclass of \ - `{pivot_class}` in `super({pivot_class}, {owner})` call", - pivot_class = pivot_class.display(context.db()), - owner = owner.display(context.db()), - )); - if let Some(typevar_context) = typevar_context { - let bound_or_constraints_union = - Self::describe_typevar(context.db(), &mut diagnostic, *typevar_context); - diagnostic.info(format_args!( - "`{bounds_or_constraints}` is not an instance or subclass of `{pivot_class}`", - bounds_or_constraints = - bound_or_constraints_union.display(context.db()), - pivot_class = pivot_class.display(context.db()), - )); - } - } - } - BoundSuperError::UnavailableImplicitArguments => { - if let Some(builder) = - context.report_lint(&UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS, node) - { - builder.into_diagnostic(format_args!( - "Cannot determine implicit arguments for 'super()' in this context", - )); - } - } - } - } - - /// Add an `info`-level diagnostic describing the bounds or constraints, - /// and return the type variable's upper bound or the union of its constraints. - fn describe_typevar( - db: &'db dyn Db, - diagnostic: &mut Diagnostic, - type_var: TypeVarInstance<'db>, - ) -> Type<'db> { - match type_var.bound_or_constraints(db) { - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - diagnostic.info(format_args!( - "Type variable `{}` has upper bound `{}`", - type_var.name(db), - bound.display(db) - )); - bound - } - Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - diagnostic.info(format_args!( - "Type variable `{}` has constraints `{}`", - type_var.name(db), - constraints - .elements(db) - .iter() - .map(|c| c.display(db)) - .join(", ") - )); - Type::Union(constraints) - } - None => { - diagnostic.info(format_args!( - "Type variable `{}` has `object` as its implicit upper bound", - type_var.name(db) - )); - Type::object() - } - } - } -} - -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)] -pub enum SuperOwnerKind<'db> { - Dynamic(DynamicType<'db>), - Class(ClassType<'db>), - Instance(NominalInstanceType<'db>), -} - -impl<'db> SuperOwnerKind<'db> { - fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { - match self { - SuperOwnerKind::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic.normalized()), - SuperOwnerKind::Class(class) => { - SuperOwnerKind::Class(class.normalized_impl(db, visitor)) - } - SuperOwnerKind::Instance(instance) => instance - .normalized_impl(db, visitor) - .into_nominal_instance() - .map(Self::Instance) - .unwrap_or(Self::Dynamic(DynamicType::Any)), - } - } - - fn iter_mro(self, db: &'db dyn Db) -> impl Iterator> { - match self { - SuperOwnerKind::Dynamic(dynamic) => { - Either::Left(ClassBase::Dynamic(dynamic).mro(db, None)) - } - SuperOwnerKind::Class(class) => Either::Right(class.iter_mro(db)), - SuperOwnerKind::Instance(instance) => Either::Right(instance.class(db).iter_mro(db)), - } - } - - fn into_class(self, db: &'db dyn Db) -> Option> { - match self { - SuperOwnerKind::Dynamic(_) => None, - SuperOwnerKind::Class(class) => Some(class), - SuperOwnerKind::Instance(instance) => Some(instance.class(db)), - } - } -} - -impl<'db> From> for Type<'db> { - fn from(owner: SuperOwnerKind<'db>) -> Self { - match owner { - SuperOwnerKind::Dynamic(dynamic) => Type::Dynamic(dynamic), - SuperOwnerKind::Class(class) => class.into(), - SuperOwnerKind::Instance(instance) => instance.into(), - } - } -} - -/// Represent a bound super object like `super(PivotClass, owner)` -#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] -pub struct BoundSuperType<'db> { - pub pivot_class: ClassBase<'db>, - pub owner: SuperOwnerKind<'db>, -} - -// The Salsa heap is tracked separately. -impl get_size2::GetSize for BoundSuperType<'_> {} - -fn walk_bound_super_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( - db: &'db dyn Db, - bound_super: BoundSuperType<'db>, - visitor: &V, -) { - visitor.visit_type(db, Type::from(bound_super.pivot_class(db))); - visitor.visit_type(db, Type::from(bound_super.owner(db))); -} - -impl<'db> BoundSuperType<'db> { - /// Attempts to build a `Type::BoundSuper` based on the given `pivot_class` and `owner`. - /// - /// This mimics the behavior of Python's built-in `super(pivot, owner)` at runtime. - /// - `super(pivot, owner_class)` is valid only if `issubclass(owner_class, pivot)` - /// - `super(pivot, owner_instance)` is valid only if `isinstance(owner_instance, pivot)` - /// - /// However, the checking is skipped when any of the arguments is a dynamic type. - fn build( - db: &'db dyn Db, - pivot_class_type: Type<'db>, - owner_type: Type<'db>, - ) -> Result, BoundSuperError<'db>> { - let delegate_to = - |type_to_delegate_to| BoundSuperType::build(db, pivot_class_type, type_to_delegate_to); - - let delegate_with_error_mapped = - |type_to_delegate_to, error_context: Option>| { - delegate_to(type_to_delegate_to).map_err(|err| match err { - BoundSuperError::AbstractOwnerType { - owner_type: _, - pivot_class: _, - typevar_context: _, - } => BoundSuperError::AbstractOwnerType { - owner_type, - pivot_class: pivot_class_type, - typevar_context: error_context, - }, - BoundSuperError::FailingConditionCheck { - pivot_class, - owner: _, - typevar_context: _, - } => BoundSuperError::FailingConditionCheck { - pivot_class, - owner: owner_type, - typevar_context: error_context, - }, - BoundSuperError::InvalidPivotClassType { pivot_class } => { - BoundSuperError::InvalidPivotClassType { pivot_class } - } - BoundSuperError::UnavailableImplicitArguments => { - BoundSuperError::UnavailableImplicitArguments - } - }) - }; - - let owner = match owner_type { - Type::Never => SuperOwnerKind::Dynamic(DynamicType::Unknown), - Type::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), - Type::ClassLiteral(class) => SuperOwnerKind::Class(ClassType::NonGeneric(class)), - Type::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() { - SubclassOfInner::Class(class) => SuperOwnerKind::Class(class), - SubclassOfInner::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), - }, - Type::NominalInstance(instance) => SuperOwnerKind::Instance(instance), - - Type::ProtocolInstance(protocol) => { - if let Some(nominal_instance) = protocol.as_nominal_type() { - SuperOwnerKind::Instance(nominal_instance) - } else { - return Err(BoundSuperError::AbstractOwnerType { - owner_type, - pivot_class: pivot_class_type, - typevar_context: None, - }); - } - } - - Type::Union(union) => { - return Ok(union - .elements(db) - .iter() - .try_fold(UnionBuilder::new(db), |builder, element| { - delegate_to(*element).map(|ty| builder.add(ty)) - })? - .build()); - } - Type::Intersection(intersection) => { - let mut builder = IntersectionBuilder::new(db); - let mut one_good_element_found = false; - for positive in intersection.positive(db) { - if let Ok(good_element) = delegate_to(*positive) { - one_good_element_found = true; - builder = builder.add_positive(good_element); - } - } - if !one_good_element_found { - return Err(BoundSuperError::AbstractOwnerType { - owner_type, - pivot_class: pivot_class_type, - typevar_context: None, - }); - } - for negative in intersection.negative(db) { - if let Ok(good_element) = delegate_to(*negative) { - builder = builder.add_negative(good_element); - } - } - return Ok(builder.build()); - } - Type::TypeAlias(alias) => { - return delegate_with_error_mapped(alias.value_type(db), None); - } - Type::TypeVar(type_var) | Type::NonInferableTypeVar(type_var) => { - let type_var = type_var.typevar(db); - return match type_var.bound_or_constraints(db) { - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - delegate_with_error_mapped(bound, Some(type_var)) - } - Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - delegate_with_error_mapped(Type::Union(constraints), Some(type_var)) - } - None => delegate_with_error_mapped(Type::object(), Some(type_var)), - }; - } - Type::BooleanLiteral(_) | Type::TypeIs(_) => { - return delegate_to(KnownClass::Bool.to_instance(db)); - } - Type::IntLiteral(_) => return delegate_to(KnownClass::Int.to_instance(db)), - Type::StringLiteral(_) | Type::LiteralString => { - return delegate_to(KnownClass::Str.to_instance(db)); - } - Type::BytesLiteral(_) => { - return delegate_to(KnownClass::Bytes.to_instance(db)); - } - Type::SpecialForm(special_form) => { - return delegate_to(special_form.instance_fallback(db)); - } - Type::KnownInstance(instance) => { - return delegate_to(instance.instance_fallback(db)); - } - Type::FunctionLiteral(_) | Type::DataclassDecorator(_) => { - return delegate_to(KnownClass::FunctionType.to_instance(db)); - } - Type::WrapperDescriptor(_) => { - return delegate_to(KnownClass::WrapperDescriptorType.to_instance(db)); - } - Type::KnownBoundMethod(method) => { - return delegate_to(method.class().to_instance(db)); - } - Type::BoundMethod(_) => return delegate_to(KnownClass::MethodType.to_instance(db)), - Type::ModuleLiteral(_) => { - return delegate_to(KnownClass::ModuleType.to_instance(db)); - } - Type::GenericAlias(_) => return delegate_to(KnownClass::GenericAlias.to_instance(db)), - Type::PropertyInstance(_) => return delegate_to(KnownClass::Property.to_instance(db)), - Type::EnumLiteral(enum_literal_type) => { - return delegate_to(enum_literal_type.enum_class_instance(db)); - } - Type::BoundSuper(_) => return delegate_to(KnownClass::Super.to_instance(db)), - Type::TypedDict(td) => { - // In general it isn't sound to upcast a `TypedDict` to a `dict`, - // but here it seems like it's probably sound? - let mut key_builder = UnionBuilder::new(db); - let mut value_builder = UnionBuilder::new(db); - for (name, field) in td.items(db) { - key_builder = key_builder.add(Type::string_literal(db, &name)); - value_builder = value_builder.add(field.declared_ty); - } - return delegate_to( - KnownClass::Dict - .to_specialized_instance(db, [key_builder.build(), value_builder.build()]), - ); - } - Type::Callable(callable) if callable.is_function_like(db) => { - return delegate_to(KnownClass::FunctionType.to_instance(db)); - } - Type::AlwaysFalsy - | Type::AlwaysTruthy - | Type::Callable(_) - | Type::DataclassTransformer(_) => { - return Err(BoundSuperError::AbstractOwnerType { - owner_type, - pivot_class: pivot_class_type, - typevar_context: None, - }); - } - }; - - // We don't use `Classbase::try_from_type` here because: - // - There are objects that may validly be present in a class's bases list - // but are not valid as pivot classes, e.g. `typing.ChainMap` - // - There are objects that are not valid in a class's bases list - // but are valid as pivot classes, e.g. unsubscripted `typing.Generic` - let pivot_class = match pivot_class_type { - Type::ClassLiteral(class) => ClassBase::Class(ClassType::NonGeneric(class)), - Type::GenericAlias(class) => ClassBase::Class(ClassType::Generic(class)), - Type::SubclassOf(subclass_of) if subclass_of.subclass_of().is_dynamic() => { - ClassBase::Dynamic( - subclass_of - .subclass_of() - .into_dynamic() - .expect("Checked in branch arm"), - ) - } - Type::SpecialForm(SpecialFormType::Protocol) => ClassBase::Protocol, - Type::SpecialForm(SpecialFormType::Generic) => ClassBase::Generic, - Type::SpecialForm(SpecialFormType::TypedDict) => ClassBase::TypedDict, - Type::Dynamic(dynamic) => ClassBase::Dynamic(dynamic), - _ => { - return Err(BoundSuperError::InvalidPivotClassType { - pivot_class: pivot_class_type, - }); - } - }; - - if let Some(pivot_class) = pivot_class.into_class() - && let Some(owner_class) = owner.into_class(db) - { - let pivot_class = pivot_class.class_literal(db).0; - if !owner_class.iter_mro(db).any(|superclass| match superclass { - ClassBase::Dynamic(_) => true, - ClassBase::Generic | ClassBase::Protocol | ClassBase::TypedDict => false, - ClassBase::Class(superclass) => superclass.class_literal(db).0 == pivot_class, - }) { - return Err(BoundSuperError::FailingConditionCheck { - pivot_class: pivot_class_type, - owner: owner_type, - typevar_context: None, - }); - } - } - - Ok(Type::BoundSuper(BoundSuperType::new( - db, - pivot_class, - owner, - ))) - } - - /// Skips elements in the MRO up to and including the pivot class. - /// - /// If the pivot class is a dynamic type, its MRO can't be determined, - /// so we fall back to using the MRO of `DynamicType::Unknown`. - fn skip_until_after_pivot( - self, - db: &'db dyn Db, - mro_iter: impl Iterator>, - ) -> impl Iterator> { - let Some(pivot_class) = self.pivot_class(db).into_class() else { - return Either::Left(ClassBase::Dynamic(DynamicType::Unknown).mro(db, None)); - }; - - let mut pivot_found = false; - - Either::Right(mro_iter.skip_while(move |superclass| { - if pivot_found { - false - } else if Some(pivot_class) == superclass.into_class() { - pivot_found = true; - true - } else { - true - } - })) - } - - /// Tries to call `__get__` on the attribute. - /// The arguments passed to `__get__` depend on whether the owner is an instance or a class. - /// See the `CPython` implementation for reference: - /// - fn try_call_dunder_get_on_attribute( - self, - db: &'db dyn Db, - attribute: PlaceAndQualifiers<'db>, - ) -> Option> { - let owner = self.owner(db); - - match owner { - // If the owner is a dynamic type, we can't tell whether it's a class or an instance. - // Also, invoking a descriptor on a dynamic attribute is meaningless, so we don't handle this. - SuperOwnerKind::Dynamic(_) => None, - SuperOwnerKind::Class(_) => Some( - Type::try_call_dunder_get_on_attribute( - db, - attribute, - Type::none(db), - Type::from(owner), - ) - .0, - ), - SuperOwnerKind::Instance(_) => { - let owner = Type::from(owner); - Some( - Type::try_call_dunder_get_on_attribute( - db, - attribute, - owner, - owner.to_meta_type(db), - ) - .0, - ) - } - } - } - - /// Similar to `Type::find_name_in_mro_with_policy`, but performs lookup starting *after* the - /// pivot class in the MRO, based on the `owner` type instead of the `super` type. - fn find_name_in_mro_after_pivot( - self, - db: &'db dyn Db, - name: &str, - policy: MemberLookupPolicy, - ) -> PlaceAndQualifiers<'db> { - let owner = self.owner(db); - let class = match owner { - SuperOwnerKind::Dynamic(dynamic) => { - return Type::Dynamic(dynamic) - .find_name_in_mro_with_policy(db, name, policy) - .expect("Calling `find_name_in_mro` on dynamic type should return `Some`"); - } - SuperOwnerKind::Class(class) => class, - SuperOwnerKind::Instance(instance) => instance.class(db), - }; - - let (class_literal, _) = class.class_literal(db); - // TODO properly support super() with generic types - // * requires a fix for https://github.com/astral-sh/ruff/issues/17432 - // * also requires understanding how we should handle cases like this: - // ```python - // b_int: B[int] - // b_unknown: B - // - // super(B, b_int) - // super(B[int], b_unknown) - // ``` - match class_literal.generic_context(db) { - Some(_) => Place::bound(todo_type!("super in generic class")).into(), - None => class_literal.class_member_from_mro( - db, - name, - policy, - self.skip_until_after_pivot(db, owner.iter_mro(db)), - ), - } - } - - pub(super) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { - Self::new( - db, - self.pivot_class(db).normalized_impl(db, visitor), - self.owner(db).normalized_impl(db, visitor), - ) - } -} - #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct TypeIsType<'db> { return_type: Type<'db>, diff --git a/crates/ty_python_semantic/src/types/bound_super.rs b/crates/ty_python_semantic/src/types/bound_super.rs new file mode 100644 index 0000000000..07ce3fbc46 --- /dev/null +++ b/crates/ty_python_semantic/src/types/bound_super.rs @@ -0,0 +1,573 @@ +//! Logic for inferring `super()`, `super(x)` and `super(x, y)` calls. + +use itertools::{Either, Itertools}; +use ruff_db::diagnostic::Diagnostic; +use ruff_python_ast::AnyNodeRef; + +use crate::{ + Db, + place::{Place, PlaceAndQualifiers}, + types::{ + ClassBase, ClassType, DynamicType, IntersectionBuilder, KnownClass, MemberLookupPolicy, + NominalInstanceType, NormalizedVisitor, SpecialFormType, SubclassOfInner, Type, + TypeVarBoundOrConstraints, TypeVarInstance, UnionBuilder, + context::InferContext, + diagnostic::{INVALID_SUPER_ARGUMENT, UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS}, + todo_type, visitor, + }, +}; + +/// Enumeration of ways in which a `super()` call can cause us to emit a diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BoundSuperError<'db> { + /// The second argument to `super()` (which may have been implicitly provided by + /// the Python interpreter) has an abstract or structural type. + /// It's impossible to determine whether a `Callable` type or a synthesized protocol + /// type is an instance or subclass of the pivot class, so these are rejected. + AbstractOwnerType { + owner_type: Type<'db>, + pivot_class: Type<'db>, + /// If `owner_type` is a type variable, this contains the type variable instance + typevar_context: Option>, + }, + /// The first argument to `super()` (which may have been implicitly provided by + /// the Python interpreter) is not a valid class type. + InvalidPivotClassType { pivot_class: Type<'db> }, + /// The second argument to `super()` was not a subclass or instance of the first argument. + /// (Note that both arguments may have been implicitly provided by the Python interpreter.) + FailingConditionCheck { + pivot_class: Type<'db>, + owner: Type<'db>, + /// If `owner_type` is a type variable, this contains the type variable instance + typevar_context: Option>, + }, + /// It was a single-argument `super()` call, but we were unable to determine + /// the implicit arguments provided by the Python interpreter. + UnavailableImplicitArguments, +} + +impl<'db> BoundSuperError<'db> { + pub(super) fn report_diagnostic(&self, context: &'db InferContext<'db, '_>, node: AnyNodeRef) { + match self { + BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class, + typevar_context, + } => { + if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { + if let Some(typevar_context) = typevar_context { + let mut diagnostic = builder.into_diagnostic(format_args!( + "`{owner}` is a type variable with an abstract/structural type as \ + its bounds or constraints, in `super({pivot_class}, {owner})` call", + pivot_class = pivot_class.display(context.db()), + owner = owner_type.display(context.db()), + )); + Self::describe_typevar(context.db(), &mut diagnostic, *typevar_context); + } else { + builder.into_diagnostic(format_args!( + "`{owner}` is an abstract/structural type in \ + `super({pivot_class}, {owner})` call", + pivot_class = pivot_class.display(context.db()), + owner = owner_type.display(context.db()), + )); + } + } + } + BoundSuperError::InvalidPivotClassType { pivot_class } => { + if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { + builder.into_diagnostic(format_args!( + "`{pivot_class}` is not a valid class", + pivot_class = pivot_class.display(context.db()), + )); + } + } + BoundSuperError::FailingConditionCheck { + pivot_class, + owner, + typevar_context, + } => { + if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { + let mut diagnostic = builder.into_diagnostic(format_args!( + "`{owner}` is not an instance or subclass of \ + `{pivot_class}` in `super({pivot_class}, {owner})` call", + pivot_class = pivot_class.display(context.db()), + owner = owner.display(context.db()), + )); + if let Some(typevar_context) = typevar_context { + let bound_or_constraints_union = + Self::describe_typevar(context.db(), &mut diagnostic, *typevar_context); + diagnostic.info(format_args!( + "`{bounds_or_constraints}` is not an instance or subclass of `{pivot_class}`", + bounds_or_constraints = + bound_or_constraints_union.display(context.db()), + pivot_class = pivot_class.display(context.db()), + )); + } + } + } + BoundSuperError::UnavailableImplicitArguments => { + if let Some(builder) = + context.report_lint(&UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS, node) + { + builder.into_diagnostic(format_args!( + "Cannot determine implicit arguments for 'super()' in this context", + )); + } + } + } + } + + /// Add an `info`-level diagnostic describing the bounds or constraints, + /// and return the type variable's upper bound or the union of its constraints. + fn describe_typevar( + db: &'db dyn Db, + diagnostic: &mut Diagnostic, + type_var: TypeVarInstance<'db>, + ) -> Type<'db> { + match type_var.bound_or_constraints(db) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + diagnostic.info(format_args!( + "Type variable `{}` has upper bound `{}`", + type_var.name(db), + bound.display(db) + )); + bound + } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + diagnostic.info(format_args!( + "Type variable `{}` has constraints `{}`", + type_var.name(db), + constraints + .elements(db) + .iter() + .map(|c| c.display(db)) + .join(", ") + )); + Type::Union(constraints) + } + None => { + diagnostic.info(format_args!( + "Type variable `{}` has `object` as its implicit upper bound", + type_var.name(db) + )); + Type::object() + } + } + } +} + +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)] +pub enum SuperOwnerKind<'db> { + Dynamic(DynamicType<'db>), + Class(ClassType<'db>), + Instance(NominalInstanceType<'db>), +} + +impl<'db> SuperOwnerKind<'db> { + fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { + match self { + SuperOwnerKind::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic.normalized()), + SuperOwnerKind::Class(class) => { + SuperOwnerKind::Class(class.normalized_impl(db, visitor)) + } + SuperOwnerKind::Instance(instance) => instance + .normalized_impl(db, visitor) + .into_nominal_instance() + .map(Self::Instance) + .unwrap_or(Self::Dynamic(DynamicType::Any)), + } + } + + fn iter_mro(self, db: &'db dyn Db) -> impl Iterator> { + match self { + SuperOwnerKind::Dynamic(dynamic) => { + Either::Left(ClassBase::Dynamic(dynamic).mro(db, None)) + } + SuperOwnerKind::Class(class) => Either::Right(class.iter_mro(db)), + SuperOwnerKind::Instance(instance) => Either::Right(instance.class(db).iter_mro(db)), + } + } + + fn into_class(self, db: &'db dyn Db) -> Option> { + match self { + SuperOwnerKind::Dynamic(_) => None, + SuperOwnerKind::Class(class) => Some(class), + SuperOwnerKind::Instance(instance) => Some(instance.class(db)), + } + } +} + +impl<'db> From> for Type<'db> { + fn from(owner: SuperOwnerKind<'db>) -> Self { + match owner { + SuperOwnerKind::Dynamic(dynamic) => Type::Dynamic(dynamic), + SuperOwnerKind::Class(class) => class.into(), + SuperOwnerKind::Instance(instance) => instance.into(), + } + } +} + +/// Represent a bound super object like `super(PivotClass, owner)` +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub struct BoundSuperType<'db> { + pub pivot_class: ClassBase<'db>, + pub owner: SuperOwnerKind<'db>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for BoundSuperType<'_> {} + +pub(super) fn walk_bound_super_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( + db: &'db dyn Db, + bound_super: BoundSuperType<'db>, + visitor: &V, +) { + visitor.visit_type(db, Type::from(bound_super.pivot_class(db))); + visitor.visit_type(db, Type::from(bound_super.owner(db))); +} + +impl<'db> BoundSuperType<'db> { + /// Attempts to build a `Type::BoundSuper` based on the given `pivot_class` and `owner`. + /// + /// This mimics the behavior of Python's built-in `super(pivot, owner)` at runtime. + /// - `super(pivot, owner_class)` is valid only if `issubclass(owner_class, pivot)` + /// - `super(pivot, owner_instance)` is valid only if `isinstance(owner_instance, pivot)` + /// + /// However, the checking is skipped when any of the arguments is a dynamic type. + pub(super) fn build( + db: &'db dyn Db, + pivot_class_type: Type<'db>, + owner_type: Type<'db>, + ) -> Result, BoundSuperError<'db>> { + let delegate_to = + |type_to_delegate_to| BoundSuperType::build(db, pivot_class_type, type_to_delegate_to); + + let delegate_with_error_mapped = + |type_to_delegate_to, error_context: Option>| { + delegate_to(type_to_delegate_to).map_err(|err| match err { + BoundSuperError::AbstractOwnerType { + owner_type: _, + pivot_class: _, + typevar_context: _, + } => BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: error_context, + }, + BoundSuperError::FailingConditionCheck { + pivot_class, + owner: _, + typevar_context: _, + } => BoundSuperError::FailingConditionCheck { + pivot_class, + owner: owner_type, + typevar_context: error_context, + }, + BoundSuperError::InvalidPivotClassType { pivot_class } => { + BoundSuperError::InvalidPivotClassType { pivot_class } + } + BoundSuperError::UnavailableImplicitArguments => { + BoundSuperError::UnavailableImplicitArguments + } + }) + }; + + let owner = match owner_type { + Type::Never => SuperOwnerKind::Dynamic(DynamicType::Unknown), + Type::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), + Type::ClassLiteral(class) => SuperOwnerKind::Class(ClassType::NonGeneric(class)), + Type::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() { + SubclassOfInner::Class(class) => SuperOwnerKind::Class(class), + SubclassOfInner::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), + }, + Type::NominalInstance(instance) => SuperOwnerKind::Instance(instance), + + Type::ProtocolInstance(protocol) => { + if let Some(nominal_instance) = protocol.as_nominal_type() { + SuperOwnerKind::Instance(nominal_instance) + } else { + return Err(BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: None, + }); + } + } + + Type::Union(union) => { + return Ok(union + .elements(db) + .iter() + .try_fold(UnionBuilder::new(db), |builder, element| { + delegate_to(*element).map(|ty| builder.add(ty)) + })? + .build()); + } + Type::Intersection(intersection) => { + let mut builder = IntersectionBuilder::new(db); + let mut one_good_element_found = false; + for positive in intersection.positive(db) { + if let Ok(good_element) = delegate_to(*positive) { + one_good_element_found = true; + builder = builder.add_positive(good_element); + } + } + if !one_good_element_found { + return Err(BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: None, + }); + } + for negative in intersection.negative(db) { + if let Ok(good_element) = delegate_to(*negative) { + builder = builder.add_negative(good_element); + } + } + return Ok(builder.build()); + } + Type::TypeAlias(alias) => { + return delegate_with_error_mapped(alias.value_type(db), None); + } + Type::TypeVar(type_var) | Type::NonInferableTypeVar(type_var) => { + let type_var = type_var.typevar(db); + return match type_var.bound_or_constraints(db) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + delegate_with_error_mapped(bound, Some(type_var)) + } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + delegate_with_error_mapped(Type::Union(constraints), Some(type_var)) + } + None => delegate_with_error_mapped(Type::object(), Some(type_var)), + }; + } + Type::BooleanLiteral(_) | Type::TypeIs(_) => { + return delegate_to(KnownClass::Bool.to_instance(db)); + } + Type::IntLiteral(_) => return delegate_to(KnownClass::Int.to_instance(db)), + Type::StringLiteral(_) | Type::LiteralString => { + return delegate_to(KnownClass::Str.to_instance(db)); + } + Type::BytesLiteral(_) => { + return delegate_to(KnownClass::Bytes.to_instance(db)); + } + Type::SpecialForm(special_form) => { + return delegate_to(special_form.instance_fallback(db)); + } + Type::KnownInstance(instance) => { + return delegate_to(instance.instance_fallback(db)); + } + Type::FunctionLiteral(_) | Type::DataclassDecorator(_) => { + return delegate_to(KnownClass::FunctionType.to_instance(db)); + } + Type::WrapperDescriptor(_) => { + return delegate_to(KnownClass::WrapperDescriptorType.to_instance(db)); + } + Type::KnownBoundMethod(method) => { + return delegate_to(method.class().to_instance(db)); + } + Type::BoundMethod(_) => return delegate_to(KnownClass::MethodType.to_instance(db)), + Type::ModuleLiteral(_) => { + return delegate_to(KnownClass::ModuleType.to_instance(db)); + } + Type::GenericAlias(_) => return delegate_to(KnownClass::GenericAlias.to_instance(db)), + Type::PropertyInstance(_) => return delegate_to(KnownClass::Property.to_instance(db)), + Type::EnumLiteral(enum_literal_type) => { + return delegate_to(enum_literal_type.enum_class_instance(db)); + } + Type::BoundSuper(_) => return delegate_to(KnownClass::Super.to_instance(db)), + Type::TypedDict(td) => { + // In general it isn't sound to upcast a `TypedDict` to a `dict`, + // but here it seems like it's probably sound? + let mut key_builder = UnionBuilder::new(db); + let mut value_builder = UnionBuilder::new(db); + for (name, field) in td.items(db) { + key_builder = key_builder.add(Type::string_literal(db, &name)); + value_builder = value_builder.add(field.declared_ty); + } + return delegate_to( + KnownClass::Dict + .to_specialized_instance(db, [key_builder.build(), value_builder.build()]), + ); + } + Type::Callable(callable) if callable.is_function_like(db) => { + return delegate_to(KnownClass::FunctionType.to_instance(db)); + } + Type::AlwaysFalsy + | Type::AlwaysTruthy + | Type::Callable(_) + | Type::DataclassTransformer(_) => { + return Err(BoundSuperError::AbstractOwnerType { + owner_type, + pivot_class: pivot_class_type, + typevar_context: None, + }); + } + }; + + // We don't use `Classbase::try_from_type` here because: + // - There are objects that may validly be present in a class's bases list + // but are not valid as pivot classes, e.g. `typing.ChainMap` + // - There are objects that are not valid in a class's bases list + // but are valid as pivot classes, e.g. unsubscripted `typing.Generic` + let pivot_class = match pivot_class_type { + Type::ClassLiteral(class) => ClassBase::Class(ClassType::NonGeneric(class)), + Type::GenericAlias(class) => ClassBase::Class(ClassType::Generic(class)), + Type::SubclassOf(subclass_of) if subclass_of.subclass_of().is_dynamic() => { + ClassBase::Dynamic( + subclass_of + .subclass_of() + .into_dynamic() + .expect("Checked in branch arm"), + ) + } + Type::SpecialForm(SpecialFormType::Protocol) => ClassBase::Protocol, + Type::SpecialForm(SpecialFormType::Generic) => ClassBase::Generic, + Type::SpecialForm(SpecialFormType::TypedDict) => ClassBase::TypedDict, + Type::Dynamic(dynamic) => ClassBase::Dynamic(dynamic), + _ => { + return Err(BoundSuperError::InvalidPivotClassType { + pivot_class: pivot_class_type, + }); + } + }; + + if let Some(pivot_class) = pivot_class.into_class() + && let Some(owner_class) = owner.into_class(db) + { + let pivot_class = pivot_class.class_literal(db).0; + if !owner_class.iter_mro(db).any(|superclass| match superclass { + ClassBase::Dynamic(_) => true, + ClassBase::Generic | ClassBase::Protocol | ClassBase::TypedDict => false, + ClassBase::Class(superclass) => superclass.class_literal(db).0 == pivot_class, + }) { + return Err(BoundSuperError::FailingConditionCheck { + pivot_class: pivot_class_type, + owner: owner_type, + typevar_context: None, + }); + } + } + + Ok(Type::BoundSuper(BoundSuperType::new( + db, + pivot_class, + owner, + ))) + } + + /// Skips elements in the MRO up to and including the pivot class. + /// + /// If the pivot class is a dynamic type, its MRO can't be determined, + /// so we fall back to using the MRO of `DynamicType::Unknown`. + fn skip_until_after_pivot( + self, + db: &'db dyn Db, + mro_iter: impl Iterator>, + ) -> impl Iterator> { + let Some(pivot_class) = self.pivot_class(db).into_class() else { + return Either::Left(ClassBase::Dynamic(DynamicType::Unknown).mro(db, None)); + }; + + let mut pivot_found = false; + + Either::Right(mro_iter.skip_while(move |superclass| { + if pivot_found { + false + } else if Some(pivot_class) == superclass.into_class() { + pivot_found = true; + true + } else { + true + } + })) + } + + /// Tries to call `__get__` on the attribute. + /// The arguments passed to `__get__` depend on whether the owner is an instance or a class. + /// See the `CPython` implementation for reference: + /// + pub(super) fn try_call_dunder_get_on_attribute( + self, + db: &'db dyn Db, + attribute: PlaceAndQualifiers<'db>, + ) -> Option> { + let owner = self.owner(db); + + match owner { + // If the owner is a dynamic type, we can't tell whether it's a class or an instance. + // Also, invoking a descriptor on a dynamic attribute is meaningless, so we don't handle this. + SuperOwnerKind::Dynamic(_) => None, + SuperOwnerKind::Class(_) => Some( + Type::try_call_dunder_get_on_attribute( + db, + attribute, + Type::none(db), + Type::from(owner), + ) + .0, + ), + SuperOwnerKind::Instance(_) => { + let owner = Type::from(owner); + Some( + Type::try_call_dunder_get_on_attribute( + db, + attribute, + owner, + owner.to_meta_type(db), + ) + .0, + ) + } + } + } + + /// Similar to `Type::find_name_in_mro_with_policy`, but performs lookup starting *after* the + /// pivot class in the MRO, based on the `owner` type instead of the `super` type. + pub(super) fn find_name_in_mro_after_pivot( + self, + db: &'db dyn Db, + name: &str, + policy: MemberLookupPolicy, + ) -> PlaceAndQualifiers<'db> { + let owner = self.owner(db); + let class = match owner { + SuperOwnerKind::Dynamic(dynamic) => { + return Type::Dynamic(dynamic) + .find_name_in_mro_with_policy(db, name, policy) + .expect("Calling `find_name_in_mro` on dynamic type should return `Some`"); + } + SuperOwnerKind::Class(class) => class, + SuperOwnerKind::Instance(instance) => instance.class(db), + }; + + let (class_literal, _) = class.class_literal(db); + // TODO properly support super() with generic types + // * requires a fix for https://github.com/astral-sh/ruff/issues/17432 + // * also requires understanding how we should handle cases like this: + // ```python + // b_int: B[int] + // b_unknown: B + // + // super(B, b_int) + // super(B[int], b_unknown) + // ``` + match class_literal.generic_context(db) { + Some(_) => Place::bound(todo_type!("super in generic class")).into(), + None => class_literal.class_member_from_mro( + db, + name, + policy, + self.skip_until_after_pivot(db, owner.iter_mro(db)), + ), + } + } + + pub(super) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { + Self::new( + db, + self.pivot_class(db).normalized_impl(db, visitor), + self.owner(db).normalized_impl(db, visitor), + ) + } +} diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 3fe625397a..c28a132abd 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -14,6 +14,7 @@ use crate::semantic_index::symbol::Symbol; use crate::semantic_index::{ DeclarationWithConstraint, SemanticIndex, attribute_declarations, attribute_scopes, }; +use crate::types::bound_super::BoundSuperError; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::context::InferContext; use crate::types::diagnostic::INVALID_TYPE_ALIAS_TYPE; @@ -26,12 +27,12 @@ use crate::types::signatures::{CallableSignature, Parameter, Parameters, Signatu use crate::types::tuple::{TupleSpec, TupleType}; use crate::types::typed_dict::typed_dict_params_from_class_def; use crate::types::{ - ApplyTypeMappingVisitor, Binding, BoundSuperError, BoundSuperType, CallableType, - DataclassParams, DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, - IsDisjointVisitor, IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, - MaterializationKind, NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, - TypeContext, TypeMapping, TypeRelation, TypedDictParams, UnionBuilder, VarianceInferable, - declaration_type, determine_upper_bound, infer_definition_types, + ApplyTypeMappingVisitor, Binding, BoundSuperType, CallableType, DataclassParams, + DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, + IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, MaterializationKind, + NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, TypeContext, + TypeMapping, TypeRelation, TypedDictParams, UnionBuilder, VarianceInferable, declaration_type, + determine_upper_bound, infer_definition_types, }; use crate::{ Db, FxIndexMap, FxOrderSet, Program, diff --git a/crates/ty_python_semantic/src/types/type_ordering.rs b/crates/ty_python_semantic/src/types/type_ordering.rs index 3a0f1cd251..f331aefa63 100644 --- a/crates/ty_python_semantic/src/types/type_ordering.rs +++ b/crates/ty_python_semantic/src/types/type_ordering.rs @@ -1,10 +1,9 @@ use std::cmp::Ordering; -use crate::db::Db; +use crate::{db::Db, types::bound_super::SuperOwnerKind}; use super::{ - DynamicType, SuperOwnerKind, TodoType, Type, TypeIsType, class_base::ClassBase, - subclass_of::SubclassOfInner, + DynamicType, TodoType, Type, TypeIsType, class_base::ClassBase, subclass_of::SubclassOfInner, }; /// Return an [`Ordering`] that describes the canonical order in which two types should appear diff --git a/crates/ty_python_semantic/src/types/visitor.rs b/crates/ty_python_semantic/src/types/visitor.rs index 9ca0f98e4c..69ce7a663d 100644 --- a/crates/ty_python_semantic/src/types/visitor.rs +++ b/crates/ty_python_semantic/src/types/visitor.rs @@ -5,14 +5,15 @@ use crate::{ IntersectionType, KnownBoundMethodType, KnownInstanceType, NominalInstanceType, PropertyInstanceType, ProtocolInstanceType, SubclassOfType, Type, TypeAliasType, TypeIsType, TypeVarInstance, TypedDictType, UnionType, + bound_super::walk_bound_super_type, class::walk_generic_alias, function::{FunctionType, walk_function_type}, instance::{walk_nominal_instance_type, walk_protocol_instance_type}, subclass_of::walk_subclass_of_type, - walk_bound_method_type, walk_bound_super_type, walk_bound_type_var_type, - walk_callable_type, walk_intersection_type, walk_known_instance_type, - walk_method_wrapper_type, walk_property_instance_type, walk_type_alias_type, - walk_type_var_type, walk_typed_dict_type, walk_typeis_type, walk_union, + walk_bound_method_type, walk_bound_type_var_type, walk_callable_type, + walk_intersection_type, walk_known_instance_type, walk_method_wrapper_type, + walk_property_instance_type, walk_type_alias_type, walk_type_var_type, + walk_typed_dict_type, walk_typeis_type, walk_union, }, }; use std::cell::{Cell, RefCell}; From 195e8f0684ce3cd9a1686c16ae051909164bb6c5 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Oct 2025 15:21:55 +0200 Subject: [PATCH 017/113] [ty] Treat functions, methods, and dynamic types as function-like `Callable`s (#20842) ## Summary Treat functions, methods, and dynamic types as function-like `Callable`s closes https://github.com/astral-sh/ty/issues/1342 closes https://github.com/astral-sh/ty/issues/1344 ## Ecosystem analysis All removed diagnostics look like cases of https://github.com/astral-sh/ty/issues/1344 ## Test Plan Added regression test --- .../mdtest/dataclasses/dataclasses.md | 20 +++++-- .../mdtest/literal/collections/list.md | 6 +++ .../type_properties/is_assignable_to.md | 34 ++++++++++++ .../type_properties/is_equivalent_to.md | 54 ++++++++++++++----- crates/ty_python_semantic/src/types.rs | 4 +- .../ty_python_semantic/src/types/function.rs | 2 +- 6 files changed, 101 insertions(+), 19 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index bb2092aa5c..7eb2ff9d67 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -1204,9 +1204,9 @@ python-version = "3.12" from dataclasses import dataclass from typing import Callable from types import FunctionType -from ty_extensions import CallableTypeOf, TypeOf, static_assert, is_subtype_of, is_assignable_to +from ty_extensions import CallableTypeOf, TypeOf, static_assert, is_subtype_of, is_assignable_to, is_equivalent_to -@dataclass +@dataclass(order=True) class C: x: int @@ -1233,8 +1233,20 @@ static_assert(not is_assignable_to(EquivalentPureCallableType, DunderInitType)) static_assert(is_subtype_of(DunderInitType, EquivalentFunctionLikeCallableType)) static_assert(is_assignable_to(DunderInitType, EquivalentFunctionLikeCallableType)) -static_assert(not is_subtype_of(EquivalentFunctionLikeCallableType, DunderInitType)) -static_assert(not is_assignable_to(EquivalentFunctionLikeCallableType, DunderInitType)) +static_assert(is_subtype_of(EquivalentFunctionLikeCallableType, DunderInitType)) +static_assert(is_assignable_to(EquivalentFunctionLikeCallableType, DunderInitType)) + +static_assert(is_equivalent_to(EquivalentFunctionLikeCallableType, DunderInitType)) static_assert(is_subtype_of(DunderInitType, FunctionType)) ``` + +It should be possible to mock out synthesized methods: + +```py +from unittest.mock import Mock + +def test_c(): + c = C(1) + c.__lt__ = Mock() +``` diff --git a/crates/ty_python_semantic/resources/mdtest/literal/collections/list.md b/crates/ty_python_semantic/resources/mdtest/literal/collections/list.md index 13c02c15b8..15f385fa88 100644 --- a/crates/ty_python_semantic/resources/mdtest/literal/collections/list.md +++ b/crates/ty_python_semantic/resources/mdtest/literal/collections/list.md @@ -25,6 +25,12 @@ x = [a, b] reveal_type(x) # revealed: list[Unknown | ((_: int) -> int)] ``` +The inferred `Callable` type is function-like, i.e. we can still access attributes like `__name__`: + +```py +reveal_type(x[0].__name__) # revealed: Unknown | str +``` + ## Mixed list ```py diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md index aa1f4d26cf..17da33f1d3 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md @@ -963,6 +963,40 @@ c: Callable[[Any], str] = f c: Callable[[Any], str] = g ``` +A function with no explicit return type should be assignable to a callable with a return type of +`Any`. + +```py +def h(): + return + +c: Callable[[], Any] = h +``` + +And, similarly for parameters with no annotations: + +```py +def i(a, b, /) -> None: + return + +c: Callable[[Any, Any], None] = i +``` + +Additionally, a function definition that includes both `*args` and `**kwargs` parameters that are +annotated as `Any` or kept unannotated should be assignable to a callable with `...` as the +parameter type. + +```py +def variadic_without_annotation(*args, **kwargs): + return + +def variadic_with_annotation(*args: Any, **kwargs: Any) -> Any: + return + +c: Callable[..., Any] = variadic_without_annotation +c: Callable[..., Any] = variadic_with_annotation +``` + ### Method types ```py diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md index 225a24ecda..624c5884c8 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md @@ -464,7 +464,7 @@ gradual types. The cases with fully static types and using different combination are covered above. ```py -from ty_extensions import Unknown, CallableTypeOf, is_equivalent_to, static_assert +from ty_extensions import Unknown, CallableTypeOf, TypeOf, is_equivalent_to, static_assert from typing import Any, Callable static_assert(is_equivalent_to(Callable[..., int], Callable[..., int])) @@ -476,14 +476,17 @@ static_assert(not is_equivalent_to(Callable[[int, str], None], Callable[[int, st static_assert(not is_equivalent_to(Callable[..., None], Callable[[], None])) ``` -A function with no explicit return type should be gradual equivalent to a callable with a return -type of `Any`. +A function with no explicit return type should be gradually equivalent to a function-like callable +with a return type of `Any`. ```py def f1(): return -static_assert(is_equivalent_to(CallableTypeOf[f1], Callable[[], Any])) +def f1_equivalent() -> Any: + return + +static_assert(is_equivalent_to(CallableTypeOf[f1], CallableTypeOf[f1_equivalent])) ``` And, similarly for parameters with no annotations. @@ -492,12 +495,15 @@ And, similarly for parameters with no annotations. def f2(a, b, /) -> None: return -static_assert(is_equivalent_to(CallableTypeOf[f2], Callable[[Any, Any], None])) +def f2_equivalent(a: Any, b: Any, /) -> None: + return + +static_assert(is_equivalent_to(CallableTypeOf[f2], CallableTypeOf[f2_equivalent])) ``` -Additionally, as per the spec, a function definition that includes both `*args` and `**kwargs` -parameter that are annotated as `Any` or kept unannotated should be gradual equivalent to a callable -with `...` as the parameter type. +A function definition that includes both `*args` and `**kwargs` parameter that are annotated as +`Any` or kept unannotated should be gradual equivalent to a callable with `...` as the parameter +type. ```py def variadic_without_annotation(*args, **kwargs): @@ -506,12 +512,27 @@ def variadic_without_annotation(*args, **kwargs): def variadic_with_annotation(*args: Any, **kwargs: Any) -> Any: return -static_assert(is_equivalent_to(CallableTypeOf[variadic_without_annotation], Callable[..., Any])) -static_assert(is_equivalent_to(CallableTypeOf[variadic_with_annotation], Callable[..., Any])) +def _( + signature_variadic_without_annotation: CallableTypeOf[variadic_without_annotation], + signature_variadic_with_annotation: CallableTypeOf[variadic_with_annotation], +) -> None: + # revealed: (...) -> Unknown + reveal_type(signature_variadic_without_annotation) + # revealed: (...) -> Any + reveal_type(signature_variadic_with_annotation) ``` -But, a function with either `*args` or `**kwargs` (and not both) is not gradual equivalent to a -callable with `...` as the parameter type. +Note that `variadic_without_annotation` and `variadic_with_annotation` are *not* considered +gradually equivalent to `Callable[..., Any]`, because the latter is not a function-like callable +type: + +```py +static_assert(not is_equivalent_to(CallableTypeOf[variadic_without_annotation], Callable[..., Any])) +static_assert(not is_equivalent_to(CallableTypeOf[variadic_with_annotation], Callable[..., Any])) +``` + +A function with either `*args` or `**kwargs` (and not both) is is not equivalent to a callable with +`...` as the parameter type. ```py def variadic_args(*args): @@ -520,6 +541,15 @@ def variadic_args(*args): def variadic_kwargs(**kwargs): return +def _( + signature_variadic_args: CallableTypeOf[variadic_args], + signature_variadic_kwargs: CallableTypeOf[variadic_kwargs], +) -> None: + # revealed: (*args) -> Unknown + reveal_type(signature_variadic_args) + # revealed: (**kwargs) -> Unknown + reveal_type(signature_variadic_kwargs) + static_assert(not is_equivalent_to(CallableTypeOf[variadic_args], Callable[..., Any])) static_assert(not is_equivalent_to(CallableTypeOf[variadic_kwargs], Callable[..., Any])) ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index e6c8b2e99d..62e3145ca1 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1401,7 +1401,7 @@ impl<'db> Type<'db> { match self { Type::Callable(_) => Some(self), - Type::Dynamic(_) => Some(CallableType::single(db, Signature::dynamic(self))), + Type::Dynamic(_) => Some(CallableType::function_like(db, Signature::dynamic(self))), Type::FunctionLiteral(function_literal) => { Some(Type::Callable(function_literal.into_callable_type(db))) @@ -9770,7 +9770,7 @@ impl<'db> BoundMethodType<'db> { .iter() .map(|signature| signature.bind_self(db, Some(self_instance))), ), - false, + true, ) } diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 89ebac378f..027642f542 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -943,7 +943,7 @@ impl<'db> FunctionType<'db> { /// Convert the `FunctionType` into a [`CallableType`]. pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> CallableType<'db> { - CallableType::new(db, self.signature(db), false) + CallableType::new(db, self.signature(db), true) } /// Convert the `FunctionType` into a [`BoundMethodType`]. From 975891fc90939a8886c9c03bbfb277e90784184d Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:00:37 -0400 Subject: [PATCH 018/113] Render unsupported syntax errors in formatter tests (#20777) ## Summary Based on the suggestion in https://github.com/astral-sh/ruff/issues/20774#issuecomment-3383153511, I added rendering of unsupported syntax errors in our `format` test. In support of this, I added a `DummyFileResolver` type to `ruff_db` to pass to `DisplayDiagnostics::new` (first commit). Another option would obviously be implementing this directly in the fixtures, but we'd have to import a `NotebookIndex` somehow; either by depending directly on `ruff_notebook` or re-exporting it from `ruff_db`. I thought it might be convenient elsewhere to have a dummy resolver, for example in the parser, where we currently have a separate rendering pipeline [copied](https://github.com/astral-sh/ruff/blob/main/crates/ruff_python_parser/tests/fixtures.rs#L321) from our old rendering code in `ruff_linter`. I also briefly tried implementing a `TestDb` in the formatter since I noticed the `ruff_python_formatter::db` module, but that was turning into a lot more code than the dummy resolver. We could also push this a bit further if we wanted. I didn't add the new snapshots to the black compatibility tests or to the preview snapshots, for example. I thought it was kind of noisy enough (and helpful enough) already, though. We could also use a shorter diagnostic format, but the full output seems most useful once we accept this initial large batch of changes. ## Test Plan I went through the baseline snapshots pretty quickly, but they all looked reasonable to me, with one exception I noted below. I also tested that the case from #20774 produces a new unsupported syntax error. --- crates/ruff_db/src/diagnostic/mod.rs | 3 +- crates/ruff_db/src/diagnostic/render.rs | 25 +++ crates/ruff_linter/src/fs.rs | 3 +- .../test/fixtures/ruff/expression/fstring.py | 2 + .../ruff_python_formatter/tests/fixtures.rs | 172 ++++++++++++++---- .../format@expression__fstring.py.snap | 67 +++++++ .../snapshots/format@statement__with.py.snap | 25 +++ 7 files changed, 258 insertions(+), 39 deletions(-) diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 20ff72f14e..230cda7a84 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -7,7 +7,8 @@ use ruff_annotate_snippets::Level as AnnotateLevel; use ruff_text_size::{Ranged, TextRange, TextSize}; pub use self::render::{ - DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input, ceil_char_boundary, + DisplayDiagnostic, DisplayDiagnostics, DummyFileResolver, FileResolver, Input, + ceil_char_boundary, github::{DisplayGithubDiagnostics, GithubRenderer}, }; use crate::{Db, files::File}; diff --git a/crates/ruff_db/src/diagnostic/render.rs b/crates/ruff_db/src/diagnostic/render.rs index 96fbb565b4..f7c618ae41 100644 --- a/crates/ruff_db/src/diagnostic/render.rs +++ b/crates/ruff_db/src/diagnostic/render.rs @@ -1170,6 +1170,31 @@ pub fn ceil_char_boundary(text: &str, offset: TextSize) -> TextSize { .unwrap_or_else(|| TextSize::from(upper_bound)) } +/// A stub implementation of [`FileResolver`] intended for testing. +pub struct DummyFileResolver; + +impl FileResolver for DummyFileResolver { + fn path(&self, _file: File) -> &str { + unimplemented!() + } + + fn input(&self, _file: File) -> Input { + unimplemented!() + } + + fn notebook_index(&self, _file: &UnifiedFile) -> Option { + None + } + + fn is_notebook(&self, _file: &UnifiedFile) -> bool { + false + } + + fn current_directory(&self) -> &Path { + Path::new(".") + } +} + #[cfg(test)] mod tests { diff --git a/crates/ruff_linter/src/fs.rs b/crates/ruff_linter/src/fs.rs index ce1a1abe87..5543d9a569 100644 --- a/crates/ruff_linter/src/fs.rs +++ b/crates/ruff_linter/src/fs.rs @@ -11,8 +11,7 @@ use crate::settings::types::CompiledPerFileIgnoreList; pub fn get_cwd() -> &'static Path { #[cfg(target_arch = "wasm32")] { - static CWD: std::sync::LazyLock = std::sync::LazyLock::new(|| PathBuf::from(".")); - &CWD + Path::new(".") } #[cfg(not(target_arch = "wasm32"))] path_absolutize::path_dedot::CWD.as_path() diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index fd658cc5ce..541af99baa 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -706,6 +706,8 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' +# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +# spec, which is valid even before 3.12. f'{x:a{z:hy "user"}} \'\'\'' # Changing the outer quotes is fine because the format-spec is in a nested expression. diff --git a/crates/ruff_python_formatter/tests/fixtures.rs b/crates/ruff_python_formatter/tests/fixtures.rs index 49b207621f..a5f25dfcd1 100644 --- a/crates/ruff_python_formatter/tests/fixtures.rs +++ b/crates/ruff_python_formatter/tests/fixtures.rs @@ -1,10 +1,15 @@ use crate::normalizer::Normalizer; -use itertools::Itertools; +use ruff_db::diagnostic::{ + Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, + DisplayDiagnostics, DummyFileResolver, Severity, Span, SubDiagnostic, SubDiagnosticSeverity, +}; use ruff_formatter::FormatOptions; +use ruff_python_ast::Mod; use ruff_python_ast::comparable::ComparableMod; +use ruff_python_ast::visitor::source_order::SourceOrderVisitor; use ruff_python_formatter::{PreviewMode, PyFormatOptions, format_module_source, format_range}; -use ruff_python_parser::{ParseOptions, UnsupportedSyntaxError, parse}; -use ruff_source_file::{LineIndex, OneIndexed}; +use ruff_python_parser::{ParseOptions, Parsed, UnsupportedSyntaxError, parse}; +use ruff_source_file::{LineIndex, OneIndexed, SourceFileBuilder}; use ruff_text_size::{Ranged, TextRange, TextSize}; use rustc_hash::FxHashMap; use similar::TextDiff; @@ -193,7 +198,8 @@ fn format() { writeln!(snapshot, "## Outputs").unwrap(); for (i, options) in options.into_iter().enumerate() { - let formatted_code = format_file(&content, &options, input_path); + let (formatted_code, unsupported_syntax_errors) = + format_file(&content, &options, input_path); writeln!( snapshot, @@ -210,7 +216,7 @@ fn format() { // We want to capture the differences in the preview style in our fixtures let options_preview = options.with_preview(PreviewMode::Enabled); - let formatted_preview = format_file(&content, &options_preview, input_path); + let (formatted_preview, _) = format_file(&content, &options_preview, input_path); if formatted_code != formatted_preview { // Having both snapshots makes it hard to see the difference, so we're keeping only @@ -227,14 +233,28 @@ fn format() { ) .unwrap(); } + + if !unsupported_syntax_errors.is_empty() { + writeln!( + snapshot, + "### Unsupported Syntax Errors\n{}", + DisplayDiagnostics::new( + &DummyFileResolver, + &DisplayDiagnosticConfig::default().format(DiagnosticFormat::Full), + &unsupported_syntax_errors + ) + ) + .unwrap(); + } } } else { // We want to capture the differences in the preview style in our fixtures let options = PyFormatOptions::from_extension(input_path); - let formatted_code = format_file(&content, &options, input_path); + let (formatted_code, unsupported_syntax_errors) = + format_file(&content, &options, input_path); let options_preview = options.with_preview(PreviewMode::Enabled); - let formatted_preview = format_file(&content, &options_preview, input_path); + let (formatted_preview, _) = format_file(&content, &options_preview, input_path); if formatted_code == formatted_preview { writeln!( @@ -259,6 +279,19 @@ fn format() { ) .unwrap(); } + + if !unsupported_syntax_errors.is_empty() { + writeln!( + snapshot, + "## Unsupported Syntax Errors\n{}", + DisplayDiagnostics::new( + &DummyFileResolver, + &DisplayDiagnosticConfig::default().format(DiagnosticFormat::Full), + &unsupported_syntax_errors + ) + ) + .unwrap(); + } } insta::with_settings!({ @@ -277,7 +310,11 @@ fn format() { ); } -fn format_file(source: &str, options: &PyFormatOptions, input_path: &Path) -> String { +fn format_file( + source: &str, + options: &PyFormatOptions, + input_path: &Path, +) -> (String, Vec) { let (unformatted, formatted_code) = if source.contains("") { let mut content = source.to_string(); let without_markers = content @@ -337,9 +374,10 @@ fn format_file(source: &str, options: &PyFormatOptions, input_path: &Path) -> St (Cow::Borrowed(source), formatted_code) }; - ensure_unchanged_ast(&unformatted, &formatted_code, options, input_path); + let unsupported_syntax_errors = + ensure_unchanged_ast(&unformatted, &formatted_code, options, input_path); - formatted_code + (formatted_code, unsupported_syntax_errors) } /// Format another time and make sure that there are no changes anymore @@ -391,12 +429,23 @@ Formatted twice: /// Like Black, there are a few exceptions to this "invariant" which are encoded in /// [`NormalizedMod`] and related structs. Namely, formatting can change indentation within strings, /// and can also flatten tuples within `del` statements. +/// +/// Returns any new [`UnsupportedSyntaxError`]s in the formatted code as [`Diagnostic`]s for +/// snapshotting. +/// +/// As noted in the sub-diagnostic message, new syntax errors should only be accepted when they are +/// the result of an existing syntax error in the input. For example, the formatter knows that +/// escapes in f-strings are only allowed after Python 3.12, so it can replace escaped quotes with +/// reused outer quote characters, which are also valid after 3.12, even if the configured Python +/// version is lower. Such cases disrupt the fingerprint filter because the syntax error, and thus +/// its fingerprint, is different from the input syntax error. More typical cases like using a +/// t-string before 3.14 will be filtered out and not included in snapshots. fn ensure_unchanged_ast( unformatted_code: &str, formatted_code: &str, options: &PyFormatOptions, input_path: &Path, -) { +) -> Vec { let source_type = options.source_type(); // Parse the unformatted code. @@ -407,7 +456,7 @@ fn ensure_unchanged_ast( .expect("Unformatted code to be valid syntax"); let unformatted_unsupported_syntax_errors = - collect_unsupported_syntax_errors(unformatted_parsed.unsupported_syntax_errors()); + collect_unsupported_syntax_errors(&unformatted_parsed); let mut unformatted_ast = unformatted_parsed.into_syntax(); Normalizer.visit_module(&mut unformatted_ast); @@ -422,29 +471,31 @@ fn ensure_unchanged_ast( // Assert that there are no new unsupported syntax errors let mut formatted_unsupported_syntax_errors = - collect_unsupported_syntax_errors(formatted_parsed.unsupported_syntax_errors()); + collect_unsupported_syntax_errors(&formatted_parsed); formatted_unsupported_syntax_errors .retain(|fingerprint, _| !unformatted_unsupported_syntax_errors.contains_key(fingerprint)); - if !formatted_unsupported_syntax_errors.is_empty() { - let index = LineIndex::from_source_text(formatted_code); - panic!( - "Formatted code `{}` introduced new unsupported syntax errors:\n---\n{}\n---", - input_path.display(), - formatted_unsupported_syntax_errors - .into_values() - .map(|error| { - let location = index.line_column(error.start(), formatted_code); - format!( - "{row}:{col} {error}", - row = location.line, - col = location.column - ) - }) - .join("\n") - ); - } + let file = SourceFileBuilder::new( + input_path.file_name().unwrap().to_string_lossy(), + formatted_code, + ) + .finish(); + let diagnostics = formatted_unsupported_syntax_errors + .values() + .map(|error| { + let mut diag = Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, error); + let span = Span::from(file.clone()).with_range(error.range()); + diag.annotate(Annotation::primary(span)); + let sub = SubDiagnostic::new( + SubDiagnosticSeverity::Warning, + "Only accept new syntax errors if they are also present in the input. \ + The formatter should not introduce syntax errors.", + ); + diag.sub(sub); + diag + }) + .collect::>(); let mut formatted_ast = formatted_parsed.into_syntax(); Normalizer.visit_module(&mut formatted_ast); @@ -466,6 +517,8 @@ fn ensure_unchanged_ast( input_path.display(), ); } + + diagnostics } struct Header<'a> { @@ -537,14 +590,56 @@ source_type = {source_type:?}"#, } } +/// A visitor to collect a sequence of node IDs for fingerprinting [`UnsupportedSyntaxError`]s. +/// +/// It visits each statement in the AST in source order and saves its range. The index of the node +/// enclosing a syntax error's range can then be retrieved with the `node_id` method. This `node_id` +/// should be stable across formatting runs since the formatter won't add or remove statements. +struct StmtVisitor { + nodes: Vec, +} + +impl StmtVisitor { + fn new(parsed: &Parsed) -> Self { + let mut visitor = Self { nodes: Vec::new() }; + visitor.visit_mod(parsed.syntax()); + visitor + } + + /// Return the index of the statement node that contains `range`. + fn node_id(&self, range: TextRange) -> usize { + self.nodes + .iter() + .enumerate() + .filter(|(_, node)| node.contains_range(range)) + .min_by_key(|(_, node)| node.len()) + .expect("Expected an enclosing node in the AST") + .0 + } +} + +impl<'a> SourceOrderVisitor<'a> for StmtVisitor { + fn visit_stmt(&mut self, stmt: &'a ruff_python_ast::Stmt) { + self.nodes.push(stmt.range()); + ruff_python_ast::visitor::source_order::walk_stmt(self, stmt); + } +} + /// Collects the unsupported syntax errors and assigns a unique hash to each error. fn collect_unsupported_syntax_errors( - errors: &[UnsupportedSyntaxError], + parsed: &Parsed, ) -> FxHashMap { let mut collected = FxHashMap::default(); - for error in errors { - let mut error_fingerprint = fingerprint_unsupported_syntax_error(error, 0); + if parsed.unsupported_syntax_errors().is_empty() { + return collected; + } + + let visitor = StmtVisitor::new(parsed); + + for error in parsed.unsupported_syntax_errors() { + let node_id = visitor.node_id(error.range); + let mut error_fingerprint = fingerprint_unsupported_syntax_error(error, node_id, 0); // Make sure that we do not get a fingerprint that is already in use // by adding in the previously generated one. @@ -552,7 +647,7 @@ fn collect_unsupported_syntax_errors( match collected.entry(error_fingerprint) { Entry::Occupied(_) => { error_fingerprint = - fingerprint_unsupported_syntax_error(error, error_fingerprint); + fingerprint_unsupported_syntax_error(error, node_id, error_fingerprint); } Entry::Vacant(entry) => { entry.insert(error.clone()); @@ -565,7 +660,11 @@ fn collect_unsupported_syntax_errors( collected } -fn fingerprint_unsupported_syntax_error(error: &UnsupportedSyntaxError, salt: u64) -> u64 { +fn fingerprint_unsupported_syntax_error( + error: &UnsupportedSyntaxError, + node_id: usize, + salt: u64, +) -> u64 { let mut hasher = DefaultHasher::new(); let UnsupportedSyntaxError { @@ -579,6 +678,7 @@ fn fingerprint_unsupported_syntax_error(error: &UnsupportedSyntaxError, salt: u6 salt.hash(&mut hasher); kind.hash(&mut hasher); target_version.hash(&mut hasher); + node_id.hash(&mut hasher); hasher.finish() } diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index 40ac13cb24..2eb1e09a08 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -712,6 +712,8 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' +# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +# spec, which is valid even before 3.12. f'{x:a{z:hy "user"}} \'\'\'' # Changing the outer quotes is fine because the format-spec is in a nested expression. @@ -1534,6 +1536,8 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{"aa"}" }' f'{1=: "abcd {'aa'}}' +# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +# spec, which is valid even before 3.12. f"{x:a{z:hy \"user\"}} '''" # Changing the outer quotes is fine because the format-spec is in a nested expression. @@ -2361,6 +2365,8 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{"aa"}" }' f'{1=: "abcd {'aa'}}' +# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +# spec, which is valid even before 3.12. f"{x:a{z:hy \"user\"}} '''" # Changing the outer quotes is fine because the format-spec is in a nested expression. @@ -2409,3 +2415,64 @@ print( # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1 }") ``` + + +### Unsupported Syntax Errors +error[invalid-syntax]: Cannot use an escape sequence (backslash) in f-strings on Python 3.10 (syntax was added in Python 3.12) + --> fstring.py:764:19 + | +762 | # FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +763 | # spec, which is valid even before 3.12. +764 | f"{x:a{z:hy \"user\"}} '''" + | ^ +765 | +766 | # Changing the outer quotes is fine because the format-spec is in a nested expression. + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. + +error[invalid-syntax]: Cannot use an escape sequence (backslash) in f-strings on Python 3.10 (syntax was added in Python 3.12) + --> fstring.py:764:13 + | +762 | # FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +763 | # spec, which is valid even before 3.12. +764 | f"{x:a{z:hy \"user\"}} '''" + | ^ +765 | +766 | # Changing the outer quotes is fine because the format-spec is in a nested expression. + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. + +error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) + --> fstring.py:178:8 + | +176 | # Here, the formatter will remove the escapes which is correct because they aren't allowed +177 | # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +178 | f"foo {"'bar'"}" + | ^ +179 | f"foo {'"bar"'}" + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. + +error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) + --> fstring.py:773:14 + | +771 | f'{1=: "abcd \'\'}' # Don't change the outer quotes, or it results in a syntax error +772 | f"{1=: abcd \'\'}" # Changing the quotes here is fine because the inner quotes aren't the opposite quotes +773 | f"{1=: abcd \"\"}" # Changing the quotes here is fine because the inner quotes are escaped + | ^ +774 | # Don't change the quotes in the following cases: +775 | f'{x=:hy "user"} \'\'\'' + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. + +error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) + --> fstring.py:764:14 + | +762 | # FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format +763 | # spec, which is valid even before 3.12. +764 | f"{x:a{z:hy \"user\"}} '''" + | ^ +765 | +766 | # Changing the outer quotes is fine because the format-spec is in a nested expression. + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap index bb04ae9560..992cb23f5e 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap @@ -807,6 +807,31 @@ with ( ``` +### Unsupported Syntax Errors +error[invalid-syntax]: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) + --> with.py:333:10 + | +332 | if True: +333 | with ( + | ^ +334 | anyio.CancelScope(shield=True) +335 | if get_running_loop() + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. + +error[invalid-syntax]: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) + --> with.py:359:6 + | +357 | pass +358 | +359 | with ( + | ^ +360 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +361 | ): + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. + + ### Output 2 ``` indent-style = space From 373fe8a39ca5210c83646566de2eef2aadc8846c Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 13 Oct 2025 19:50:19 +0200 Subject: [PATCH 019/113] [ty] Remove 'pre-release software' warning (#20817) --- crates/ty/src/lib.rs | 5 - crates/ty/tests/cli/config_option.rs | 35 ++- crates/ty/tests/cli/exit_code.rs | 40 ++- crates/ty/tests/cli/file_selection.rs | 111 ++++----- crates/ty/tests/cli/main.rs | 105 ++++---- crates/ty/tests/cli/python_environment.rs | 290 +++++++++------------- crates/ty/tests/cli/rule_selection.rs | 74 +++--- 7 files changed, 260 insertions(+), 400 deletions(-) diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 4e3c5993d0..6fb1b05232 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -73,11 +73,6 @@ fn run_check(args: CheckCommand) -> anyhow::Result { let printer = Printer::default().with_verbosity(verbosity); - tracing::warn!( - "ty is pre-release software and not ready for production use. \ - Expect to encounter bugs, missing features, and fatal errors.", - ); - tracing::debug!("Version: {}", version::version()); // The base path to which all CLI arguments are relative to. diff --git a/crates/ty/tests/cli/config_option.rs b/crates/ty/tests/cli/config_option.rs index 4732b28236..997696967d 100644 --- a/crates/ty/tests/cli/config_option.rs +++ b/crates/ty/tests/cli/config_option.rs @@ -7,7 +7,7 @@ fn cli_config_args_toml_string_basic() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", r"print(x) # [unresolved-reference]")?; // Long flag - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=true"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=true"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -22,11 +22,10 @@ fn cli_config_args_toml_string_basic() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Short flag - assert_cmd_snapshot!(case.command().arg("-c").arg("terminal.error-on-warning=true"), @r" + assert_cmd_snapshot!(case.command().arg("-c").arg("terminal.error-on-warning=true"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -41,8 +40,7 @@ fn cli_config_args_toml_string_basic() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -61,7 +59,7 @@ fn cli_config_args_overrides_ty_toml() -> anyhow::Result<()> { ])?; // Exit code of 1 due to the setting in `ty.toml` - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -76,11 +74,10 @@ fn cli_config_args_overrides_ty_toml() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Exit code of 0 because the `ty.toml` setting is overwritten by `--config` - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=false"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=false"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -95,8 +92,7 @@ fn cli_config_args_overrides_ty_toml() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -104,7 +100,7 @@ fn cli_config_args_overrides_ty_toml() -> anyhow::Result<()> { #[test] fn cli_config_args_later_overrides_earlier() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", r"print(x) # [unresolved-reference]")?; - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=true").arg("--config").arg("terminal.error-on-warning=false"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=true").arg("--config").arg("terminal.error-on-warning=false"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -119,8 +115,7 @@ fn cli_config_args_later_overrides_earlier() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -165,7 +160,7 @@ fn config_file_override() -> anyhow::Result<()> { ])?; // Ensure flag works via CLI arg - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config-file").arg("ty-override.toml"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config-file").arg("ty-override.toml"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -180,11 +175,10 @@ fn config_file_override() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Ensure the flag works via an environment variable - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").env("TY_CONFIG_FILE", "ty-override.toml"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").env("TY_CONFIG_FILE", "ty-override.toml"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -199,8 +193,7 @@ fn config_file_override() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } diff --git a/crates/ty/tests/cli/exit_code.rs b/crates/ty/tests/cli/exit_code.rs index 7c7a93e488..5aceafbbbc 100644 --- a/crates/ty/tests/cli/exit_code.rs +++ b/crates/ty/tests/cli/exit_code.rs @@ -6,7 +6,7 @@ use crate::CliTest; fn only_warnings() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", r"print(x) # [unresolved-reference]")?; - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -21,8 +21,7 @@ fn only_warnings() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -37,7 +36,7 @@ fn only_info() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -52,8 +51,7 @@ fn only_info() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -68,7 +66,7 @@ fn only_info_and_error_on_warning_is_true() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r" + assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -83,8 +81,7 @@ fn only_info_and_error_on_warning_is_true() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -93,7 +90,7 @@ fn only_info_and_error_on_warning_is_true() -> anyhow::Result<()> { fn no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", r"print(x) # [unresolved-reference]")?; - assert_cmd_snapshot!(case.command().arg("--error-on-warning").arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--error-on-warning").arg("--warn").arg("unresolved-reference"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -108,8 +105,7 @@ fn no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -127,7 +123,7 @@ fn no_errors_but_error_on_warning_is_enabled_in_configuration() -> anyhow::Resul ), ])?; - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -142,8 +138,7 @@ fn no_errors_but_error_on_warning_is_enabled_in_configuration() -> anyhow::Resul Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -158,7 +153,7 @@ fn both_warnings_and_errors() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -183,8 +178,7 @@ fn both_warnings_and_errors() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -199,7 +193,7 @@ fn both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::Result<()> "###, )?; - assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--error-on-warning"), @r" + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--error-on-warning"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -224,8 +218,7 @@ fn both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::Result<()> Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -240,7 +233,7 @@ fn exit_zero_is_true() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command().arg("--exit-zero").arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--exit-zero").arg("--warn").arg("unresolved-reference"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -265,8 +258,7 @@ fn exit_zero_is_true() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } diff --git a/crates/ty/tests/cli/file_selection.rs b/crates/ty/tests/cli/file_selection.rs index ca6918343c..5668e5829d 100644 --- a/crates/ty/tests/cli/file_selection.rs +++ b/crates/ty/tests/cli/file_selection.rs @@ -27,7 +27,7 @@ fn exclude_argument() -> anyhow::Result<()> { ])?; // Test that exclude argument is recognized and works - assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/"), @r" + assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -50,11 +50,10 @@ fn exclude_argument() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Test multiple exclude patterns - assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/").arg("--exclude").arg("temp_*.py"), @r" + assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/").arg("--exclude").arg("temp_*.py"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -69,8 +68,7 @@ fn exclude_argument() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -108,7 +106,7 @@ fn configuration_include() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -123,8 +121,7 @@ fn configuration_include() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Test multiple include patterns via configuration case.write_file( @@ -135,7 +132,7 @@ fn configuration_include() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -158,8 +155,7 @@ fn configuration_include() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -197,7 +193,7 @@ fn configuration_exclude() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -220,8 +216,7 @@ fn configuration_exclude() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Test multiple exclude patterns via configuration case.write_file( @@ -232,7 +227,7 @@ fn configuration_exclude() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -247,8 +242,7 @@ fn configuration_exclude() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -287,7 +281,7 @@ fn exclude_precedence_over_include() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -302,8 +296,7 @@ fn exclude_precedence_over_include() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -356,7 +349,6 @@ fn exclude_argument_precedence_include_argument() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "###); Ok(()) @@ -381,7 +373,7 @@ fn remove_default_exclude() -> anyhow::Result<()> { ])?; // By default, 'dist' directory should be excluded (see default excludes) - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -396,8 +388,7 @@ fn remove_default_exclude() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Now override the default exclude by using a negated pattern to re-include 'dist' case.write_file( @@ -408,7 +399,7 @@ fn remove_default_exclude() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -431,8 +422,7 @@ fn remove_default_exclude() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -465,7 +455,7 @@ fn cli_removes_config_exclude() -> anyhow::Result<()> { )?; // Verify that build/ is excluded by configuration - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -480,11 +470,10 @@ fn cli_removes_config_exclude() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Now remove the configuration exclude via CLI negation - assert_cmd_snapshot!(case.command().arg("--exclude").arg("!build/"), @r" + assert_cmd_snapshot!(case.command().arg("--exclude").arg("!build/"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -507,8 +496,7 @@ fn cli_removes_config_exclude() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -545,7 +533,7 @@ fn explicit_path_overrides_exclude() -> anyhow::Result<()> { ])?; // dist is excluded by default and `tests/generated` is excluded in the project, so only src/main.py should be checked - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -560,11 +548,10 @@ fn explicit_path_overrides_exclude() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Explicitly checking a file in an excluded directory should still check that file - assert_cmd_snapshot!(case.command().arg("tests/generated.py"), @r" + assert_cmd_snapshot!(case.command().arg("tests/generated.py"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -579,11 +566,10 @@ fn explicit_path_overrides_exclude() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Explicitly checking the entire excluded directory should check all files in it - assert_cmd_snapshot!(case.command().arg("dist/"), @r" + assert_cmd_snapshot!(case.command().arg("dist/"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -598,8 +584,7 @@ fn explicit_path_overrides_exclude() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -625,13 +610,12 @@ fn invalid_include_pattern() -> anyhow::Result<()> { ])?; // By default, dist/ is excluded, so only src/main.py should be checked - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: error[invalid-glob]: Invalid include pattern --> ty.toml:4:5 @@ -642,7 +626,7 @@ fn invalid_include_pattern() -> anyhow::Result<()> { | ^^^^^^^^^^^^^ Too many stars at position 5 5 | ] | - "#); + "###); Ok(()) } @@ -668,16 +652,15 @@ fn invalid_include_pattern_concise_output() -> anyhow::Result<()> { ])?; // By default, dist/ is excluded, so only src/main.py should be checked - assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @r" + assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: ty.toml:4:5: error[invalid-glob] Invalid include pattern: Too many stars at position 5 - "); + "###); Ok(()) } @@ -703,13 +686,12 @@ fn invalid_exclude_pattern() -> anyhow::Result<()> { ])?; // By default, dist/ is excluded, so only src/main.py should be checked - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: error[invalid-glob]: Invalid exclude pattern --> ty.toml:4:5 @@ -720,7 +702,7 @@ fn invalid_exclude_pattern() -> anyhow::Result<()> { | ^^^^^^^^ The parent directory operator (`..`) at position 1 is not allowed 5 | ] | - "#); + "###); Ok(()) } @@ -772,7 +754,7 @@ print(other_undefined) # error: unresolved-reference // Change to the bazel-out directory and run ty from there // The symlinks should be followed and errors should be found - assert_cmd_snapshot!(case.command().current_dir(case.project_dir.join("bazel-out/k8-fastbuild/bin")), @r" + assert_cmd_snapshot!(case.command().current_dir(case.project_dir.join("bazel-out/k8-fastbuild/bin")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -797,11 +779,10 @@ print(other_undefined) # error: unresolved-reference Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Test that when checking a specific symlinked file from the bazel-out directory, it works correctly - assert_cmd_snapshot!(case.command().current_dir(case.project_dir.join("bazel-out/k8-fastbuild/bin")).arg("main.py"), @r" + assert_cmd_snapshot!(case.command().current_dir(case.project_dir.join("bazel-out/k8-fastbuild/bin")).arg("main.py"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -817,8 +798,7 @@ print(other_undefined) # error: unresolved-reference Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -857,7 +837,7 @@ print(regular_undefined) # error: unresolved-reference case.write_symlink("src/utils.py", "generated_utils.py")?; // Exclude pattern should match on the symlink name (generated_*), not the target name - assert_cmd_snapshot!(case.command().arg("--exclude").arg("generated_*.py"), @r" + assert_cmd_snapshot!(case.command().arg("--exclude").arg("generated_*.py"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -890,11 +870,10 @@ print(regular_undefined) # error: unresolved-reference Found 3 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Exclude pattern on target path should not affect symlinks with different names - assert_cmd_snapshot!(case.command().arg("--exclude").arg("src/*.py"), @r" + assert_cmd_snapshot!(case.command().arg("--exclude").arg("src/*.py"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -927,11 +906,10 @@ print(regular_undefined) # error: unresolved-reference Found 3 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Test that explicitly passing a symlink always checks it, even if excluded - assert_cmd_snapshot!(case.command().arg("--exclude").arg("generated_*.py").arg("generated_module.py"), @r" + assert_cmd_snapshot!(case.command().arg("--exclude").arg("generated_*.py").arg("generated_module.py"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -947,8 +925,7 @@ print(regular_undefined) # error: unresolved-reference Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 335f288d07..5270c1212c 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -19,15 +19,14 @@ fn test_quiet_output() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", "x: int = 1")?; // By default, we emit an "all checks passed" message - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // With `quiet`, the message is not displayed assert_cmd_snapshot!(case.command().arg("--quiet"), @r" @@ -41,7 +40,7 @@ fn test_quiet_output() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", "x: int = 'foo'")?; // By default, we emit a diagnostic - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -56,8 +55,7 @@ fn test_quiet_output() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); // With `quiet`, the diagnostic is not displayed, just the summary message assert_cmd_snapshot!(case.command().arg("--quiet"), @r" @@ -94,7 +92,7 @@ fn test_quiet_output() -> anyhow::Result<()> { #[test] fn test_run_in_sub_directory() -> anyhow::Result<()> { let case = CliTest::with_files([("test.py", "~"), ("subdir/nothing", "")])?; - assert_cmd_snapshot!(case.command().current_dir(case.root().join("subdir")).arg(".."), @r" + assert_cmd_snapshot!(case.command().current_dir(case.root().join("subdir")).arg(".."), @r###" success: false exit_code: 1 ----- stdout ----- @@ -108,15 +106,14 @@ fn test_run_in_sub_directory() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } #[test] fn test_include_hidden_files_by_default() -> anyhow::Result<()> { let case = CliTest::with_files([(".test.py", "~")])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -130,8 +127,7 @@ fn test_include_hidden_files_by_default() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -139,19 +135,18 @@ fn test_include_hidden_files_by_default() -> anyhow::Result<()> { fn test_respect_ignore_files() -> anyhow::Result<()> { // First test that the default option works correctly (the file is skipped) let case = CliTest::with_files([(".ignore", "test.py"), ("test.py", "~")])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. WARN No python files found under the given path(s) - "); + "###); // Test that we can set to false via CLI - assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r" + assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -165,12 +160,11 @@ fn test_respect_ignore_files() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Test that we can set to false via config file case.write_file("ty.toml", "src.respect-ignore-files = false")?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -184,12 +178,11 @@ fn test_respect_ignore_files() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Ensure CLI takes precedence case.write_file("ty.toml", "src.respect-ignore-files = true")?; - assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r" + assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -203,8 +196,7 @@ fn test_respect_ignore_files() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -251,7 +243,7 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> { ])?; // Make sure that the CLI fails when the `libs` directory is not in the search path. - assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r" + assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -272,18 +264,16 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); - assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")).arg("--extra-search-path").arg("../libs"), @r" + assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")).arg("--extra-search-path").arg("../libs"), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -329,15 +319,14 @@ fn paths_in_configuration_files_are_relative_to_the_project_root() -> anyhow::Re ), ])?; - assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r" + assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -374,7 +363,7 @@ fn user_configuration() -> anyhow::Result<()> { assert_cmd_snapshot!( case.command().current_dir(case.root().join("project")).env(config_env_var, config_directory.as_os_str()), - @r" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -401,8 +390,7 @@ fn user_configuration() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - " + "### ); // The user-level configuration sets the severity for `unresolved-reference` to warn. @@ -419,7 +407,7 @@ fn user_configuration() -> anyhow::Result<()> { assert_cmd_snapshot!( case.command().current_dir(case.root().join("project")).env(config_env_var, config_directory.as_os_str()), - @r" + @r###" success: true exit_code: 0 ----- stdout ----- @@ -446,8 +434,7 @@ fn user_configuration() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - " + "### ); Ok(()) @@ -480,7 +467,7 @@ fn check_specific_paths() -> anyhow::Result<()> { assert_cmd_snapshot!( case.command(), - @r" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -513,15 +500,14 @@ fn check_specific_paths() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - " + "### ); // Now check only the `tests` and `other.py` files. // We should no longer see any diagnostics related to `main.py`. assert_cmd_snapshot!( case.command().arg("project/tests").arg("project/other.py"), - @r" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -554,8 +540,7 @@ fn check_specific_paths() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - " + "### ); Ok(()) @@ -574,7 +559,7 @@ fn check_non_existing_path() -> anyhow::Result<()> { assert_cmd_snapshot!( case.command().arg("project/main.py").arg("project/tests"), - @r" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -585,9 +570,8 @@ fn check_non_existing_path() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. WARN No python files found under the given path(s) - " + "### ); Ok(()) @@ -603,7 +587,7 @@ fn concise_diagnostics() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command().arg("--output-format=concise").arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--output-format=concise").arg("--warn").arg("unresolved-reference"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -612,8 +596,7 @@ fn concise_diagnostics() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -633,7 +616,7 @@ fn gitlab_diagnostics() -> anyhow::Result<()> { let _s = settings.bind_to_scope(); assert_cmd_snapshot!(case.command().arg("--output-format=gitlab").arg("--warn").arg("unresolved-reference") - .env("CI_PROJECT_DIR", case.project_dir), @r#" + .env("CI_PROJECT_DIR", case.project_dir), @r###" success: false exit_code: 1 ----- stdout ----- @@ -678,8 +661,7 @@ fn gitlab_diagnostics() -> anyhow::Result<()> { } ] ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -694,7 +676,7 @@ fn github_diagnostics() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command().arg("--output-format=github").arg("--warn").arg("unresolved-reference"), @r" + assert_cmd_snapshot!(case.command().arg("--output-format=github").arg("--warn").arg("unresolved-reference"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -702,8 +684,7 @@ fn github_diagnostics() -> anyhow::Result<()> { ::error title=ty (non-subscriptable),file=/test.py,line=3,col=7,endLine=3,endColumn=8::test.py:3:7: non-subscriptable: Cannot subscript object of type `Literal[4]` with no `__getitem__` method ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -728,7 +709,7 @@ fn concise_revealed_type() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command().arg("--output-format=concise"), @r#" + assert_cmd_snapshot!(case.command().arg("--output-format=concise"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -736,8 +717,7 @@ fn concise_revealed_type() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -757,7 +737,7 @@ fn can_handle_large_binop_expressions() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", &ruff_python_trivia::textwrap::dedent(&content))?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -773,8 +753,7 @@ fn can_handle_large_binop_expressions() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 3a96129a65..a8a9f0038d 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -26,7 +26,7 @@ fn config_override_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -42,18 +42,16 @@ fn config_override_python_version() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); - assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r" + assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -80,7 +78,7 @@ fn config_override_python_platform() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -96,10 +94,9 @@ fn config_override_python_platform() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); - assert_cmd_snapshot!(case.command().arg("--python-platform").arg("all"), @r" + assert_cmd_snapshot!(case.command().arg("--python-platform").arg("all"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -115,8 +112,7 @@ fn config_override_python_platform() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -139,7 +135,7 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -162,10 +158,9 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); - assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r" + assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -182,8 +177,7 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -201,7 +195,7 @@ fn src_subdirectory_takes_precedence_over_repo_root() -> anyhow::Result<()> { // If `./src` didn't take priority over `.` here, we would report // "Module `src.package` has no member `nonexistent_submodule`" // instead of "Module `package` has no member `nonexistent_submodule`". - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -216,8 +210,7 @@ fn src_subdirectory_takes_precedence_over_repo_root() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -242,7 +235,7 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { ("test.py", "aiter"), ])?; - assert_cmd_snapshot!(cpython_case.command().arg("--python").arg("pythons/Python3.8/bin/python"), @r" + assert_cmd_snapshot!(cpython_case.command().arg("--python").arg("pythons/Python3.8/bin/python"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -261,8 +254,7 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); let pypy_case = CliTest::with_files([ ("pythons/pypy3.8/bin/python", ""), @@ -270,7 +262,7 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { ("test.py", "aiter"), ])?; - assert_cmd_snapshot!(pypy_case.command().arg("--python").arg("pythons/pypy3.8/bin/python"), @r" + assert_cmd_snapshot!(pypy_case.command().arg("--python").arg("pythons/pypy3.8/bin/python"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -289,8 +281,7 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); let free_threaded_case = CliTest::with_files([ ("pythons/Python3.13t/bin/python", ""), @@ -301,7 +292,7 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { ("test.py", "import string.templatelib"), ])?; - assert_cmd_snapshot!(free_threaded_case.command().arg("--python").arg("pythons/Python3.13t/bin/python"), @r" + assert_cmd_snapshot!(free_threaded_case.command().arg("--python").arg("pythons/Python3.13t/bin/python"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -320,8 +311,7 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -356,7 +346,7 @@ import bar", "strange-venv-location/bin/python", )?; - assert_cmd_snapshot!(case.command().arg("--python").arg("strange-venv-location/bin/python"), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg("strange-venv-location/bin/python"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -377,8 +367,7 @@ import bar", Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -406,7 +395,7 @@ fn lib64_site_packages_directory_on_unix() -> anyhow::Result<()> { ("test.py", "import foo, bar, baz"), ])?; - assert_cmd_snapshot!(case.command().arg("--python").arg(".venv"), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg(".venv"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -427,8 +416,7 @@ fn lib64_site_packages_directory_on_unix() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -463,7 +451,7 @@ fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Resu ("test.py", "aiter"), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -487,8 +475,7 @@ fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Resu Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -523,7 +510,7 @@ fn pyvenv_cfg_file_annotation_no_trailing_newline() -> anyhow::Result<()> { ("test.py", "aiter"), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -546,8 +533,7 @@ fn pyvenv_cfg_file_annotation_no_trailing_newline() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -574,7 +560,7 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -597,10 +583,9 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); - assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r" + assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -617,8 +602,7 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -648,51 +632,47 @@ fn python_cli_argument_virtual_environment() -> anyhow::Result<()> { ])?; // Passing a path to the installation works - assert_cmd_snapshot!(case.command().arg("--python").arg("my-venv"), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg("my-venv"), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // And so does passing a path to the executable inside the installation - assert_cmd_snapshot!(case.command().arg("--python").arg(path_to_executable), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg(path_to_executable), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // But random other paths inside the installation are rejected - assert_cmd_snapshot!(case.command().arg("--python").arg(other_venv_path), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg(other_venv_path), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: Invalid `--python` argument `/my-venv/foo/some_other_file.txt`: does not point to a Python executable or a directory on disk - "); + "###); // And so are paths that do not exist on disk - assert_cmd_snapshot!(case.command().arg("--python").arg("not-a-directory-or-executable"), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg("not-a-directory-or-executable"), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: Invalid `--python` argument `/not-a-directory-or-executable`: does not point to a Python executable or a directory on disk Cause: No such file or directory (os error 2) - "); + "###); Ok(()) } @@ -719,26 +699,24 @@ fn python_cli_argument_system_installation() -> anyhow::Result<()> { ])?; // Passing a path to the installation works - assert_cmd_snapshot!(case.command().arg("--python").arg("Python3.11"), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg("Python3.11"), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // And so does passing a path to the executable inside the installation - assert_cmd_snapshot!(case.command().arg("--python").arg(path_to_executable), @r" + assert_cmd_snapshot!(case.command().arg("--python").arg(path_to_executable), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -764,13 +742,12 @@ fn config_file_broken_python_setting() -> anyhow::Result<()> { ("test.py", ""), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: Invalid `environment.python` setting @@ -783,7 +760,7 @@ fn config_file_broken_python_setting() -> anyhow::Result<()> { | Cause: No such file or directory (os error 2) - "#); + "###); Ok(()) } @@ -802,13 +779,12 @@ fn config_file_python_setting_directory_with_no_site_packages() -> anyhow::Resul ("test.py", ""), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: Failed to discover the site-packages directory Cause: Invalid `environment.python` setting @@ -820,7 +796,7 @@ fn config_file_python_setting_directory_with_no_site_packages() -> anyhow::Resul 3 | python = "directory-but-no-site-packages" | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Could not find a `site-packages` directory for this Python installation/executable | - "#); + "###); Ok(()) } @@ -841,13 +817,12 @@ fn unix_system_installation_with_no_lib_directory() -> anyhow::Result<()> { ("test.py", ""), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: Failed to discover the site-packages directory Cause: Failed to iterate over the contents of the `lib`/`lib64` directories of the Python installation @@ -859,7 +834,7 @@ fn unix_system_installation_with_no_lib_directory() -> anyhow::Result<()> { 3 | python = "directory-but-no-site-packages" | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - "#); + "###); Ok(()) } @@ -888,7 +863,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -905,8 +880,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Use default (which should be latest supported) let case = CliTest::with_files([ @@ -927,15 +901,14 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1089,7 +1062,7 @@ home = ./ // Run with nothing set, should find the working venv assert_cmd_snapshot!(case.command() - .current_dir(case.root().join("project")), @r" + .current_dir(case.root().join("project")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1107,13 +1080,12 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Run with VIRTUAL_ENV set, should find the active venv assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) - .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1130,13 +1102,12 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX set, should find the child conda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) - .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")), @r" + .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1154,14 +1125,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX and CONDA_DEFAULT_ENV set (unequal), should find working venv assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) - .env("CONDA_DEFAULT_ENV", "base"), @r" + .env("CONDA_DEFAULT_ENV", "base"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1179,8 +1149,7 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (unequal) and VIRTUAL_ENV set, // should find child active venv @@ -1188,7 +1157,7 @@ home = ./ .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) .env("CONDA_DEFAULT_ENV", "base") - .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1205,14 +1174,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (equal!) set, should find ChildConda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")) - .env("CONDA_DEFAULT_ENV", "conda-env"), @r" + .env("CONDA_DEFAULT_ENV", "conda-env"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1230,14 +1198,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with _CONDA_ROOT and CONDA_PREFIX (unequal!) set, should find ChildConda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")) - .env("_CONDA_ROOT", "conda"), @r" + .env("_CONDA_ROOT", "conda"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1255,14 +1222,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with _CONDA_ROOT and CONDA_PREFIX (equal!) set, should find BaseConda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) - .env("_CONDA_ROOT", "conda"), @r" + .env("_CONDA_ROOT", "conda"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1279,8 +1245,7 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1353,7 +1318,7 @@ home = ./ // Run with nothing set, should fail to find anything assert_cmd_snapshot!(case.command() - .current_dir(case.root().join("project")), @r" + .current_dir(case.root().join("project")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1418,13 +1383,12 @@ home = ./ Found 4 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // Run with VIRTUAL_ENV set, should find the active venv assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) - .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1441,13 +1405,12 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX set, should find the child conda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) - .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")), @r" + .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1465,14 +1428,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX and CONDA_DEFAULT_ENV set (unequal), should find base conda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) - .env("CONDA_DEFAULT_ENV", "base"), @r" + .env("CONDA_DEFAULT_ENV", "base"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1489,8 +1451,7 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (unequal) and VIRTUAL_ENV set, // should find child active venv @@ -1498,7 +1459,7 @@ home = ./ .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) .env("CONDA_DEFAULT_ENV", "base") - .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1515,14 +1476,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (unequal!) set, should find base conda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) - .env("CONDA_DEFAULT_ENV", "base"), @r" + .env("CONDA_DEFAULT_ENV", "base"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1539,14 +1499,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with _CONDA_ROOT and CONDA_PREFIX (unequal!) set, should find ChildConda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda/envs/conda-env")) - .env("_CONDA_ROOT", "conda"), @r" + .env("_CONDA_ROOT", "conda"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1564,14 +1523,13 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); // run with _CONDA_ROOT and CONDA_PREFIX (equal!) set, should find BaseConda assert_cmd_snapshot!(case.command() .current_dir(case.root().join("project")) .env("CONDA_PREFIX", case.root().join("conda")) - .env("_CONDA_ROOT", "conda"), @r" + .env("_CONDA_ROOT", "conda"), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1588,8 +1546,7 @@ home = ./ Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1607,7 +1564,7 @@ fn src_root_deprecation_warning() -> anyhow::Result<()> { ("src/test.py", ""), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1622,8 +1579,7 @@ fn src_root_deprecation_warning() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -1644,7 +1600,7 @@ fn src_root_deprecation_warning_with_environment_root() -> anyhow::Result<()> { ("app/test.py", ""), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1662,8 +1618,7 @@ fn src_root_deprecation_warning_with_environment_root() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -1690,7 +1645,7 @@ fn environment_root_takes_precedence_over_src_root() -> anyhow::Result<()> { // The test should pass because environment.root points to ./app where my_module.py exists // If src.root took precedence, it would fail because my_module.py doesn't exist in ./src - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1708,8 +1663,7 @@ fn environment_root_takes_precedence_over_src_root() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -1730,15 +1684,14 @@ fn default_root_src_layout() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1766,15 +1719,14 @@ fn default_root_project_name_folder() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1795,15 +1747,14 @@ fn default_root_flat_layout() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1824,15 +1775,14 @@ fn default_root_tests_folder() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1855,7 +1805,7 @@ fn default_root_tests_package() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1878,8 +1828,7 @@ fn default_root_tests_package() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -1900,15 +1849,14 @@ fn default_root_python_folder() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -1931,7 +1879,7 @@ fn default_root_python_package() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1954,8 +1902,7 @@ fn default_root_python_package() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -1978,7 +1925,7 @@ fn default_root_python_package_pyi() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -2001,8 +1948,7 @@ fn default_root_python_package_pyi() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -2021,7 +1967,7 @@ fn pythonpath_is_respected() -> anyhow::Result<()> { ])?; assert_cmd_snapshot!(case.command(), - @r#" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -2042,20 +1988,18 @@ fn pythonpath_is_respected() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); assert_cmd_snapshot!(case.command() .env("PYTHONPATH", case.root().join("baz-dir")), - @r#" + @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -2078,7 +2022,7 @@ fn pythonpath_multiple_dirs_is_respected() -> anyhow::Result<()> { ])?; assert_cmd_snapshot!(case.command(), - @r#" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -2115,22 +2059,20 @@ fn pythonpath_multiple_dirs_is_respected() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); let pythonpath = std::env::join_paths([case.root().join("baz-dir"), case.root().join("foo-dir")])?; assert_cmd_snapshot!(case.command() .env("PYTHONPATH", pythonpath), - @r#" + @r###" success: true exit_code: 0 ----- stdout ----- All checks passed! ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } diff --git a/crates/ty/tests/cli/rule_selection.rs b/crates/ty/tests/cli/rule_selection.rs index 8b5382f564..8e9ee8e3af 100644 --- a/crates/ty/tests/cli/rule_selection.rs +++ b/crates/ty/tests/cli/rule_selection.rs @@ -35,7 +35,6 @@ fn configuration_rule_severity() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "###); case.write_file( @@ -47,7 +46,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> { "#, )?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -64,8 +63,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -89,7 +87,7 @@ fn cli_rule_severity() -> anyhow::Result<()> { // Assert that there's an `unresolved-reference` diagnostic (error) // and an unresolved-import (error) diagnostic by default. - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -120,8 +118,7 @@ fn cli_rule_severity() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); assert_cmd_snapshot!( case @@ -132,7 +129,7 @@ fn cli_rule_severity() -> anyhow::Result<()> { .arg("division-by-zero") .arg("--warn") .arg("unresolved-import"), - @r" + @r###" success: true exit_code: 0 ----- stdout ----- @@ -165,8 +162,7 @@ fn cli_rule_severity() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - " + "### ); Ok(()) @@ -206,7 +202,6 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "###); assert_cmd_snapshot!( @@ -218,7 +213,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> { .arg("division-by-zero") .arg("--ignore") .arg("unresolved-reference"), - @r" + @r###" success: true exit_code: 0 ----- stdout ----- @@ -235,8 +230,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - " + "### ); Ok(()) @@ -256,7 +250,7 @@ fn configuration_unknown_rules() -> anyhow::Result<()> { ("test.py", "print(10)"), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -271,8 +265,7 @@ fn configuration_unknown_rules() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -282,7 +275,7 @@ fn configuration_unknown_rules() -> anyhow::Result<()> { fn cli_unknown_rules() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", "print(10)")?; - assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r" + assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -291,8 +284,7 @@ fn cli_unknown_rules() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -371,7 +363,6 @@ fn overrides_basic() -> anyhow::Result<()> { Found 3 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "###); Ok(()) @@ -414,7 +405,7 @@ fn overrides_precedence() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -429,8 +420,7 @@ fn overrides_precedence() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -466,7 +456,7 @@ fn overrides_exclude() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -489,8 +479,7 @@ fn overrides_exclude() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "); + "###); Ok(()) } @@ -530,7 +519,7 @@ fn overrides_inherit_global() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -564,8 +553,7 @@ fn overrides_inherit_global() -> anyhow::Result<()> { Found 3 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -594,13 +582,12 @@ fn overrides_invalid_include_glob() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: error[invalid-glob]: Invalid include pattern --> pyproject.toml:6:12 @@ -611,7 +598,7 @@ fn overrides_invalid_include_glob() -> anyhow::Result<()> { 7 | [tool.ty.overrides.rules] 8 | division-by-zero = "warn" | - "#); + "###); Ok(()) } @@ -641,13 +628,12 @@ fn overrides_invalid_exclude_glob() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. ty failed Cause: error[invalid-glob]: Invalid exclude pattern --> pyproject.toml:7:12 @@ -659,7 +645,7 @@ fn overrides_invalid_exclude_glob() -> anyhow::Result<()> { 8 | [tool.ty.overrides.rules] 9 | division-by-zero = "warn" | - "#); + "###); Ok(()) } @@ -688,7 +674,7 @@ fn overrides_missing_include_exclude() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -717,8 +703,7 @@ fn overrides_missing_include_exclude() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -747,7 +732,7 @@ fn overrides_empty_include() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -773,8 +758,7 @@ fn overrides_empty_include() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -802,7 +786,7 @@ fn overrides_no_actual_overrides() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command(), @r###" success: false exit_code: 1 ----- stdout ----- @@ -831,8 +815,7 @@ fn overrides_no_actual_overrides() -> anyhow::Result<()> { Found 2 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. - "#); + "###); Ok(()) } @@ -901,7 +884,6 @@ fn overrides_unknown_rules() -> anyhow::Result<()> { Found 3 diagnostics ----- stderr ----- - WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "###); Ok(()) From 71f8389f61a243a0c7584adffc49134ccf792aba Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:13:27 -0400 Subject: [PATCH 020/113] Fix syntax error false positives on parenthesized context managers (#20846) This PR resolves the issue noticed in https://github.com/astral-sh/ruff/pull/20777#discussion_r2417233227. Namely, cases like this were being flagged as syntax errors despite being perfectly valid on Python 3.8: ```pycon Python 3.8.20 (default, Oct 2 2024, 16:34:12) [Clang 18.1.8 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> with (open("foo.txt", "w")): ... ... Ellipsis >>> with (open("foo.txt", "w")) as f: print(f) ... <_io.TextIOWrapper name='foo.txt' mode='w' encoding='UTF-8'> ``` The second of these was already allowed but not the first: ```shell > ruff check --target-version py38 --ignore ALL - < -:1:6 | 1 | with (open("foo.txt", "w")): ... | ^ 2 | with (open("foo.txt", "w")) as f: print(f) | Found 1 error. ``` There was some discussion of related cases in https://github.com/astral-sh/ruff/pull/16523#discussion_r1984657793, but it seems I overlooked the single-element case when flagging tuples. As suggested in the other thread, we can just check if there's more than one element or a trailing comma, which will cause the tuple parsing on <=3.8 and avoid the false positives. --- .../snapshots/format@statement__with.py.snap | 25 --- .../inline/err/tuple_context_manager_py38.py | 2 - ...parenthesized_item_context_manager_py38.py | 5 + .../src/parser/statement.rs | 40 +++-- ..._syntax@tuple_context_manager_py38.py.snap | 136 ++++----------- ...thesized_item_context_manager_py38.py.snap | 163 ++++++++++++++++++ 6 files changed, 222 insertions(+), 149 deletions(-) create mode 100644 crates/ruff_python_parser/resources/inline/ok/single_parenthesized_item_context_manager_py38.py create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap index 992cb23f5e..bb04ae9560 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap @@ -807,31 +807,6 @@ with ( ``` -### Unsupported Syntax Errors -error[invalid-syntax]: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) - --> with.py:333:10 - | -332 | if True: -333 | with ( - | ^ -334 | anyio.CancelScope(shield=True) -335 | if get_running_loop() - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. - -error[invalid-syntax]: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) - --> with.py:359:6 - | -357 | pass -358 | -359 | with ( - | ^ -360 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb -361 | ): - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. - - ### Output 2 ``` indent-style = space diff --git a/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py b/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py index bdc0639602..159f8d13e9 100644 --- a/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py +++ b/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py @@ -3,8 +3,6 @@ # is parsed as a tuple, but this will always cause a runtime error, so we flag it # anyway with (foo, bar): ... -with ( - open('foo.txt')) as foo: ... with ( foo, bar, diff --git a/crates/ruff_python_parser/resources/inline/ok/single_parenthesized_item_context_manager_py38.py b/crates/ruff_python_parser/resources/inline/ok/single_parenthesized_item_context_manager_py38.py new file mode 100644 index 0000000000..49238d151d --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/single_parenthesized_item_context_manager_py38.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.8"} +with ( + open('foo.txt')) as foo: ... +with ( + open('foo.txt')): ... diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 171cffaa41..134c9c40fd 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -2130,7 +2130,7 @@ impl<'src> Parser<'src> { let open_paren_range = self.current_token_range(); if self.at(TokenKind::Lpar) { - if let Some(items) = self.try_parse_parenthesized_with_items() { + if let (Some(items), has_trailing_comma) = self.try_parse_parenthesized_with_items() { // test_ok tuple_context_manager_py38 // # parse_options: {"target-version": "3.8"} // with ( @@ -2139,6 +2139,13 @@ impl<'src> Parser<'src> { // baz, // ) as tup: ... + // test_ok single_parenthesized_item_context_manager_py38 + // # parse_options: {"target-version": "3.8"} + // with ( + // open('foo.txt')) as foo: ... + // with ( + // open('foo.txt')): ... + // test_err tuple_context_manager_py38 // # parse_options: {"target-version": "3.8"} // # these cases are _syntactically_ valid before Python 3.9 because the `with` item @@ -2146,8 +2153,6 @@ impl<'src> Parser<'src> { // # anyway // with (foo, bar): ... // with ( - // open('foo.txt')) as foo: ... - // with ( // foo, // bar, // baz, @@ -2165,10 +2170,12 @@ impl<'src> Parser<'src> { // with (foo as x, bar as y): ... // with (foo, bar as y): ... // with (foo as x, bar): ... - self.add_unsupported_syntax_error( - UnsupportedSyntaxErrorKind::ParenthesizedContextManager, - open_paren_range, - ); + if items.len() > 1 || has_trailing_comma { + self.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::ParenthesizedContextManager, + open_paren_range, + ); + } self.expect(TokenKind::Rpar); items @@ -2228,7 +2235,7 @@ impl<'src> Parser<'src> { /// If the parser isn't positioned at a `(` token. /// /// See: - fn try_parse_parenthesized_with_items(&mut self) -> Option> { + fn try_parse_parenthesized_with_items(&mut self) -> (Option>, bool) { let checkpoint = self.checkpoint(); // We'll start with the assumption that the with items are parenthesized. @@ -2245,11 +2252,12 @@ impl<'src> Parser<'src> { // with (item1, item2 item3, item4): ... // with (item1, item2 as f1 item3, item4): ... // with (item1, item2: ... - self.parse_comma_separated_list(RecoveryContextKind::WithItems(with_item_kind), |p| { - let parsed_with_item = p.parse_with_item(WithItemParsingState::Speculative); - has_optional_vars |= parsed_with_item.item.optional_vars.is_some(); - parsed_with_items.push(parsed_with_item); - }); + let has_trailing_comma = + self.parse_comma_separated_list(RecoveryContextKind::WithItems(with_item_kind), |p| { + let parsed_with_item = p.parse_with_item(WithItemParsingState::Speculative); + has_optional_vars |= parsed_with_item.item.optional_vars.is_some(); + parsed_with_items.push(parsed_with_item); + }); // Check if our assumption is incorrect and it's actually a parenthesized expression. if has_optional_vars { @@ -2319,7 +2327,7 @@ impl<'src> Parser<'src> { with_item_kind = WithItemKind::ParenthesizedExpression; } - if with_item_kind.is_parenthesized() { + let with_items = if with_item_kind.is_parenthesized() { Some( parsed_with_items .into_iter() @@ -2330,7 +2338,9 @@ impl<'src> Parser<'src> { self.rewind(checkpoint); None - } + }; + + (with_items, has_trailing_comma) } /// Parses a single `with` item. diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@tuple_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@tuple_context_manager_py38.py.snap index 88c24c6972..be7cb746f5 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@tuple_context_manager_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@tuple_context_manager_py38.py.snap @@ -8,7 +8,7 @@ input_file: crates/ruff_python_parser/resources/inline/err/tuple_context_manager Module( ModModule { node_index: NodeIndex(None), - range: 0..327, + range: 0..289, body: [ With( StmtWith { @@ -62,94 +62,16 @@ Module( With( StmtWith { node_index: NodeIndex(None), - range: 237..274, + range: 237..271, is_async: false, items: [ WithItem { - range: 242..269, - node_index: NodeIndex(None), - context_expr: Call( - ExprCall { - node_index: NodeIndex(None), - range: 246..261, - func: Name( - ExprName { - node_index: NodeIndex(None), - range: 246..250, - id: Name("open"), - ctx: Load, - }, - ), - arguments: Arguments { - range: 250..261, - node_index: NodeIndex(None), - args: [ - StringLiteral( - ExprStringLiteral { - node_index: NodeIndex(None), - range: 251..260, - value: StringLiteralValue { - inner: Single( - StringLiteral { - range: 251..260, - node_index: NodeIndex(None), - value: "foo.txt", - flags: StringLiteralFlags { - quote_style: Single, - prefix: Empty, - triple_quoted: false, - }, - }, - ), - }, - }, - ), - ], - keywords: [], - }, - }, - ), - optional_vars: Some( - Name( - ExprName { - node_index: NodeIndex(None), - range: 266..269, - id: Name("foo"), - ctx: Store, - }, - ), - ), - }, - ], - body: [ - Expr( - StmtExpr { - node_index: NodeIndex(None), - range: 271..274, - value: EllipsisLiteral( - ExprEllipsisLiteral { - node_index: NodeIndex(None), - range: 271..274, - }, - ), - }, - ), - ], - }, - ), - With( - StmtWith { - node_index: NodeIndex(None), - range: 275..309, - is_async: false, - items: [ - WithItem { - range: 284..287, + range: 246..249, node_index: NodeIndex(None), context_expr: Name( ExprName { node_index: NodeIndex(None), - range: 284..287, + range: 246..249, id: Name("foo"), ctx: Load, }, @@ -157,12 +79,12 @@ Module( optional_vars: None, }, WithItem { - range: 291..294, + range: 253..256, node_index: NodeIndex(None), context_expr: Name( ExprName { node_index: NodeIndex(None), - range: 291..294, + range: 253..256, id: Name("bar"), ctx: Load, }, @@ -170,12 +92,12 @@ Module( optional_vars: None, }, WithItem { - range: 298..301, + range: 260..263, node_index: NodeIndex(None), context_expr: Name( ExprName { node_index: NodeIndex(None), - range: 298..301, + range: 260..263, id: Name("baz"), ctx: Load, }, @@ -187,11 +109,11 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 306..309, + range: 268..271, value: EllipsisLiteral( ExprEllipsisLiteral { node_index: NodeIndex(None), - range: 306..309, + range: 268..271, }, ), }, @@ -202,16 +124,16 @@ Module( With( StmtWith { node_index: NodeIndex(None), - range: 310..326, + range: 272..288, is_async: false, items: [ WithItem { - range: 316..319, + range: 278..281, node_index: NodeIndex(None), context_expr: Name( ExprName { node_index: NodeIndex(None), - range: 316..319, + range: 278..281, id: Name("foo"), ctx: Load, }, @@ -223,11 +145,11 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 323..326, + range: 285..288, value: EllipsisLiteral( ExprEllipsisLiteral { node_index: NodeIndex(None), - range: 323..326, + range: 285..288, }, ), }, @@ -247,23 +169,23 @@ Module( 5 | with (foo, bar): ... | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) 6 | with ( -7 | open('foo.txt')) as foo: ... +7 | foo, + | + + + | +4 | # anyway +5 | with (foo, bar): ... +6 | with ( + | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) +7 | foo, +8 | bar, | | - 6 | with ( - 7 | open('foo.txt')) as foo: ... - 8 | with ( - | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) - 9 | foo, -10 | bar, - | - - - | -11 | baz, -12 | ): ... -13 | with (foo,): ... + 9 | baz, +10 | ): ... +11 | with (foo,): ... | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap new file mode 100644 index 0000000000..f2ab2b6c7b --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap @@ -0,0 +1,163 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/single_parenthesized_item_context_manager_py38.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..112, + body: [ + With( + StmtWith { + node_index: NodeIndex(None), + range: 43..80, + is_async: false, + items: [ + WithItem { + range: 48..75, + node_index: NodeIndex(None), + context_expr: Call( + ExprCall { + node_index: NodeIndex(None), + range: 52..67, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..56, + id: Name("open"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 56..67, + node_index: NodeIndex(None), + args: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 57..66, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 57..66, + node_index: NodeIndex(None), + value: "foo.txt", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + }, + }, + ), + }, + }, + ), + ], + keywords: [], + }, + }, + ), + optional_vars: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 72..75, + id: Name("foo"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 77..80, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 77..80, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + node_index: NodeIndex(None), + range: 81..111, + is_async: false, + items: [ + WithItem { + range: 90..105, + node_index: NodeIndex(None), + context_expr: Call( + ExprCall { + node_index: NodeIndex(None), + range: 90..105, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 90..94, + id: Name("open"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 94..105, + node_index: NodeIndex(None), + args: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 95..104, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 95..104, + node_index: NodeIndex(None), + value: "foo.txt", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + }, + }, + ), + }, + }, + ), + ], + keywords: [], + }, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 108..111, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 108..111, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +``` From d912f136619dd3643d39402be4e119c79aa6a3d0 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Oct 2025 20:44:27 +0200 Subject: [PATCH 021/113] [ty] Do not bind self to non-positional parameters (#20850) ## Summary closes https://github.com/astral-sh/ty/issues/1333 ## Test Plan Regression test --- .../resources/mdtest/annotations/self.md | 14 ++++++++++++++ crates/ty_python_semantic/src/types/signatures.rs | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/self.md b/crates/ty_python_semantic/resources/mdtest/annotations/self.md index f06862e4b0..91a2bcaf07 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/self.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/self.md @@ -473,4 +473,18 @@ class C: reveal_type(generic_context(C.f)) # revealed: None ``` +## Non-positional first parameters + +This makes sure that we don't bind `self` if it's not a positional parameter: + +```py +from ty_extensions import CallableTypeOf + +class C: + def method(*args, **kwargs) -> None: ... + +def _(c: CallableTypeOf[C().method]): + reveal_type(c) # revealed: (...) -> None +``` + [self attribute]: https://typing.python.org/en/latest/spec/generics.html#use-in-attribute-annotations diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 039b89a6eb..8547d41a8e 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -579,7 +579,15 @@ impl<'db> Signature<'db> { } pub(crate) fn bind_self(&self, db: &'db dyn Db, self_type: Option>) -> Self { - let mut parameters = Parameters::new(self.parameters().iter().skip(1).cloned()); + let mut parameters = self.parameters.iter().cloned().peekable(); + + // TODO: Theoretically, for a signature like `f(*args: *tuple[MyClass, int, *tuple[str, ...]])` with + // a variadic first parameter, we should also "skip the first parameter" by modifying the tuple type. + if parameters.peek().is_some_and(Parameter::is_positional) { + parameters.next(); + } + + let mut parameters = Parameters::new(parameters); let mut return_ty = self.return_ty; if let Some(self_type) = self_type { parameters = parameters.apply_type_mapping_impl( From 4b8e278a88aaad9f575df19a1d76ad6712160218 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Oct 2025 21:17:47 +0200 Subject: [PATCH 022/113] [ty] Treat `Callable`s as bound-method descriptors in special cases (#20802) ## Summary Treat `Callable`s as bound-method descriptors if `Callable` is the return type of a decorator that is applied to a function definition. See the [rendered version of the new test file](https://github.com/astral-sh/ruff/blob/david/callables-as-descriptors/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md) for the full description of this new heuristic. I could imagine that we want to treat `Callable`s as bound-method descriptors in other cases as well, but this seems like a step in the right direction. I am planning to add other "use cases" from https://github.com/astral-sh/ty/issues/491 to this test suite. partially addresses https://github.com/astral-sh/ty/issues/491 closes https://github.com/astral-sh/ty/issues/1333 ## Ecosystem impact All positive * 2961 removed `unsupported-operator` diagnostics on `sympy`, which was one of the main motivations for implementing this change * 37 removed `missing-argument` diagnostics, and no added call-error diagnostics, which is an indicator that this heuristic shouldn't cause many false positives * A few removed `possibly-missing-attribute` diagnostics when accessing attributes like `__name__` on decorated functions. The two added `unused-ignore-comment` diagnostics are also cases of this. * One new `invalid-assignment` diagnostic on `dd-trace-py`, which looks suspicious, but only because our `invalid-assignment` diagnostics are not great. This is actually a "Implicit shadowing of function" diagnostic that hides behind the `invalid-assignment` diagnostic, because a module-global function is being patched through a `module.func` attribute assignment. ## Test Plan New Markdown tests. --- .../mdtest/call/callables_as_descriptors.md | 190 ++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 7 + .../src/types/infer/builder.rs | 21 +- 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md new file mode 100644 index 0000000000..cee7df8eca --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -0,0 +1,190 @@ +# Callables as descriptors? + +```toml +[environment] +python-version = "3.14" +``` + +## Introduction + +Some common callable objects (all functions, including lambdas) are also bound-method descriptors. +That is, they have a `__get__` method which returns a bound-method object that binds the receiver +instance to the first argument. The bound-method object therefore has a different signature, lacking +the first argument: + +```py +from ty_extensions import CallableTypeOf +from typing import Callable + +class C1: + def method(self: C1, x: int) -> str: + return str(x) + +def _( + accessed_on_class: CallableTypeOf[C1.method], + accessed_on_instance: CallableTypeOf[C1().method], +): + reveal_type(accessed_on_class) # revealed: (self: C1, x: int) -> str + reveal_type(accessed_on_instance) # revealed: (x: int) -> str +``` + +Other callable objects (`staticmethod` objects, instances of classes with a `__call__` method but no +dedicated `__get__` method) are *not* bound-method descriptors. If accessed as class attributes via +an instance, they are simply themselves: + +```py +class NonDescriptorCallable2: + def __call__(self, c2: C2, x: int) -> str: + return str(x) + +class C2: + non_descriptor_callable: NonDescriptorCallable2 = NonDescriptorCallable2() + +def _( + accessed_on_class: CallableTypeOf[C2.non_descriptor_callable], + accessed_on_instance: CallableTypeOf[C2().non_descriptor_callable], +): + reveal_type(accessed_on_class) # revealed: (c2: C2, x: int) -> str + reveal_type(accessed_on_instance) # revealed: (c2: C2, x: int) -> str +``` + +Both kinds of objects can inhabit the same `Callable` type: + +```py +class NonDescriptorCallable3: + def __call__(self, c3: C3, x: int) -> str: + return str(x) + +class C3: + def method(self: C3, x: int) -> str: + return str(x) + non_descriptor_callable: NonDescriptorCallable3 = NonDescriptorCallable3() + + callable_m: Callable[[C3, int], str] = method + callable_n: Callable[[C3, int], str] = non_descriptor_callable +``` + +However, when they are accessed on instances of `C3`, they have different signatures: + +```py +def _( + method_accessed_on_instance: CallableTypeOf[C3().method], + callable_accessed_on_instance: CallableTypeOf[C3().non_descriptor_callable], +): + reveal_type(method_accessed_on_instance) # revealed: (x: int) -> str + reveal_type(callable_accessed_on_instance) # revealed: (c3: C3, x: int) -> str +``` + +This leaves the question how the `callable_m` and `callable_n` attributes should be treated when +accessed on instances of `C3`. If we treat `Callable` as being equivalent to a protocol that defines +a `__call__` method (and no `__get__` method), then they should show no bound-method behavior. This +is what we currently do: + +```py +reveal_type(C3().callable_m) # revealed: (C3, int, /) -> str +reveal_type(C3().callable_n) # revealed: (C3, int, /) -> str +``` + +However, this leads to unsoundness: `C3().callable_m` is actually `C3.method` which *is* a +bound-method descriptor. We currently allow the following call, which will fail at runtime: + +```py +C3().callable_m(C3(), 1) # runtime error! ("takes 2 positional arguments but 3 were given") +``` + +If we were to treat `Callable`s as bound-method descriptors, then the signatures of `callable_m` and +`callable_n` when accessed on instances would bind the `self` argument: + +- `C3().callable_m`: `(x: int) -> str` +- `C3().callable_n`: `(x: int) -> str` + +This would be equally unsound, because now we would allow a call to `C3().callable_n(1)` which would +also fail at runtime. + +There is no perfect solution here, but we can use some heuristics to improve the situation for +certain use cases (at the cost of purity and simplicity). + +## Use case: Decorating a method with a `Callable`-typed decorator + +A commonly used pattern in the ecosystem is to use a `Callable`-typed decorator on a method with the +intention that it shouldn't influence the method's descriptor behavior. For example, we treat +`method_decorated` below as a bound method, even though its type is `Callable[[C1, int], str]`: + +```py +from typing import Callable + +# TODO: this could use a generic signature, but we don't support +# `ParamSpec` and solving of typevars inside `Callable` types yet. +def memoize(f: Callable[[C1, int], str]) -> Callable[[C1, int], str]: + raise NotImplementedError + +class C1: + def method(self, x: int) -> str: + return str(x) + + @memoize + def method_decorated(self, x: int) -> str: + return str(x) + +C1().method(1) + +C1().method_decorated(1) +``` + +This also works with an argumentless `Callable` annotation: + +```py +def memoize2(f: Callable) -> Callable: + raise NotImplementedError + +class C2: + @memoize2 + def method_decorated(self, x: int) -> str: + return str(x) + +C2().method_decorated(1) +``` + +Note that we currently only apply this heuristic when calling a function such as `memoize` via the +decorator syntax. This is inconsistent, because the above *should* be equivalent to the following, +but here we emit errors: + +```py +def memoize3(f: Callable[[C3, int], str]) -> Callable[[C3, int], str]: + raise NotImplementedError + +class C3: + def method(self, x: int) -> str: + return str(x) + method_decorated = memoize3(method) + +# error: [missing-argument] +# error: [invalid-argument-type] +C3().method_decorated(1) +``` + +The reason for this is that the heuristic is problematic. We don't *know* that the `Callable` in the +return type of `memoize` is actually related to the method that we pass in. But when `memoize` is +applied as a decorator, it is reasonable to assume so. + +In general, a function call might however return a `Callable` that is unrelated to the argument +passed in. And here, it seems more reasonable and safe to treat the `Callable` as a non-descriptor. +This allows correct programs like the following to pass type checking (that are currently rejected +by pyright and mypy with a heuristic that apparently applies in a wider range of situations): + +```py +class SquareCalculator: + def __init__(self, post_process: Callable[[float], int]): + self.post_process = post_process + + def __call__(self, x: float) -> int: + return self.post_process(x * x) + +def square_then(c: Callable[[float], int]) -> Callable[[float], int]: + return SquareCalculator(c) + +class Calculator: + square_then_round = square_then(round) + +reveal_type(Calculator().square_then_round(3.14)) # revealed: Unknown | int +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 62e3145ca1..cc9021719a 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1014,6 +1014,13 @@ impl<'db> Type<'db> { } } + pub(crate) const fn unwrap_as_callable_type(self) -> Option> { + match self { + Type::Callable(callable_type) => Some(callable_type), + _ => None, + } + } + pub(crate) const fn expect_dynamic(self) -> DynamicType<'db> { self.into_dynamic() .expect("Expected a Type::Dynamic variant") diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1e93f456ca..fcca0f18e5 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -2175,7 +2175,26 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .try_call(self.db(), &CallArguments::positional([inferred_ty])) .map(|bindings| bindings.return_type(self.db())) { - Ok(return_ty) => return_ty, + Ok(return_ty) => { + let is_input_function_like = inferred_ty + .into_callable(self.db()) + .and_then(Type::unwrap_as_callable_type) + .is_some_and(|callable| callable.is_function_like(self.db())); + if is_input_function_like + && let Some(callable_type) = return_ty.unwrap_as_callable_type() + { + // When a method on a class is decorated with a function that returns a `Callable`, assume that + // the returned callable is also function-like. See "Decorating a method with a `Callable`-typed + // decorator" in `callables_as_descriptors.md` for the extended explanation. + Type::Callable(CallableType::new( + self.db(), + callable_type.signatures(self.db()), + true, + )) + } else { + return_ty + } + } Err(CallError(_, bindings)) => { bindings.report_diagnostics(&self.context, (*decorator_node).into()); bindings.return_type(self.db()) From 2b729b4d52538e975b0e1d60b6098e1c3bc6771f Mon Sep 17 00:00:00 2001 From: Bhuminjay Soni Date: Tue, 14 Oct 2025 01:30:59 +0530 Subject: [PATCH 023/113] [syntax-errors]: break outside loop F701 (#20556) ## Summary This PR implements https://docs.astral.sh/ruff/rules/break-outside-loop/ (F701) as a semantic syntax error. ## Test Plan --------- Signed-off-by: 11happy Co-authored-by: Brent Westbrook --- .../src/checkers/ast/analyze/statement.rs | 9 ----- crates/ruff_linter/src/checkers/ast/mod.rs | 34 +++++++++++++++++-- .../pyflakes/rules/break_outside_loop.rs | 30 +--------------- .../ruff_python_parser/src/semantic_errors.rs | 11 ++++++ crates/ruff_python_parser/tests/fixtures.rs | 4 +++ .../src/semantic_index/builder.rs | 4 +++ 6 files changed, 51 insertions(+), 41 deletions(-) diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 155836b3bd..89873737a6 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -50,15 +50,6 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { pylint::rules::nonlocal_and_global(checker, nonlocal); } } - Stmt::Break(_) => { - if checker.is_rule_enabled(Rule::BreakOutsideLoop) { - pyflakes::rules::break_outside_loop( - checker, - stmt, - &mut checker.semantic.current_statements().skip(1), - ); - } - } Stmt::Continue(_) => { if checker.is_rule_enabled(Rule::ContinueOutsideLoop) { pyflakes::rules::continue_outside_loop( diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 1a1f462e8e..d6f5b81199 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -697,6 +697,7 @@ impl SemanticSyntaxContext for Checker<'_> { } } SemanticSyntaxErrorKind::FutureFeatureNotDefined(name) => { + // F407 if self.is_rule_enabled(Rule::FutureFeatureNotDefined) { self.report_diagnostic( pyflakes::rules::FutureFeatureNotDefined { name }, @@ -704,6 +705,12 @@ impl SemanticSyntaxContext for Checker<'_> { ); } } + SemanticSyntaxErrorKind::BreakOutsideLoop => { + // F701 + if self.is_rule_enabled(Rule::BreakOutsideLoop) { + self.report_diagnostic(pyflakes::rules::BreakOutsideLoop, error.range); + } + } SemanticSyntaxErrorKind::ReboundComprehensionVariable | SemanticSyntaxErrorKind::DuplicateTypeParameter | SemanticSyntaxErrorKind::MultipleCaseAssignment(_) @@ -811,19 +818,40 @@ impl SemanticSyntaxContext for Checker<'_> { } ) } + + fn in_loop_context(&self) -> bool { + let mut child = self.semantic.current_statement(); + + for parent in self.semantic.current_statements().skip(1) { + match parent { + Stmt::For(ast::StmtFor { orelse, .. }) + | Stmt::While(ast::StmtWhile { orelse, .. }) => { + if !orelse.contains(child) { + return true; + } + } + Stmt::FunctionDef(_) | Stmt::ClassDef(_) => { + break; + } + _ => {} + } + child = parent; + } + false + } } impl<'a> Visitor<'a> for Checker<'a> { fn visit_stmt(&mut self, stmt: &'a Stmt) { + // Step 0: Pre-processing + self.semantic.push_node(stmt); + // For functions, defer semantic syntax error checks until the body of the function is // visited if !stmt.is_function_def_stmt() { self.with_semantic_checker(|semantic, context| semantic.visit_stmt(stmt, context)); } - // Step 0: Pre-processing - self.semantic.push_node(stmt); - // For Jupyter Notebooks, we'll reset the `IMPORT_BOUNDARY` flag when // we encounter a cell boundary. if self.source_type.is_ipynb() diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs b/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs index 88b77c2cb4..0309c0047e 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/break_outside_loop.rs @@ -1,9 +1,6 @@ -use ruff_python_ast::{self as ast, Stmt}; - use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_text_size::Ranged; -use crate::{Violation, checkers::ast::Checker}; +use crate::Violation; /// ## What it does /// Checks for `break` statements outside of loops. @@ -29,28 +26,3 @@ impl Violation for BreakOutsideLoop { "`break` outside loop".to_string() } } - -/// F701 -pub(crate) fn break_outside_loop<'a>( - checker: &Checker, - stmt: &'a Stmt, - parents: &mut impl Iterator, -) { - let mut child = stmt; - for parent in parents { - match parent { - Stmt::For(ast::StmtFor { orelse, .. }) | Stmt::While(ast::StmtWhile { orelse, .. }) => { - if !orelse.contains(child) { - return; - } - } - Stmt::FunctionDef(_) | Stmt::ClassDef(_) => { - break; - } - _ => {} - } - child = parent; - } - - checker.report_diagnostic(BreakOutsideLoop, stmt.range()); -} diff --git a/crates/ruff_python_parser/src/semantic_errors.rs b/crates/ruff_python_parser/src/semantic_errors.rs index 8913c5d0c1..51949f8bb4 100644 --- a/crates/ruff_python_parser/src/semantic_errors.rs +++ b/crates/ruff_python_parser/src/semantic_errors.rs @@ -224,6 +224,11 @@ impl SemanticSyntaxChecker { ); } } + Stmt::Break(ast::StmtBreak { range, .. }) => { + if !ctx.in_loop_context() { + Self::add_error(ctx, SemanticSyntaxErrorKind::BreakOutsideLoop, *range); + } + } _ => {} } @@ -1125,6 +1130,7 @@ impl Display for SemanticSyntaxError { SemanticSyntaxErrorKind::FutureFeatureNotDefined(name) => { write!(f, "Future feature `{name}` is not defined") } + SemanticSyntaxErrorKind::BreakOutsideLoop => f.write_str("`break` outside loop"), } } } @@ -1498,6 +1504,9 @@ pub enum SemanticSyntaxErrorKind { /// Represents the use of a `__future__` feature that is not defined. FutureFeatureNotDefined(String), + + /// Represents the use of a `break` statement outside of a loop. + BreakOutsideLoop, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] @@ -1979,6 +1988,8 @@ pub trait SemanticSyntaxContext { fn in_notebook(&self) -> bool; fn report_semantic_error(&self, error: SemanticSyntaxError); + + fn in_loop_context(&self) -> bool; } /// Modified version of [`std::str::EscapeDefault`] that does not escape single or double quotes. diff --git a/crates/ruff_python_parser/tests/fixtures.rs b/crates/ruff_python_parser/tests/fixtures.rs index e3679ea50a..9837d9d873 100644 --- a/crates/ruff_python_parser/tests/fixtures.rs +++ b/crates/ruff_python_parser/tests/fixtures.rs @@ -571,6 +571,10 @@ impl SemanticSyntaxContext for SemanticSyntaxCheckerVisitor<'_> { fn in_generator_scope(&self) -> bool { true } + + fn in_loop_context(&self) -> bool { + true + } } impl Visitor<'_> for SemanticSyntaxCheckerVisitor<'_> { diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index b7d19d075b..01de16b8a6 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -2785,6 +2785,10 @@ impl SemanticSyntaxContext for SemanticIndexBuilder<'_, '_> { self.semantic_syntax_errors.borrow_mut().push(error); } } + + fn in_loop_context(&self) -> bool { + true + } } #[derive(Copy, Clone, Debug, PartialEq)] From 83b497ce88a0b8dddafffba46048ac746179b16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Mon, 13 Oct 2025 17:49:11 -0300 Subject: [PATCH 024/113] Update Python compatibility from 3.13 to 3.14 in README.md (#20852) After #20725 ruff is compatible with Python 3.14 without preview enabled, so lets note it in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf5b1ce71a..28a4e6b37b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ An extremely fast Python linter and code formatter, written in Rust. - ⚡️ 10-100x faster than existing linters (like Flake8) and formatters (like Black) - 🐍 Installable via `pip` - 🛠️ `pyproject.toml` support -- 🤝 Python 3.13 compatibility +- 🤝 Python 3.14 compatibility - ⚖️ Drop-in parity with [Flake8](https://docs.astral.sh/ruff/faq/#how-does-ruffs-linter-compare-to-flake8), isort, and [Black](https://docs.astral.sh/ruff/faq/#how-does-ruffs-formatter-compare-to-black) - 📦 Built-in caching, to avoid re-analyzing unchanged files - 🔧 Fix support, for automatic error correction (e.g., automatically remove unused imports) From aba0bd568e424bfe728ab42e12c22670b6405b58 Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Mon, 13 Oct 2025 19:30:49 -0400 Subject: [PATCH 025/113] [ty] Diagnostic for generic classes that reference typevars in enclosing scope (#20822) Generic classes are not allowed to bind or reference a typevar from an enclosing scope: ```py def f[T](x: T, y: T) -> None: class Ok[S]: ... # error: [invalid-generic-class] class Bad1[T]: ... # error: [invalid-generic-class] class Bad2(Iterable[T]): ... class C[T]: class Ok1[S]: ... # error: [invalid-generic-class] class Bad1[T]: ... # error: [invalid-generic-class] class Bad2(Iterable[T]): ... ``` It does not matter if the class uses PEP 695 or legacy syntax. It does not matter if the enclosing scope is a generic class or function. The generic class cannot even _reference_ an enclosing typevar in its base class list. This PR adds diagnostics for these cases. In addition, the PR adds better fallback behavior for generic classes that violate this rule: any enclosing typevars are not included in the class's generic context. (That ensures that we don't inadvertently try to infer specializations for those typevars in places where we shouldn't.) The `dulwich` ecosystem project has [examples of this](https://github.com/jelmer/dulwich/blob/d912eaaffd60b4978ff92b91a8300e2cd234e0fc/dulwich/config.py#L251) that were causing new false positives on #20677. --------- Co-authored-by: Alex Waygood --- .../mdtest/generics/legacy/classes.md | 20 +++++ .../resources/mdtest/generics/scoping.md | 12 ++- ..._Generic_class_within…_(3259718bf20b45a2).snap | 63 ++++++++++++++++ ..._Generic_class_within…_(711fb86287c4d87b).snap | 63 ++++++++++++++++ crates/ty_python_semantic/src/types.rs | 8 +- crates/ty_python_semantic/src/types/class.rs | 75 +++++++++++++++++-- .../ty_python_semantic/src/types/context.rs | 22 +++++- .../src/types/diagnostic.rs | 39 +++++++++- .../ty_python_semantic/src/types/generics.rs | 14 +++- .../src/types/infer/builder.rs | 62 ++++++++++----- 10 files changed, 338 insertions(+), 40 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(3259718bf20b45a2).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(711fb86287c4d87b).snap diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md index 84f89e1811..184a7b49a5 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md @@ -71,6 +71,26 @@ reveal_type(generic_context(InheritedGenericPartiallySpecialized)) reveal_type(generic_context(InheritedGenericFullySpecialized)) ``` +In a nested class, references to typevars in an enclosing class are not allowed, but if they are +present, they are not included in the class's generic context. + +```py +class OuterClass(Generic[T]): + # error: [invalid-generic-class] "Generic class `InnerClass` must not reference type variables bound in an enclosing scope" + class InnerClass(list[T]): ... + # revealed: None + reveal_type(generic_context(InnerClass)) + + def method(self): + # error: [invalid-generic-class] "Generic class `InnerClassInMethod` must not reference type variables bound in an enclosing scope" + class InnerClassInMethod(list[T]): ... + # revealed: None + reveal_type(generic_context(InnerClassInMethod)) + +# revealed: tuple[T@OuterClass] +reveal_type(generic_context(OuterClass)) +``` + If you don't specialize a generic base class, we use the default specialization, which maps each typevar to its default value or `Any`. Since that base class is fully specialized, it does not make the inheriting class generic. diff --git a/crates/ty_python_semantic/resources/mdtest/generics/scoping.md b/crates/ty_python_semantic/resources/mdtest/generics/scoping.md index fb8b74428c..308092f4d1 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/scoping.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/scoping.md @@ -260,27 +260,31 @@ class C[T]: ### Generic class within generic function + + ```py from typing import Iterable def f[T](x: T, y: T) -> None: class Ok[S]: ... - # TODO: error for reuse of typevar + # error: [invalid-generic-class] class Bad1[T]: ... - # TODO: error for reuse of typevar + # error: [invalid-generic-class] class Bad2(Iterable[T]): ... ``` ### Generic class within generic class + + ```py from typing import Iterable class C[T]: class Ok1[S]: ... - # TODO: error for reuse of typevar + # error: [invalid-generic-class] class Bad1[T]: ... - # TODO: error for reuse of typevar + # error: [invalid-generic-class] class Bad2(Iterable[T]): ... ``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(3259718bf20b45a2).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(3259718bf20b45a2).snap new file mode 100644 index 0000000000..5a02751545 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(3259718bf20b45a2).snap @@ -0,0 +1,63 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: scoping.md - Scoping rules for type variables - Nested formal typevars must be distinct - Generic class within generic function +mdtest path: crates/ty_python_semantic/resources/mdtest/generics/scoping.md +--- + +# Python source files + +## mdtest_snippet.py + +``` +1 | from typing import Iterable +2 | +3 | def f[T](x: T, y: T) -> None: +4 | class Ok[S]: ... +5 | # error: [invalid-generic-class] +6 | class Bad1[T]: ... +7 | # error: [invalid-generic-class] +8 | class Bad2(Iterable[T]): ... +``` + +# Diagnostics + +``` +error[invalid-generic-class]: Generic class `Bad1` must not reference type variables bound in an enclosing scope + --> src/mdtest_snippet.py:3:5 + | +1 | from typing import Iterable +2 | +3 | def f[T](x: T, y: T) -> None: + | ------------------------ Type variable `T` is bound in this enclosing scope +4 | class Ok[S]: ... +5 | # error: [invalid-generic-class] +6 | class Bad1[T]: ... + | ^^^^ `T` referenced in class definition here +7 | # error: [invalid-generic-class] +8 | class Bad2(Iterable[T]): ... + | +info: rule `invalid-generic-class` is enabled by default + +``` + +``` +error[invalid-generic-class]: Generic class `Bad2` must not reference type variables bound in an enclosing scope + --> src/mdtest_snippet.py:3:5 + | +1 | from typing import Iterable +2 | +3 | def f[T](x: T, y: T) -> None: + | ------------------------ Type variable `T` is bound in this enclosing scope +4 | class Ok[S]: ... +5 | # error: [invalid-generic-class] +6 | class Bad1[T]: ... +7 | # error: [invalid-generic-class] +8 | class Bad2(Iterable[T]): ... + | ^^^^^^^^^^^^^^^^^ `T` referenced in class definition here + | +info: rule `invalid-generic-class` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(711fb86287c4d87b).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(711fb86287c4d87b).snap new file mode 100644 index 0000000000..064c0f5344 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/scoping.md_-_Scoping_rules_for_ty…_-_Nested_formal_typeva…_-_Generic_class_within…_(711fb86287c4d87b).snap @@ -0,0 +1,63 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: scoping.md - Scoping rules for type variables - Nested formal typevars must be distinct - Generic class within generic class +mdtest path: crates/ty_python_semantic/resources/mdtest/generics/scoping.md +--- + +# Python source files + +## mdtest_snippet.py + +``` +1 | from typing import Iterable +2 | +3 | class C[T]: +4 | class Ok1[S]: ... +5 | # error: [invalid-generic-class] +6 | class Bad1[T]: ... +7 | # error: [invalid-generic-class] +8 | class Bad2(Iterable[T]): ... +``` + +# Diagnostics + +``` +error[invalid-generic-class]: Generic class `Bad1` must not reference type variables bound in an enclosing scope + --> src/mdtest_snippet.py:3:7 + | +1 | from typing import Iterable +2 | +3 | class C[T]: + | - Type variable `T` is bound in this enclosing scope +4 | class Ok1[S]: ... +5 | # error: [invalid-generic-class] +6 | class Bad1[T]: ... + | ^^^^ `T` referenced in class definition here +7 | # error: [invalid-generic-class] +8 | class Bad2(Iterable[T]): ... + | +info: rule `invalid-generic-class` is enabled by default + +``` + +``` +error[invalid-generic-class]: Generic class `Bad2` must not reference type variables bound in an enclosing scope + --> src/mdtest_snippet.py:3:7 + | +1 | from typing import Iterable +2 | +3 | class C[T]: + | - Type variable `T` is bound in this enclosing scope +4 | class Ok1[S]: ... +5 | # error: [invalid-generic-class] +6 | class Bad1[T]: ... +7 | # error: [invalid-generic-class] +8 | class Bad2(Iterable[T]): ... + | ^^^^^^^^^^^^^^^^^ `T` referenced in class definition here + | +info: rule `invalid-generic-class` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index cc9021719a..67a2486b77 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -8210,12 +8210,16 @@ impl<'db> From> for BindingContext<'db> { } impl<'db> BindingContext<'db> { - fn name(self, db: &'db dyn Db) -> Option { + pub(crate) fn definition(self) -> Option> { match self { - BindingContext::Definition(definition) => definition.name(db), + BindingContext::Definition(definition) => Some(definition), BindingContext::Synthetic => None, } } + + fn name(self, db: &'db dyn Db) -> Option { + self.definition().and_then(|definition| definition.name(db)) + } } /// A type variable that has been bound to a generic context, and which can be specialized to a diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index c28a132abd..201e382fdd 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::sync::{LazyLock, Mutex}; use super::TypeVarVariance; @@ -20,12 +21,15 @@ use crate::types::context::InferContext; use crate::types::diagnostic::INVALID_TYPE_ALIAS_TYPE; use crate::types::enums::enum_metadata; use crate::types::function::{DataclassTransformerParams, KnownFunction}; -use crate::types::generics::{GenericContext, Specialization, walk_specialization}; +use crate::types::generics::{ + GenericContext, Specialization, walk_generic_context, walk_specialization, +}; use crate::types::infer::nearest_enclosing_class; use crate::types::member::{Member, class_member}; use crate::types::signatures::{CallableSignature, Parameter, Parameters, Signature}; use crate::types::tuple::{TupleSpec, TupleType}; use crate::types::typed_dict::typed_dict_params_from_class_def; +use crate::types::visitor::{NonAtomicType, TypeKind, TypeVisitor, walk_non_atomic_type}; use crate::types::{ ApplyTypeMappingVisitor, Binding, BoundSuperType, CallableType, DataclassParams, DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, @@ -35,7 +39,7 @@ use crate::types::{ determine_upper_bound, infer_definition_types, }; use crate::{ - Db, FxIndexMap, FxOrderSet, Program, + Db, FxIndexMap, FxIndexSet, FxOrderSet, Program, module_resolver::file_to_module, place::{ Boundness, LookupError, LookupResult, Place, PlaceAndQualifiers, known_module_symbol, @@ -1382,7 +1386,7 @@ impl get_size2::GetSize for ClassLiteral<'_> {} #[expect(clippy::ref_option)] #[allow(clippy::trivially_copy_pass_by_ref)] -fn pep695_generic_context_cycle_recover<'db>( +fn generic_context_cycle_recover<'db>( _db: &'db dyn Db, _value: &Option>, _count: u32, @@ -1391,7 +1395,7 @@ fn pep695_generic_context_cycle_recover<'db>( salsa::CycleRecoveryAction::Iterate } -fn pep695_generic_context_cycle_initial<'db>( +fn generic_context_cycle_initial<'db>( _db: &'db dyn Db, _self: ClassLiteral<'db>, ) -> Option> { @@ -1431,7 +1435,11 @@ impl<'db> ClassLiteral<'db> { self.pep695_generic_context(db).is_some() } - #[salsa::tracked(cycle_fn=pep695_generic_context_cycle_recover, cycle_initial=pep695_generic_context_cycle_initial, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked( + cycle_fn=generic_context_cycle_recover, + cycle_initial=generic_context_cycle_initial, + heap_size=ruff_memory_usage::heap_size, + )] pub(crate) fn pep695_generic_context(self, db: &'db dyn Db) -> Option> { let scope = self.body_scope(db); let file = scope.file(db); @@ -1454,12 +1462,18 @@ impl<'db> ClassLiteral<'db> { }) } + #[salsa::tracked( + cycle_fn=generic_context_cycle_recover, + cycle_initial=generic_context_cycle_initial, + heap_size=ruff_memory_usage::heap_size, + )] pub(crate) fn inherited_legacy_generic_context( self, db: &'db dyn Db, ) -> Option> { GenericContext::from_base_classes( db, + self.definition(db), self.explicit_bases(db) .iter() .copied() @@ -1467,6 +1481,57 @@ impl<'db> ClassLiteral<'db> { ) } + /// Returns all of the typevars that are referenced in this class's definition. This includes + /// any typevars bound in its generic context, as well as any typevars mentioned in its base + /// class list. (This is used to ensure that classes do not bind or reference typevars from + /// enclosing generic contexts.) + pub(crate) fn typevars_referenced_in_definition( + self, + db: &'db dyn Db, + ) -> FxIndexSet> { + #[derive(Default)] + struct CollectTypeVars<'db> { + typevars: RefCell>>, + seen_types: RefCell>>, + } + + impl<'db> TypeVisitor<'db> for CollectTypeVars<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_bound_type_var_type( + &self, + _db: &'db dyn Db, + bound_typevar: BoundTypeVarInstance<'db>, + ) { + self.typevars.borrow_mut().insert(bound_typevar); + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + match TypeKind::from(ty) { + TypeKind::Atomic => {} + TypeKind::NonAtomic(non_atomic_type) => { + if !self.seen_types.borrow_mut().insert(non_atomic_type) { + // If we have already seen this type, we can skip it. + return; + } + walk_non_atomic_type(db, non_atomic_type, self); + } + } + } + } + + let visitor = CollectTypeVars::default(); + if let Some(generic_context) = self.generic_context(db) { + walk_generic_context(db, generic_context, &visitor); + } + for base in self.explicit_bases(db) { + visitor.visit_type(db, *base); + } + visitor.typevars.into_inner() + } + /// Returns the generic context that should be inherited by any constructor methods of this /// class. /// diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs index c3901ecbad..d4ac8fd898 100644 --- a/crates/ty_python_semantic/src/types/context.rs +++ b/crates/ty_python_semantic/src/types/context.rs @@ -100,6 +100,10 @@ impl<'db, 'ast> InferContext<'db, 'ast> { self.diagnostics.get_mut().extend(other); } + pub(super) fn is_lint_enabled(&self, lint: &'static LintMetadata) -> bool { + LintDiagnosticGuardBuilder::severity_and_source(self, lint).is_some() + } + /// Optionally return a builder for a lint diagnostic guard. /// /// If the current context believes a diagnostic should be reported for @@ -396,11 +400,10 @@ pub(super) struct LintDiagnosticGuardBuilder<'db, 'ctx> { } impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> { - fn new( + fn severity_and_source( ctx: &'ctx InferContext<'db, 'ctx>, lint: &'static LintMetadata, - range: TextRange, - ) -> Option> { + ) -> Option<(Severity, LintSource)> { // The comment below was copied from the original // implementation of diagnostic reporting. The code // has been refactored, but this still kind of looked @@ -429,14 +432,25 @@ impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> { if ctx.is_in_multi_inference() { return None; } - let id = DiagnosticId::Lint(lint.name()); + + Some((severity, source)) + } + + fn new( + ctx: &'ctx InferContext<'db, 'ctx>, + lint: &'static LintMetadata, + range: TextRange, + ) -> Option> { + let (severity, source) = Self::severity_and_source(ctx, lint)?; let suppressions = suppressions(ctx.db(), ctx.file()); + let lint_id = LintId::of(lint); if let Some(suppression) = suppressions.find_suppression(range, lint_id) { ctx.diagnostics.borrow_mut().mark_used(suppression.id()); return None; } + let id = DiagnosticId::Lint(lint.name()); let primary_span = Span::from(ctx.file()).with_range(range); Some(LintDiagnosticGuardBuilder { ctx, diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index f208ae3f5d..982c76672d 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -18,10 +18,10 @@ use crate::types::string_annotation::{ RAW_STRING_TYPE_ANNOTATION, }; use crate::types::{ - ClassType, DynamicType, LintDiagnosticGuard, Protocol, ProtocolInstanceType, SubclassOfInner, - TypeContext, binding_type, infer_isolated_expression, + BoundTypeVarInstance, ClassType, DynamicType, LintDiagnosticGuard, Protocol, + ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type, + infer_isolated_expression, protocol_class::ProtocolClass, }; -use crate::types::{SpecialFormType, Type, protocol_class::ProtocolClass}; use crate::util::diagnostics::format_enumeration; use crate::{ Db, DisplaySettings, FxIndexMap, FxOrderMap, Module, ModuleName, Program, declare_lint, @@ -3055,6 +3055,39 @@ pub(crate) fn report_cannot_pop_required_field_on_typed_dict<'db>( } } +pub(crate) fn report_rebound_typevar<'db>( + context: &InferContext<'db, '_>, + typevar_name: &ast::name::Name, + class: ClassLiteral<'db>, + class_node: &ast::StmtClassDef, + other_typevar: BoundTypeVarInstance<'db>, +) { + let db = context.db(); + let Some(builder) = context.report_lint(&INVALID_GENERIC_CLASS, class.header_range(db)) else { + return; + }; + let mut diagnostic = builder.into_diagnostic(format_args!( + "Generic class `{}` must not reference type variables bound in an enclosing scope", + class_node.name, + )); + diagnostic.set_primary_message(format_args!( + "`{typevar_name}` referenced in class definition here" + )); + let Some(other_definition) = other_typevar.binding_context(db).definition() else { + return; + }; + let span = match binding_type(db, other_definition) { + Type::ClassLiteral(class) => Some(class.header_span(db)), + Type::FunctionLiteral(function) => function.spans(db).map(|spans| spans.signature), + _ => return, + }; + if let Some(span) = span { + diagnostic.annotate(Annotation::secondary(span).message(format_args!( + "Type variable `{typevar_name}` is bound in this enclosing scope", + ))); + } +} + /// This function receives an unresolved `from foo import bar` import, /// where `foo` can be resolved to a module but that module does not /// have a `bar` member or submodule. diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 436638ae40..0db16268a3 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -24,7 +24,7 @@ use crate::{Db, FxOrderMap, FxOrderSet}; /// Returns an iterator of any generic context introduced by the given scope or any enclosing /// scope. -fn enclosing_generic_contexts<'db>( +pub(crate) fn enclosing_generic_contexts<'db>( db: &'db dyn Db, index: &SemanticIndex<'db>, scope: FileScopeId, @@ -317,11 +317,12 @@ impl<'db> GenericContext<'db> { /// list. pub(crate) fn from_base_classes( db: &'db dyn Db, + definition: Definition<'db>, bases: impl Iterator>, ) -> Option { let mut variables = FxOrderSet::default(); for base in bases { - base.find_legacy_typevars(db, None, &mut variables); + base.find_legacy_typevars(db, Some(definition), &mut variables); } if variables.is_empty() { return None; @@ -413,6 +414,15 @@ impl<'db> GenericContext<'db> { .all(|bound_typevar| other_variables.contains_key(&bound_typevar)) } + pub(crate) fn binds_named_typevar( + self, + db: &'db dyn Db, + name: &'db ast::name::Name, + ) -> Option> { + self.variables(db) + .find(|self_bound_typevar| self_bound_typevar.typevar(db).name(db) == name) + } + pub(crate) fn binds_typevar( self, db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index fcca0f18e5..bb1898f905 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -53,31 +53,29 @@ use crate::types::diagnostic::{ CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, DUPLICATE_KW_ONLY, INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_DECLARATION, - INVALID_GENERIC_CLASS, INVALID_KEY, INVALID_LEGACY_TYPE_VARIABLE, INVALID_NAMED_TUPLE, - INVALID_PARAMETER_DEFAULT, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, - INVALID_TYPE_VARIABLE_CONSTRAINTS, IncompatibleBases, NON_SUBSCRIPTABLE, - POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, UNDEFINED_REVEAL, - UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, - UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, report_bad_dunder_set_call, - report_cannot_pop_required_field_on_typed_dict, report_implicit_return_type, - report_instance_layout_conflict, report_invalid_assignment, - report_invalid_attribute_assignment, report_invalid_generator_function_return_type, - report_invalid_key_on_typed_dict, report_invalid_return_type, - report_namedtuple_field_without_default_after_field_with_default, - report_possibly_missing_attribute, -}; -use crate::types::diagnostic::{ - INVALID_METACLASS, INVALID_OVERLOAD, INVALID_PROTOCOL, SUBCLASS_OF_FINAL_CLASS, + INVALID_GENERIC_CLASS, INVALID_KEY, INVALID_LEGACY_TYPE_VARIABLE, INVALID_METACLASS, + INVALID_NAMED_TUPLE, INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT, INVALID_PROTOCOL, + INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_CONSTRAINTS, + IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, + SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, + UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, - report_duplicate_bases, report_index_out_of_bounds, report_invalid_exception_caught, + report_bad_dunder_set_call, report_cannot_pop_required_field_on_typed_dict, + report_duplicate_bases, report_implicit_return_type, report_index_out_of_bounds, + report_instance_layout_conflict, report_invalid_assignment, + report_invalid_attribute_assignment, report_invalid_exception_caught, report_invalid_exception_cause, report_invalid_exception_raised, - report_invalid_or_unsupported_base, report_invalid_type_checking_constant, - report_non_subscriptable, report_possibly_unresolved_reference, report_slice_step_size_zero, + report_invalid_generator_function_return_type, report_invalid_key_on_typed_dict, + report_invalid_or_unsupported_base, report_invalid_return_type, + report_invalid_type_checking_constant, + report_namedtuple_field_without_default_after_field_with_default, report_non_subscriptable, + report_possibly_missing_attribute, report_possibly_unresolved_reference, + report_rebound_typevar, report_slice_step_size_zero, }; use crate::types::function::{ FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, }; -use crate::types::generics::{GenericContext, bind_typevar}; +use crate::types::generics::{GenericContext, bind_typevar, enclosing_generic_contexts}; use crate::types::generics::{LegacyGenericBase, SpecializationBuilder}; use crate::types::infer::nearest_enclosing_function; use crate::types::instance::SliceLiteral; @@ -838,6 +836,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + // (6) If the class is generic, verify that its generic context does not violate any of + // the typevar scoping rules. if let (Some(legacy), Some(inherited)) = ( class.legacy_generic_context(self.db()), class.inherited_legacy_generic_context(self.db()), @@ -854,7 +854,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - // (6) Check that a dataclass does not have more than one `KW_ONLY`. + let scope = class.body_scope(self.db()).scope(self.db()); + if self.context.is_lint_enabled(&INVALID_GENERIC_CLASS) + && let Some(parent) = scope.parent() + { + for self_typevar in class.typevars_referenced_in_definition(self.db()) { + let self_typevar_name = self_typevar.typevar(self.db()).name(self.db()); + for enclosing in enclosing_generic_contexts(self.db(), self.index, parent) { + if let Some(other_typevar) = + enclosing.binds_named_typevar(self.db(), self_typevar_name) + { + report_rebound_typevar( + &self.context, + self_typevar_name, + class, + class_node, + other_typevar, + ); + } + } + } + } + + // (7) Check that a dataclass does not have more than one `KW_ONLY`. if let Some(field_policy @ CodeGeneratorKind::DataclassLike(_)) = CodeGeneratorKind::from_class(self.db(), class) { From 5e08e5451df62398caf16034ae50621e46688641 Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Mon, 13 Oct 2025 20:09:27 -0400 Subject: [PATCH 026/113] [ty] Add separate type for typevar "identity" (#20813) As part of #20598, we added `is_identical_to` methods to `TypeVarInstance` and `BoundTypeVarInstance`, which compare when two typevar instances refer to "the same" underlying typevar, even if we have forced their lazy bounds/constraints as part of marking typevars as inferable. (Doing so results in a different salsa interned struct ID, since we've changed the contents of the `bounds_or_constraints` field.) It turns out that marking typevars as inferable is not the only way that we might force lazy bounds/constraints; it also happens when we materialize a type containing a typevar. This surfaced as ecosystem report failures on #20677. That means that we need a more long-term fix to this problem. (`is_identical_to`, and its underlying `original` field, were meant to be a temporary fix until we removed the `MarkTypeVarsInferable` type mapping.) This PR extracts out a separate type (`TypeVarIdentity`) that only includes the fields that actually inform whether two typevars are "the same". All other properties of the typevar (default, bounds/constraints, etc) still live in `TypeVarInstance`. Call sites that care about typevar identity can now either store just `TypeVarIdentity` (if they never need access to those other properties), or continue to store `TypeVarInstance` but pull out its `identity` when performing those "are they the same typevar" comparisons. (All of this also applies respectively to `BoundTypeVar{Identity,Instance}`.) In particular, constraint sets now work on `BoundTypeVarIdentity`, and generic contexts still _store_ a `BoundTypeVarInstance` (since we might need access to defaults when specializing), but are keyed on `BoundTypeVarIdentity`. --- crates/ty_python_semantic/src/types.rs | 191 ++++++++++-------- .../src/types/constraints.rs | 10 +- .../ty_python_semantic/src/types/display.rs | 20 +- .../ty_python_semantic/src/types/function.rs | 5 +- .../ty_python_semantic/src/types/generics.rs | 122 ++++++----- .../src/types/infer/builder.rs | 20 +- 6 files changed, 199 insertions(+), 169 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 67a2486b77..7ca7c2a44a 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1674,7 +1674,7 @@ impl<'db> Type<'db> { ( Type::NonInferableTypeVar(lhs_bound_typevar), Type::NonInferableTypeVar(rhs_bound_typevar), - ) if lhs_bound_typevar.is_identical_to(db, rhs_bound_typevar) => { + ) if lhs_bound_typevar.identity(db) == rhs_bound_typevar.identity(db) => { ConstraintSet::from(true) } @@ -2443,7 +2443,9 @@ impl<'db> Type<'db> { ( Type::NonInferableTypeVar(self_bound_typevar), Type::NonInferableTypeVar(other_bound_typevar), - ) if self_bound_typevar == other_bound_typevar => ConstraintSet::from(false), + ) if self_bound_typevar.identity(db) == other_bound_typevar.identity(db) => { + ConstraintSet::from(false) + } (tvar @ Type::NonInferableTypeVar(_), Type::Intersection(intersection)) | (Type::Intersection(intersection), tvar @ Type::NonInferableTypeVar(_)) @@ -7789,7 +7791,32 @@ pub enum TypeVarKind { TypingSelf, } -/// A type variable that has not been bound to a generic context yet. +/// The identity of a type variable. +/// +/// This represents the core identity of a typevar, independent of its bounds or constraints. Two +/// typevars have the same identity if they represent the same logical typevar, even if their +/// bounds have been materialized differently. +/// +/// # Ordering +/// Ordering is based on the identity's salsa-assigned id and not on its values. +/// The id may change between runs, or when the identity was garbage collected and recreated. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct TypeVarIdentity<'db> { + /// The name of this TypeVar (e.g. `T`) + #[returns(ref)] + pub(crate) name: ast::name::Name, + + /// The type var's definition (None if synthesized) + pub(crate) definition: Option>, + + /// The kind of typevar (PEP 695, Legacy, or TypingSelf) + pub(crate) kind: TypeVarKind, +} + +impl get_size2::GetSize for TypeVarIdentity<'_> {} + +/// A specific instance of a type variable that has not been bound to a generic context yet. /// /// This is usually not the type that you want; if you are working with a typevar, in a generic /// context, which might be specialized to a concrete type, you want [`BoundTypeVarInstance`]. This @@ -7828,12 +7855,8 @@ pub enum TypeVarKind { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] #[derive(PartialOrd, Ord)] pub struct TypeVarInstance<'db> { - /// The name of this TypeVar (e.g. `T`) - #[returns(ref)] - name: ast::name::Name, - - /// The type var's definition (None if synthesized) - pub definition: Option>, + /// The identity of this typevar + pub(crate) identity: TypeVarIdentity<'db>, /// The upper bound or constraint on the type of this TypeVar, if any. Don't use this field /// directly; use the `bound_or_constraints` (or `upper_bound` and `constraints`) methods @@ -7846,14 +7869,6 @@ pub struct TypeVarInstance<'db> { /// The default type for this TypeVar, if any. Don't use this field directly, use the /// `default_type` method instead (to evaluate any lazy default). _default: Option>, - - pub kind: TypeVarKind, - - /// If this typevar was transformed from another typevar via `mark_typevars_inferable`, this - /// records the identity of the "original" typevar, so we can recognize them as the same - /// typevar in `bind_typevar`. TODO: this (and the `is_identical_to` methods) should be - /// removable once we remove `mark_typevars_inferable`. - pub(crate) original: Option>, } // The Salsa heap is tracked separately. @@ -7899,6 +7914,18 @@ impl<'db> TypeVarInstance<'db> { BoundTypeVarInstance::new(db, self, BindingContext::Definition(binding_context)) } + pub(crate) fn name(self, db: &'db dyn Db) -> &'db ast::name::Name { + self.identity(db).name(db) + } + + pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { + self.identity(db).definition(db) + } + + pub(crate) fn kind(self, db: &'db dyn Db) -> TypeVarKind { + self.identity(db).kind(db) + } + pub(crate) fn is_self(self, db: &'db dyn Db) -> bool { matches!(self.kind(db), TypeVarKind::TypingSelf) } @@ -7942,8 +7969,7 @@ impl<'db> TypeVarInstance<'db> { pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { Self::new( db, - self.name(db), - self.definition(db), + self.identity(db), self._bound_or_constraints(db) .and_then(|bound_or_constraints| match bound_or_constraints { TypeVarBoundOrConstraintsEvaluation::Eager(bound_or_constraints) => { @@ -7963,8 +7989,6 @@ impl<'db> TypeVarInstance<'db> { .lazy_default(db) .map(|ty| ty.normalized_impl(db, visitor).into()), }), - self.kind(db), - self.original(db), ) } @@ -7976,8 +8000,7 @@ impl<'db> TypeVarInstance<'db> { ) -> Self { Self::new( db, - self.name(db), - self.definition(db), + self.identity(db), self._bound_or_constraints(db) .and_then(|bound_or_constraints| match bound_or_constraints { TypeVarBoundOrConstraintsEvaluation::Eager(bound_or_constraints) => Some( @@ -8009,8 +8032,6 @@ impl<'db> TypeVarInstance<'db> { .lazy_default(db) .map(|ty| ty.materialize(db, materialization_kind, visitor).into()), }), - self.kind(db), - self.original(db), ) } @@ -8047,33 +8068,15 @@ impl<'db> TypeVarInstance<'db> { }), }); - // Ensure that we only modify the `original` field if we are going to modify one or both of - // `_bound_or_constraints` and `_default`; don't trigger creation of a new - // `TypeVarInstance` unnecessarily. - let new_original = if new_bound_or_constraints == self._bound_or_constraints(db) - && new_default == self._default(db) - { - self.original(db) - } else { - Some(self) - }; - Self::new( db, - self.name(db), - self.definition(db), + self.identity(db), new_bound_or_constraints, self.explicit_variance(db), new_default, - self.kind(db), - new_original, ) } - fn is_identical_to(self, db: &'db dyn Db, other: Self) -> bool { - self == other || (self.original(db) == Some(other) || other.original(db) == Some(self)) - } - fn to_instance(self, db: &'db dyn Db) -> Option { let bound_or_constraints = match self.bound_or_constraints(db)? { TypeVarBoundOrConstraints::UpperBound(upper_bound) => { @@ -8083,15 +8086,18 @@ impl<'db> TypeVarInstance<'db> { TypeVarBoundOrConstraints::Constraints(constraints.to_instance(db)?.into_union()?) } }; - Some(Self::new( + let identity = TypeVarIdentity::new( db, Name::new(format!("{}'instance", self.name(db))), - None, + None, // definition + self.kind(db), + ); + Some(Self::new( + db, + identity, Some(bound_or_constraints.into()), self.explicit_variance(db), - None, - self.kind(db), - self.original(db), + None, // _default )) } @@ -8222,6 +8228,18 @@ impl<'db> BindingContext<'db> { } } +/// The identity of a bound type variable. +/// +/// This identifies a specific binding of a typevar to a context (e.g., `T@ClassC` vs `T@FunctionF`), +/// independent of the typevar's bounds or constraints. Two bound typevars have the same identity +/// if they represent the same logical typevar bound in the same context, even if their bounds +/// have been materialized differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] +pub struct BoundTypeVarIdentity<'db> { + pub(crate) identity: TypeVarIdentity<'db>, + pub(crate) binding_context: BindingContext<'db>, +} + /// A type variable that has been bound to a generic context, and which can be specialized to a /// concrete type. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] @@ -8235,6 +8253,17 @@ pub struct BoundTypeVarInstance<'db> { impl get_size2::GetSize for BoundTypeVarInstance<'_> {} impl<'db> BoundTypeVarInstance<'db> { + /// Get the identity of this bound typevar. + /// + /// This is used for comparing whether two bound typevars represent the same logical typevar, + /// regardless of e.g. differences in their bounds or constraints due to materialization. + pub(crate) fn identity(self, db: &'db dyn Db) -> BoundTypeVarIdentity<'db> { + BoundTypeVarIdentity { + identity: self.typevar(db).identity(db), + binding_context: self.binding_context(db), + } + } + /// Create a new PEP 695 type variable that can be used in signatures /// of synthetic generic functions. pub(crate) fn synthetic( @@ -8242,20 +8271,20 @@ impl<'db> BoundTypeVarInstance<'db> { name: &'static str, variance: TypeVarVariance, ) -> Self { - Self::new( + let identity = TypeVarIdentity::new( db, - TypeVarInstance::new( - db, - Name::new_static(name), - None, // definition - None, // _bound_or_constraints - Some(variance), - None, // _default - TypeVarKind::Pep695, - None, - ), - BindingContext::Synthetic, - ) + Name::new_static(name), + None, // definition + TypeVarKind::Pep695, + ); + let typevar = TypeVarInstance::new( + db, + identity, + None, // _bound_or_constraints + Some(variance), + None, // _default + ); + Self::new(db, typevar, BindingContext::Synthetic) } /// Create a new synthetic `Self` type variable with the given upper bound. @@ -8264,32 +8293,20 @@ impl<'db> BoundTypeVarInstance<'db> { upper_bound: Type<'db>, binding_context: BindingContext<'db>, ) -> Self { - Self::new( + let identity = TypeVarIdentity::new( db, - TypeVarInstance::new( - db, - Name::new_static("Self"), - None, - Some(TypeVarBoundOrConstraints::UpperBound(upper_bound).into()), - Some(TypeVarVariance::Invariant), - None, - TypeVarKind::TypingSelf, - None, - ), - binding_context, - ) - } - - pub(crate) fn is_identical_to(self, db: &'db dyn Db, other: Self) -> bool { - if self == other { - return true; - } - - if self.binding_context(db) != other.binding_context(db) { - return false; - } - - self.typevar(db).is_identical_to(db, other.typevar(db)) + Name::new_static("Self"), + None, // definition + TypeVarKind::TypingSelf, + ); + let typevar = TypeVarInstance::new( + db, + identity, + Some(TypeVarBoundOrConstraints::UpperBound(upper_bound).into()), + Some(TypeVarVariance::Invariant), + None, // _default + ); + Self::new(db, typevar, binding_context) } pub(crate) fn variance_with_polarity( diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 1db37c3e56..3d2b23c09f 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -60,7 +60,7 @@ use itertools::Itertools; use rustc_hash::FxHashSet; use crate::Db; -use crate::types::{BoundTypeVarInstance, IntersectionType, Type, UnionType}; +use crate::types::{BoundTypeVarIdentity, IntersectionType, Type, UnionType}; /// An extension trait for building constraint sets from [`Option`] values. pub(crate) trait OptionConstraintsExtension { @@ -223,7 +223,7 @@ impl<'db> ConstraintSet<'db> { pub(crate) fn range( db: &'db dyn Db, lower: Type<'db>, - typevar: BoundTypeVarInstance<'db>, + typevar: BoundTypeVarIdentity<'db>, upper: Type<'db>, ) -> Self { let lower = lower.bottom_materialization(db); @@ -236,7 +236,7 @@ impl<'db> ConstraintSet<'db> { pub(crate) fn negated_range( db: &'db dyn Db, lower: Type<'db>, - typevar: BoundTypeVarInstance<'db>, + typevar: BoundTypeVarIdentity<'db>, upper: Type<'db>, ) -> Self { Self::range(db, lower, typevar, upper).negate(db) @@ -258,7 +258,7 @@ impl From for ConstraintSet<'_> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] #[derive(PartialOrd, Ord)] pub(crate) struct ConstrainedTypeVar<'db> { - typevar: BoundTypeVarInstance<'db>, + typevar: BoundTypeVarIdentity<'db>, lower: Type<'db>, upper: Type<'db>, } @@ -274,7 +274,7 @@ impl<'db> ConstrainedTypeVar<'db> { fn new_node( db: &'db dyn Db, lower: Type<'db>, - typevar: BoundTypeVarInstance<'db>, + typevar: BoundTypeVarIdentity<'db>, upper: Type<'db>, ) -> Node<'db> { debug_assert_eq!(lower, lower.bottom_materialization(db)); diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 0a25401fe9..debde1a54c 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -24,7 +24,7 @@ use crate::types::signatures::{CallableSignature, Parameter, Parameters, Signatu use crate::types::tuple::TupleSpec; use crate::types::visitor::TypeVisitor; use crate::types::{ - BoundTypeVarInstance, CallableType, IntersectionType, KnownBoundMethodType, KnownClass, + BoundTypeVarIdentity, CallableType, IntersectionType, KnownBoundMethodType, KnownClass, MaterializationKind, Protocol, ProtocolInstanceType, StringLiteralType, SubclassOfInner, Type, UnionType, WrapperDescriptorKind, visitor, }; @@ -561,7 +561,7 @@ impl Display for DisplayRepresentation<'_> { literal_name = enum_literal.name(self.db) ), Type::NonInferableTypeVar(bound_typevar) | Type::TypeVar(bound_typevar) => { - bound_typevar.display(self.db).fmt(f) + bound_typevar.identity(self.db).display(self.db).fmt(f) } Type::AlwaysTruthy => f.write_str("AlwaysTruthy"), Type::AlwaysFalsy => f.write_str("AlwaysFalsy"), @@ -598,24 +598,24 @@ impl Display for DisplayRepresentation<'_> { } } -impl<'db> BoundTypeVarInstance<'db> { +impl<'db> BoundTypeVarIdentity<'db> { pub(crate) fn display(self, db: &'db dyn Db) -> impl Display { - DisplayBoundTypeVarInstance { - bound_typevar: self, + DisplayBoundTypeVarIdentity { + bound_typevar_identity: self, db, } } } -struct DisplayBoundTypeVarInstance<'db> { - bound_typevar: BoundTypeVarInstance<'db>, +struct DisplayBoundTypeVarIdentity<'db> { + bound_typevar_identity: BoundTypeVarIdentity<'db>, db: &'db dyn Db, } -impl Display for DisplayBoundTypeVarInstance<'_> { +impl Display for DisplayBoundTypeVarIdentity<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.write_str(self.bound_typevar.typevar(self.db).name(self.db))?; - if let Some(binding_context) = self.bound_typevar.binding_context(self.db).name(self.db) { + f.write_str(self.bound_typevar_identity.identity.name(self.db))?; + if let Some(binding_context) = self.bound_typevar_identity.binding_context.name(self.db) { write!(f, "@{binding_context}")?; } Ok(()) diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 027642f542..f7cce4e03a 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1730,7 +1730,7 @@ impl KnownFunction { return; }; - let constraints = ConstraintSet::range(db, *lower, *typevar, *upper); + let constraints = ConstraintSet::range(db, *lower, typevar.identity(db), *upper); let tracked = TrackedConstraintSet::new(db, constraints); overload.set_return_type(Type::KnownInstance(KnownInstanceType::ConstraintSet( tracked, @@ -1747,7 +1747,8 @@ impl KnownFunction { return; }; - let constraints = ConstraintSet::negated_range(db, *lower, *typevar, *upper); + let constraints = + ConstraintSet::negated_range(db, *lower, typevar.identity(db), *upper); let tracked = TrackedConstraintSet::new(db, constraints); overload.set_return_type(Type::KnownInstance(KnownInstanceType::ConstraintSet( tracked, diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 0db16268a3..afb3cc45ce 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -14,11 +14,11 @@ use crate::types::instance::{Protocol, ProtocolInstanceType}; use crate::types::signatures::{Parameter, Parameters, Signature}; use crate::types::tuple::{TupleSpec, TupleType, walk_tuple_type}; use crate::types::{ - ApplyTypeMappingVisitor, BoundTypeVarInstance, ClassLiteral, FindLegacyTypeVarsVisitor, - HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, KnownClass, KnownInstanceType, - MaterializationKind, NormalizedVisitor, Type, TypeContext, TypeMapping, TypeRelation, - TypeVarBoundOrConstraints, TypeVarInstance, TypeVarKind, TypeVarVariance, UnionType, - binding_type, declaration_type, + ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, ClassLiteral, + FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, + KnownClass, KnownInstanceType, MaterializationKind, NormalizedVisitor, Type, TypeContext, + TypeMapping, TypeRelation, TypeVarBoundOrConstraints, TypeVarIdentity, TypeVarInstance, + TypeVarKind, TypeVarVariance, UnionType, binding_type, declaration_type, }; use crate::{Db, FxOrderMap, FxOrderSet}; @@ -109,24 +109,25 @@ pub(crate) fn typing_self<'db>( ) -> Option> { let index = semantic_index(db, scope_id.file(db)); - let typevar = TypeVarInstance::new( + let identity = TypeVarIdentity::new( db, ast::name::Name::new_static("Self"), Some(class.definition(db)), - Some( - TypeVarBoundOrConstraints::UpperBound(Type::instance( - db, - class.identity_specialization(db, typevar_to_type), - )) - .into(), - ), + TypeVarKind::TypingSelf, + ); + let bounds = TypeVarBoundOrConstraints::UpperBound(Type::instance( + db, + class.identity_specialization(db, typevar_to_type), + )); + let typevar = TypeVarInstance::new( + db, + identity, + Some(bounds.into()), // According to the [spec], we can consider `Self` // equivalent to an invariant type variable // [spec]: https://typing.python.org/en/latest/spec/generics.html#self Some(TypeVarVariance::Invariant), None, - TypeVarKind::TypingSelf, - None, ); bind_typevar( @@ -139,12 +140,20 @@ pub(crate) fn typing_self<'db>( .map(typevar_to_type) } -#[derive(Copy, Clone, Debug, Default, Eq, Hash, PartialEq, get_size2::GetSize)] -pub struct GenericContextTypeVarOptions { +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] +pub struct GenericContextTypeVar<'db> { + bound_typevar: BoundTypeVarInstance<'db>, should_promote_literals: bool, } -impl GenericContextTypeVarOptions { +impl<'db> GenericContextTypeVar<'db> { + fn new(bound_typevar: BoundTypeVarInstance<'db>) -> Self { + Self { + bound_typevar, + should_promote_literals: false, + } + } + fn promote_literals(mut self) -> Self { self.should_promote_literals = true; self @@ -160,7 +169,7 @@ impl GenericContextTypeVarOptions { #[derive(PartialOrd, Ord)] pub struct GenericContext<'db> { #[returns(ref)] - variables_inner: FxOrderMap, GenericContextTypeVarOptions>, + variables_inner: FxOrderMap, GenericContextTypeVar<'db>>, } pub(super) fn walk_generic_context<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( @@ -179,9 +188,15 @@ impl get_size2::GetSize for GenericContext<'_> {} impl<'db> GenericContext<'db> { fn from_variables( db: &'db dyn Db, - variables: impl IntoIterator, GenericContextTypeVarOptions)>, + variables: impl IntoIterator>, ) -> Self { - Self::new_internal(db, variables.into_iter().collect::>()) + Self::new_internal( + db, + variables + .into_iter() + .map(|variable| (variable.bound_typevar.identity(db), variable)) + .collect::>(), + ) } /// Creates a generic context from a list of PEP-695 type parameters. @@ -203,12 +218,7 @@ impl<'db> GenericContext<'db> { db: &'db dyn Db, type_params: impl IntoIterator>, ) -> Self { - Self::from_variables( - db, - type_params - .into_iter() - .map(|bound_typevar| (bound_typevar, GenericContextTypeVarOptions::default())), - ) + Self::from_variables(db, type_params.into_iter().map(GenericContextTypeVar::new)) } /// Returns a copy of this generic context where we will promote literal types in any inferred @@ -217,8 +227,8 @@ impl<'db> GenericContext<'db> { Self::from_variables( db, self.variables_inner(db) - .iter() - .map(|(bound_typevar, options)| (*bound_typevar, options.promote_literals())), + .values() + .map(|variable| variable.promote_literals()), ) } @@ -228,9 +238,9 @@ impl<'db> GenericContext<'db> { Self::from_variables( db, self.variables_inner(db) - .iter() - .chain(other.variables_inner(db).iter()) - .map(|(bound_typevar, options)| (*bound_typevar, *options)), + .values() + .chain(other.variables_inner(db).values()) + .copied(), ) } @@ -238,7 +248,9 @@ impl<'db> GenericContext<'db> { self, db: &'db dyn Db, ) -> impl ExactSizeIterator> + Clone { - self.variables_inner(db).keys().copied() + self.variables_inner(db) + .values() + .map(|variable| variable.bound_typevar) } fn variable_from_type_param( @@ -411,7 +423,7 @@ impl<'db> GenericContext<'db> { pub(crate) fn is_subset_of(self, db: &'db dyn Db, other: GenericContext<'db>) -> bool { let other_variables = other.variables_inner(db); self.variables(db) - .all(|bound_typevar| other_variables.contains_key(&bound_typevar)) + .all(|bound_typevar| other_variables.contains_key(&bound_typevar.identity(db))) } pub(crate) fn binds_named_typevar( @@ -428,8 +440,9 @@ impl<'db> GenericContext<'db> { db: &'db dyn Db, typevar: TypeVarInstance<'db>, ) -> Option> { - self.variables(db) - .find(|self_bound_typevar| self_bound_typevar.typevar(db).is_identical_to(db, typevar)) + self.variables(db).find(|self_bound_typevar| { + self_bound_typevar.typevar(db).identity(db) == typevar.identity(db) + }) } /// Creates a specialization of this generic context. Panics if the length of `types` does not @@ -513,7 +526,7 @@ impl<'db> GenericContext<'db> { } fn heap_size( - (variables,): &(FxOrderMap, GenericContextTypeVarOptions>,), + (variables,): &(FxOrderMap, GenericContextTypeVar<'db>>,), ) -> usize { ruff_memory_usage::order_map_heap_size(variables) } @@ -765,7 +778,7 @@ impl<'db> Specialization<'db> { let restricted_variables = generic_context.variables(db); let restricted_types: Option> = restricted_variables .map(|variable| { - let index = self_variables.get_index_of(&variable)?; + let index = self_variables.get_index_of(&variable.identity(db))?; self_types.get(index).copied() }) .collect(); @@ -793,7 +806,7 @@ impl<'db> Specialization<'db> { let index = self .generic_context(db) .variables_inner(db) - .get_index_of(&bound_typevar)?; + .get_index_of(&bound_typevar.identity(db))?; self.types(db).get(index).copied() } @@ -1146,7 +1159,7 @@ impl<'db> PartialSpecialization<'_, 'db> { let index = self .generic_context .variables_inner(db) - .get_index_of(&bound_typevar)?; + .get_index_of(&bound_typevar.identity(db))?; self.types.get(index).copied() } } @@ -1155,7 +1168,7 @@ impl<'db> PartialSpecialization<'_, 'db> { /// specialization of a generic function. pub(crate) struct SpecializationBuilder<'db> { db: &'db dyn Db, - types: FxHashMap, Type<'db>>, + types: FxHashMap, Type<'db>>, } impl<'db> SpecializationBuilder<'db> { @@ -1175,20 +1188,21 @@ impl<'db> SpecializationBuilder<'db> { .annotation .and_then(|annotation| annotation.specialization_of(self.db, None)); - let types = (generic_context.variables_inner(self.db).iter()).map(|(variable, options)| { - let mut ty = self.types.get(variable).copied(); + let types = + (generic_context.variables_inner(self.db).iter()).map(|(identity, variable)| { + let mut ty = self.types.get(identity).copied(); - // When inferring a specialization for a generic class typevar from a constructor call, - // promote any typevars that are inferred as a literal to the corresponding instance type. - if options.should_promote_literals { - let tcx = tcx_specialization - .and_then(|specialization| specialization.get(self.db, *variable)); + // When inferring a specialization for a generic class typevar from a constructor call, + // promote any typevars that are inferred as a literal to the corresponding instance type. + if variable.should_promote_literals { + let tcx = tcx_specialization.and_then(|specialization| { + specialization.get(self.db, variable.bound_typevar) + }); + ty = ty.map(|ty| ty.promote_literals(self.db, TypeContext::new(tcx))); + } - ty = ty.map(|ty| ty.promote_literals(self.db, TypeContext::new(tcx))); - } - - ty - }); + ty + }); // TODO Infer the tuple spec for a tuple type generic_context.specialize_partial(self.db, types) @@ -1196,7 +1210,7 @@ impl<'db> SpecializationBuilder<'db> { fn add_type_mapping(&mut self, bound_typevar: BoundTypeVarInstance<'db>, ty: Type<'db>) { self.types - .entry(bound_typevar) + .entry(bound_typevar.identity(self.db)) .and_modify(|existing| { *existing = UnionType::from_elements(self.db, [*existing, ty]); }) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index bb1898f905..538d170ae6 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -94,8 +94,9 @@ use crate::types::{ MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, Parameter, ParameterForm, Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet, Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, - TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarInstance, TypeVarKind, - TypeVarVariance, TypedDictType, UnionBuilder, UnionType, binding_type, todo_type, + TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity, + TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder, UnionType, + binding_type, todo_type, }; use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic}; use crate::unpack::{EvaluationMode, UnpackPosition}; @@ -3001,15 +3002,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if bound_or_constraint.is_some() || default.is_some() { self.deferred.insert(definition); } + let identity = + TypeVarIdentity::new(self.db(), &name.id, Some(definition), TypeVarKind::Pep695); let ty = Type::KnownInstance(KnownInstanceType::TypeVar(TypeVarInstance::new( self.db(), - &name.id, - Some(definition), + identity, bound_or_constraint, - None, + None, // explicit_variance default.as_deref().map(|_| TypeVarDefaultEvaluation::Lazy), - TypeVarKind::Pep695, - None, ))); self.add_declaration_with_binding( node.into(), @@ -4340,15 +4340,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred.insert(definition); } + let identity = TypeVarIdentity::new(db, target_name, Some(definition), TypeVarKind::Legacy); Type::KnownInstance(KnownInstanceType::TypeVar(TypeVarInstance::new( db, - target_name, - Some(definition), + identity, bound_or_constraints, Some(variance), default, - TypeVarKind::Legacy, - None, ))) } From e338d2095e1f191bec41fa9234511357ed5d17fa Mon Sep 17 00:00:00 2001 From: Matt Norton Date: Tue, 14 Oct 2025 07:43:24 +0100 Subject: [PATCH 027/113] Update `lint.flake8-type-checking.quoted-annotations` docs (#20765) Co-authored-by: Micha Reiser --- crates/ruff_workspace/src/options.rs | 3 ++- ruff.schema.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 21fc2269eb..0260b5f9e5 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -2168,7 +2168,8 @@ pub struct Flake8TypeCheckingOptions { /// /// Note that this setting has no effect when `from __future__ import annotations` /// is present, as `__future__` annotations are always treated equivalently - /// to quoted annotations. + /// to quoted annotations. Similarly, this setting has no effect on Python + /// versions after 3.14 because these annotations are also deferred. #[option( default = "false", value_type = "bool", diff --git a/ruff.schema.json b/ruff.schema.json index 684d9e56ee..33ccab9364 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -1474,7 +1474,7 @@ } }, "quote-annotations": { - "description": "Whether to add quotes around type annotations, if doing so would allow the corresponding import to be moved into a type-checking block.\n\nFor example, in the following, Python requires that `Sequence` be available at runtime, despite the fact that it's only used in a type annotation:\n\n```python from collections.abc import Sequence\n\ndef func(value: Sequence[int]) -> None: ... ```\n\nIn other words, moving `from collections.abc import Sequence` into an `if TYPE_CHECKING:` block above would cause a runtime error, as the type would no longer be available at runtime.\n\nBy default, Ruff will respect such runtime semantics and avoid moving the import to prevent such runtime errors.\n\nSetting `quote-annotations` to `true` will instruct Ruff to add quotes around the annotation (e.g., `\"Sequence[int]\"`), which in turn enables Ruff to move the import into an `if TYPE_CHECKING:` block, like so:\n\n```python from typing import TYPE_CHECKING\n\nif TYPE_CHECKING: from collections.abc import Sequence\n\ndef func(value: \"Sequence[int]\") -> None: ... ```\n\nNote that this setting has no effect when `from __future__ import annotations` is present, as `__future__` annotations are always treated equivalently to quoted annotations.", + "description": "Whether to add quotes around type annotations, if doing so would allow the corresponding import to be moved into a type-checking block.\n\nFor example, in the following, Python requires that `Sequence` be available at runtime, despite the fact that it's only used in a type annotation:\n\n```python from collections.abc import Sequence\n\ndef func(value: Sequence[int]) -> None: ... ```\n\nIn other words, moving `from collections.abc import Sequence` into an `if TYPE_CHECKING:` block above would cause a runtime error, as the type would no longer be available at runtime.\n\nBy default, Ruff will respect such runtime semantics and avoid moving the import to prevent such runtime errors.\n\nSetting `quote-annotations` to `true` will instruct Ruff to add quotes around the annotation (e.g., `\"Sequence[int]\"`), which in turn enables Ruff to move the import into an `if TYPE_CHECKING:` block, like so:\n\n```python from typing import TYPE_CHECKING\n\nif TYPE_CHECKING: from collections.abc import Sequence\n\ndef func(value: \"Sequence[int]\") -> None: ... ```\n\nNote that this setting has no effect when `from __future__ import annotations` is present, as `__future__` annotations are always treated equivalently to quoted annotations. Similarly, this setting has no effect on Python versions after 3.14 because these annotations are also deferred.", "type": [ "boolean", "null" From f73bb45be681a17b90fb6239b47b054d2c8b026b Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 14 Oct 2025 09:53:29 +0200 Subject: [PATCH 028/113] [ty] Rename Type unwrapping methods (#20857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Rename "unwrapping" methods on `Type` from e.g. `Type::into_class_literal` to `Type::as_class_literal`. I personally find that name more intuitive, since no transformation of any kind is happening. We are just unwrapping from certain enum variants. An alternative would be `try_as_class_literal`, which would follow the [`strum` naming scheme](https://docs.rs/strum/latest/strum/derive.EnumTryAs.html), but is slightly longer. Also rename `Type::into_callable` to `Type::try_upcast_to_callable`. Note that I intentionally kept names like `FunctionType::into_callable_type`, because those return `CallableType`, not `Option>`. ## Test Plan Pure refactoring --- crates/ty_python_semantic/src/place.rs | 2 +- .../reachability_constraints.rs | 2 +- crates/ty_python_semantic/src/types.rs | 63 +++++++++---------- .../ty_python_semantic/src/types/builder.rs | 4 +- .../ty_python_semantic/src/types/call/bind.rs | 8 +-- crates/ty_python_semantic/src/types/class.rs | 10 +-- .../ty_python_semantic/src/types/context.rs | 2 +- .../ty_python_semantic/src/types/function.rs | 4 +- .../ty_python_semantic/src/types/generics.rs | 12 ++-- .../src/types/ide_support.rs | 4 +- crates/ty_python_semantic/src/types/infer.rs | 4 +- .../src/types/infer/builder.rs | 37 +++++------ .../types/infer/builder/type_expression.rs | 2 +- .../ty_python_semantic/src/types/instance.rs | 2 +- crates/ty_python_semantic/src/types/narrow.rs | 8 +-- .../src/types/protocol_class.rs | 2 +- .../src/types/signatures.rs | 2 +- 17 files changed, 81 insertions(+), 87 deletions(-) diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 7e8820cb77..6423ccc7e7 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1447,7 +1447,7 @@ mod implicit_globals { fn module_type_symbols<'db>(db: &'db dyn Db) -> smallvec::SmallVec<[ast::name::Name; 8]> { let Some(module_type) = KnownClass::ModuleType .to_class_literal(db) - .into_class_literal() + .as_class_literal() else { // The most likely way we get here is if a user specified a `--custom-typeshed-dir` // without a `types.pyi` stub in the `stdlib/` directory diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 2ef1fe4c6a..67b266b3ff 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -854,7 +854,7 @@ impl ReachabilityConstraints { } let overloads_iterator = - if let Some(Type::Callable(callable)) = ty.into_callable(db) { + if let Some(Type::Callable(callable)) = ty.try_upcast_to_callable(db) { callable.signatures(db).overloads.iter() } else { return Truthiness::AlwaysFalse.negate_if(!predicate.is_positive); diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 7ca7c2a44a..32bcc7aa63 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -982,39 +982,39 @@ impl<'db> Type<'db> { matches!(self, Type::TypeVar(_)) } - pub(crate) const fn into_type_var(self) -> Option> { + pub(crate) const fn as_typevar(self) -> Option> { match self { Type::TypeVar(bound_typevar) => Some(bound_typevar), _ => None, } } - pub(crate) fn has_type_var(self, db: &'db dyn Db) -> bool { + pub(crate) fn has_typevar(self, db: &'db dyn Db) -> bool { any_over_type(db, self, &|ty| matches!(ty, Type::TypeVar(_)), false) } - pub(crate) const fn into_class_literal(self) -> Option> { + pub(crate) const fn as_class_literal(self) -> Option> { match self { Type::ClassLiteral(class_type) => Some(class_type), _ => None, } } - pub(crate) const fn into_type_alias(self) -> Option> { + pub(crate) const fn as_type_alias(self) -> Option> { match self { Type::KnownInstance(KnownInstanceType::TypeAliasType(type_alias)) => Some(type_alias), _ => None, } } - pub(crate) const fn into_dynamic(self) -> Option> { + pub(crate) const fn as_dynamic(self) -> Option> { match self { Type::Dynamic(dynamic_type) => Some(dynamic_type), _ => None, } } - pub(crate) const fn unwrap_as_callable_type(self) -> Option> { + pub(crate) const fn as_callable(self) -> Option> { match self { Type::Callable(callable_type) => Some(callable_type), _ => None, @@ -1022,11 +1022,10 @@ impl<'db> Type<'db> { } pub(crate) const fn expect_dynamic(self) -> DynamicType<'db> { - self.into_dynamic() - .expect("Expected a Type::Dynamic variant") + self.as_dynamic().expect("Expected a Type::Dynamic variant") } - pub(crate) const fn into_protocol_instance(self) -> Option> { + pub(crate) const fn as_protocol_instance(self) -> Option> { match self { Type::ProtocolInstance(instance) => Some(instance), _ => None, @@ -1035,7 +1034,7 @@ impl<'db> Type<'db> { #[track_caller] pub(crate) fn expect_class_literal(self) -> ClassLiteral<'db> { - self.into_class_literal() + self.as_class_literal() .expect("Expected a Type::ClassLiteral variant") } @@ -1048,7 +1047,7 @@ impl<'db> Type<'db> { matches!(self, Type::ClassLiteral(..)) } - pub(crate) fn into_enum_literal(self) -> Option> { + pub(crate) fn as_enum_literal(self) -> Option> { match self { Type::EnumLiteral(enum_literal) => Some(enum_literal), _ => None, @@ -1058,7 +1057,7 @@ impl<'db> Type<'db> { #[cfg(test)] #[track_caller] pub(crate) fn expect_enum_literal(self) -> EnumLiteralType<'db> { - self.into_enum_literal() + self.as_enum_literal() .expect("Expected a Type::EnumLiteral variant") } @@ -1066,7 +1065,7 @@ impl<'db> Type<'db> { matches!(self, Type::TypedDict(..)) } - pub(crate) fn into_typed_dict(self) -> Option> { + pub(crate) fn as_typed_dict(self) -> Option> { match self { Type::TypedDict(typed_dict) => Some(typed_dict), _ => None, @@ -1100,14 +1099,14 @@ impl<'db> Type<'db> { )) } - pub(crate) const fn into_module_literal(self) -> Option> { + pub(crate) const fn as_module_literal(self) -> Option> { match self { Type::ModuleLiteral(module) => Some(module), _ => None, } } - pub(crate) const fn into_union(self) -> Option> { + pub(crate) const fn as_union(self) -> Option> { match self { Type::Union(union_type) => Some(union_type), _ => None, @@ -1117,10 +1116,10 @@ impl<'db> Type<'db> { #[cfg(test)] #[track_caller] pub(crate) fn expect_union(self) -> UnionType<'db> { - self.into_union().expect("Expected a Type::Union variant") + self.as_union().expect("Expected a Type::Union variant") } - pub(crate) const fn into_function_literal(self) -> Option> { + pub(crate) const fn as_function_literal(self) -> Option> { match self { Type::FunctionLiteral(function_type) => Some(function_type), _ => None, @@ -1130,7 +1129,7 @@ impl<'db> Type<'db> { #[cfg(test)] #[track_caller] pub(crate) fn expect_function_literal(self) -> FunctionType<'db> { - self.into_function_literal() + self.as_function_literal() .expect("Expected a Type::FunctionLiteral variant") } @@ -1139,7 +1138,7 @@ impl<'db> Type<'db> { } pub(crate) fn is_union_of_single_valued(&self, db: &'db dyn Db) -> bool { - self.into_union().is_some_and(|union| { + self.as_union().is_some_and(|union| { union.elements(db).iter().all(|ty| { ty.is_single_valued(db) || ty.is_bool(db) @@ -1152,7 +1151,7 @@ impl<'db> Type<'db> { } pub(crate) fn is_union_with_single_valued(&self, db: &'db dyn Db) -> bool { - self.into_union().is_some_and(|union| { + self.as_union().is_some_and(|union| { union.elements(db).iter().any(|ty| { ty.is_single_valued(db) || ty.is_bool(db) @@ -1164,7 +1163,7 @@ impl<'db> Type<'db> { || (self.is_enum(db) && !self.overrides_equality(db)) } - pub(crate) fn into_string_literal(self) -> Option> { + pub(crate) fn as_string_literal(self) -> Option> { match self { Type::StringLiteral(string_literal) => Some(string_literal), _ => None, @@ -1404,7 +1403,7 @@ impl<'db> Type<'db> { } } - pub(crate) fn into_callable(self, db: &'db dyn Db) -> Option> { + pub(crate) fn try_upcast_to_callable(self, db: &'db dyn Db) -> Option> { match self { Type::Callable(_) => Some(self), @@ -1427,7 +1426,7 @@ impl<'db> Type<'db> { .place; if let Place::Type(ty, Boundness::Bound) = call_symbol { - ty.into_callable(db) + ty.try_upcast_to_callable(db) } else { None } @@ -1447,13 +1446,13 @@ impl<'db> Type<'db> { )), }, - Type::Union(union) => union.try_map(db, |element| element.into_callable(db)), + Type::Union(union) => union.try_map(db, |element| element.try_upcast_to_callable(db)), - Type::EnumLiteral(enum_literal) => { - enum_literal.enum_class_instance(db).into_callable(db) - } + Type::EnumLiteral(enum_literal) => enum_literal + .enum_class_instance(db) + .try_upcast_to_callable(db), - Type::TypeAlias(alias) => alias.value_type(db).into_callable(db), + Type::TypeAlias(alias) => alias.value_type(db).try_upcast_to_callable(db), Type::KnownBoundMethod(method) => Some(Type::Callable(CallableType::new( db, @@ -1943,7 +1942,7 @@ impl<'db> Type<'db> { }), (_, Type::Callable(_)) => relation_visitor.visit((self, target, relation), || { - self.into_callable(db).when_some_and(|callable| { + self.try_upcast_to_callable(db).when_some_and(|callable| { callable.has_relation_to_impl( db, target, @@ -8083,7 +8082,7 @@ impl<'db> TypeVarInstance<'db> { TypeVarBoundOrConstraints::UpperBound(upper_bound.to_instance(db)?) } TypeVarBoundOrConstraints::Constraints(constraints) => { - TypeVarBoundOrConstraints::Constraints(constraints.to_instance(db)?.into_union()?) + TypeVarBoundOrConstraints::Constraints(constraints.to_instance(db)?.as_union()?) } }; let identity = TypeVarIdentity::new( @@ -8131,7 +8130,7 @@ impl<'db> TypeVarInstance<'db> { DefinitionKind::TypeVar(typevar) => { let typevar_node = typevar.node(&module); definition_expression_type(db, definition, typevar_node.bound.as_ref()?) - .into_union()? + .as_union()? } // legacy typevar DefinitionKind::Assignment(assignment) => { @@ -10713,7 +10712,7 @@ impl<'db> TypeAliasType<'db> { } } - pub(crate) fn into_pep_695_type_alias(self) -> Option> { + pub(crate) fn as_pep_695_type_alias(self) -> Option> { match self { TypeAliasType::PEP695(type_alias) => Some(type_alias), TypeAliasType::ManualPEP695(_) => None, diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 9d101c40b2..28d589b5ae 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -421,7 +421,7 @@ impl<'db> UnionBuilder<'db> { .elements .iter() .filter_map(UnionElement::to_type_element) - .filter_map(Type::into_enum_literal) + .filter_map(Type::as_enum_literal) .map(|literal| literal.name(self.db).clone()) .chain(std::iter::once(enum_member_to_add.name(self.db).clone())) .collect::>(); @@ -650,7 +650,7 @@ impl<'db> IntersectionBuilder<'db> { for intersection in &self.intersections { if intersection.negative.iter().any(|negative| { negative - .into_enum_literal() + .as_enum_literal() .is_some_and(|lit| lit.enum_class_instance(self.db) == ty) }) { contains_enum_literal_as_negative_element = true; diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 7854982a73..b1b1b074aa 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -378,7 +378,7 @@ impl<'db> Bindings<'db> { .., ] if property.getter(db).is_some_and(|getter| { getter - .into_function_literal() + .as_function_literal() .is_some_and(|f| f.name(db) == "__name__") }) => { @@ -392,7 +392,7 @@ impl<'db> Bindings<'db> { ] => { match property .getter(db) - .and_then(Type::into_function_literal) + .and_then(Type::as_function_literal) .map(|f| f.name(db).as_str()) { Some("__name__") => { @@ -785,7 +785,7 @@ impl<'db> Bindings<'db> { // be a "(specialised) protocol class", but `typing.is_protocol(SupportsAbs[int])` returns // `False` at runtime, so we do not set the return type to `Literal[True]` in this case. overload.set_return_type(Type::BooleanLiteral( - ty.into_class_literal() + ty.as_class_literal() .is_some_and(|class| class.is_protocol(db)), )); } @@ -817,7 +817,7 @@ impl<'db> Bindings<'db> { continue; }; - let Some(attr_name) = attr_name.into_string_literal() else { + let Some(attr_name) = attr_name.as_string_literal() else { continue; }; diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 201e382fdd..01647b1c01 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1724,7 +1724,7 @@ impl<'db> ClassLiteral<'db> { /// Determine if this is an abstract class. pub(super) fn is_abstract(self, db: &'db dyn Db) -> bool { self.metaclass(db) - .into_class_literal() + .as_class_literal() .is_some_and(|metaclass| metaclass.is_known(db, KnownClass::ABCMeta)) } @@ -1758,7 +1758,7 @@ impl<'db> ClassLiteral<'db> { ) -> impl Iterator + 'db { self.decorators(db) .iter() - .filter_map(|deco| deco.into_function_literal()) + .filter_map(|deco| deco.as_function_literal()) .filter_map(|decorator| decorator.known(db)) } @@ -2398,7 +2398,7 @@ impl<'db> ClassLiteral<'db> { (CodeGeneratorKind::NamedTuple, name) if name != "__init__" => { KnownClass::NamedTupleFallback .to_class_literal(db) - .into_class_literal()? + .as_class_literal()? .own_class_member(db, self.inherited_generic_context(db), None, name) .ignore_possibly_unbound() .map(|ty| { @@ -5250,7 +5250,7 @@ impl KnownClass { }; overload.set_return_type(Type::KnownInstance(KnownInstanceType::Deprecated( - DeprecatedInstance::new(db, message.into_string_literal()), + DeprecatedInstance::new(db, message.as_string_literal()), ))); } @@ -5270,7 +5270,7 @@ impl KnownClass { return; }; - let Some(name) = name.into_string_literal() else { + let Some(name) = name.as_string_literal() else { if let Some(builder) = context.report_lint(&INVALID_TYPE_ALIAS_TYPE, call_expression) { diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs index d4ac8fd898..2221ced32d 100644 --- a/crates/ty_python_semantic/src/types/context.rs +++ b/crates/ty_python_semantic/src/types/context.rs @@ -192,7 +192,7 @@ impl<'db, 'ast> InferContext<'db, 'ast> { .ancestor_scopes(scope_id) .filter_map(|(_, scope)| scope.node().as_function()) .map(|node| binding_type(self.db, index.expect_single_definition(node))) - .filter_map(Type::into_function_literal); + .filter_map(Type::as_function_literal); // Iterate over all functions and test if any is decorated with `@no_type_check`. function_scope_tys.any(|function_ty| { diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index f7cce4e03a..b6867b84ea 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1145,7 +1145,7 @@ fn is_instance_truthiness<'db>( fn is_mode_with_nontrivial_return_type<'db>(db: &'db dyn Db, mode: Type<'db>) -> bool { // Return true for any mode that doesn't match typeshed's // `OpenTextMode` type alias (). - mode.into_string_literal().is_none_or(|mode| { + mode.as_string_literal().is_none_or(|mode| { !matches!( mode.value(db), "r+" | "+r" @@ -1557,7 +1557,7 @@ impl KnownFunction { return; } let mut diagnostic = if let Some(message) = message - .and_then(Type::into_string_literal) + .and_then(Type::as_string_literal) .map(|s| s.value(db)) { builder.into_diagnostic(format_args!("Static assertion error: {message}")) diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index afb3cc45ce..68b4d2a95c 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -35,7 +35,7 @@ pub(crate) fn enclosing_generic_contexts<'db>( NodeWithScopeKind::Class(class) => { let definition = index.expect_single_definition(class); binding_type(db, definition) - .into_class_literal()? + .as_class_literal()? .generic_context(db) } NodeWithScopeKind::Function(function) => { @@ -43,15 +43,15 @@ pub(crate) fn enclosing_generic_contexts<'db>( infer_definition_types(db, definition) .undecorated_type() .expect("function should have undecorated type") - .into_function_literal()? + .as_function_literal()? .last_definition_signature(db) .generic_context } NodeWithScopeKind::TypeAlias(type_alias) => { let definition = index.expect_single_definition(type_alias); binding_type(db, definition) - .into_type_alias()? - .into_pep_695_type_alias()? + .as_type_alias()? + .as_pep_695_type_alias()? .generic_context(db) } _ => None, @@ -1284,7 +1284,7 @@ impl<'db> SpecializationBuilder<'db> { let types_have_typevars = formal_union .elements(self.db) .iter() - .filter(|ty| ty.has_type_var(self.db)); + .filter(|ty| ty.has_typevar(self.db)); let Ok(Type::TypeVar(formal_bound_typevar)) = types_have_typevars.exactly_one() else { return Ok(()); @@ -1305,7 +1305,7 @@ impl<'db> SpecializationBuilder<'db> { // type. (Note that we've already handled above the case where the actual is // assignable to any _non-typevar_ union element.) let bound_typevars = - (formal.elements(self.db).iter()).filter_map(|ty| ty.into_type_var()); + (formal.elements(self.db).iter()).filter_map(|ty| ty.as_typevar()); if let Ok(bound_typevar) = bound_typevars.exactly_one() { self.add_type_mapping(bound_typevar, actual); } diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 1b00a41007..a5fe27877c 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -777,7 +777,7 @@ pub fn definitions_for_keyword_argument<'db>( let mut resolved_definitions = Vec::new(); - if let Some(Type::Callable(callable_type)) = func_type.into_callable(db) { + if let Some(Type::Callable(callable_type)) = func_type.try_upcast_to_callable(db) { let signatures = callable_type.signatures(db); // For each signature, find the parameter with the matching name @@ -872,7 +872,7 @@ pub fn call_signature_details<'db>( let func_type = call_expr.func.inferred_type(model); // Use into_callable to handle all the complex type conversions - if let Some(callable_type) = func_type.into_callable(db) { + if let Some(callable_type) = func_type.try_upcast_to_callable(db) { let call_arguments = CallArguments::from_arguments(&call_expr.arguments, |_, splatted_value| { splatted_value.inferred_type(model) diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 3d949a395a..171fa42438 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -490,7 +490,7 @@ pub(crate) fn nearest_enclosing_class<'db>( infer_definition_types(db, definition) .declaration_type(definition) .inner_type() - .into_class_literal() + .as_class_literal() }) } @@ -514,7 +514,7 @@ pub(crate) fn nearest_enclosing_function<'db>( inference .undecorated_type() .unwrap_or_else(|| inference.declaration_type(definition).inner_type()) - .into_function_literal() + .as_function_literal() }) } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 538d170ae6..b09b3f0196 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -531,7 +531,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Filter out class literals that result from imports if let DefinitionKind::Class(class) = definition.kind(self.db()) { ty.inner_type() - .into_class_literal() + .as_class_literal() .map(|class_literal| (class_literal, class.node(self.module()))) } else { None @@ -939,7 +939,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if !matches!(definition.kind(self.db()), DefinitionKind::Function(_)) { return None; } - let function = ty.inner_type().into_function_literal()?; + let function = ty.inner_type().as_function_literal()?; if function.has_known_decorator(self.db(), FunctionDecorators::OVERLOAD) { Some(definition.place(self.db())) } else { @@ -2200,12 +2200,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { Ok(return_ty) => { let is_input_function_like = inferred_ty - .into_callable(self.db()) - .and_then(Type::unwrap_as_callable_type) + .try_upcast_to_callable(self.db()) + .and_then(Type::as_callable) .is_some_and(|callable| callable.is_function_like(self.db())); - if is_input_function_like - && let Some(callable_type) = return_ty.unwrap_as_callable_type() - { + if is_input_function_like && let Some(callable_type) = return_ty.as_callable() { // When a method on a class is decorated with a function that returns a `Callable`, assume that // the returned callable is also function-like. See "Decorating a method with a `Callable`-typed // decorator" in `callables_as_descriptors.md` for the extended explanation. @@ -2546,7 +2544,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for decorator in decorator_list { let decorator_ty = self.infer_decorator(decorator); if decorator_ty - .into_function_literal() + .as_function_literal() .is_some_and(|function| function.is_known(self.db(), KnownFunction::Dataclass)) { dataclass_params = Some(DataclassParams::default()); @@ -3342,8 +3340,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let assigned_d = assigned_ty.display(db); let value_d = value_ty.display(db); - if let Some(typed_dict) = value_ty.into_typed_dict() { - if let Some(key) = slice_ty.into_string_literal() { + if let Some(typed_dict) = value_ty.as_typed_dict() { + if let Some(key) = slice_ty.as_string_literal() { let key = key.value(self.db()); validate_typed_dict_key_assignment( &self.context, @@ -4056,7 +4054,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); let typevar_class = callable_type - .into_class_literal() + .as_class_literal() .and_then(|cls| cls.known(self.db())) .filter(|cls| { matches!(cls, KnownClass::TypeVar | KnownClass::ExtensionsTypeVar) @@ -4278,10 +4276,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); }; - let Some(name_param) = name_param_ty - .into_string_literal() - .map(|name| name.value(db)) - else { + let Some(name_param) = name_param_ty.as_string_literal().map(|name| name.value(db)) else { return error( &self.context, "The first argument to `TypeVar` must be a string literal.", @@ -5036,7 +5031,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Avoid looking up attributes on a module if a module imports from itself // (e.g. `from parent import submodule` inside the `parent` module). let import_is_self_referential = module_ty - .into_module_literal() + .as_module_literal() .is_some_and(|module| Some(self.file()) == module.module(self.db()).file(self.db())); // First try loading the requested attribute from the module. @@ -5871,7 +5866,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = dict; // Validate `TypedDict` dictionary literal assignments. - if let Some(typed_dict) = tcx.annotation.and_then(Type::into_typed_dict) + if let Some(typed_dict) = tcx.annotation.and_then(Type::as_typed_dict) && let Some(ty) = self.infer_typed_dict_expression(dict, typed_dict) { return ty; @@ -6595,10 +6590,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_all_argument_types(arguments, &mut call_arguments, &bindings); // Validate `TypedDict` constructor calls after argument type inference - if let Some(class_literal) = callable_type.into_class_literal() { + if let Some(class_literal) = callable_type.as_class_literal() { if class_literal.is_typed_dict(self.db()) { let typed_dict_type = Type::typed_dict(ClassType::NonGeneric(class_literal)); - if let Some(typed_dict) = typed_dict_type.into_typed_dict() { + if let Some(typed_dict) = typed_dict_type.as_typed_dict() { validate_typed_dict_constructor( &self.context, typed_dict, @@ -9459,7 +9454,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } CallErrorKind::BindingError => { - if let Some(typed_dict) = value_ty.into_typed_dict() { + if let Some(typed_dict) = value_ty.as_typed_dict() { let slice_node = subscript.slice.as_ref(); report_invalid_key_on_typed_dict( @@ -9566,7 +9561,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // TODO: properly handle old-style generics; get rid of this temporary hack if !value_ty - .into_class_literal() + .as_class_literal() .is_some_and(|class| class.iter_mro(db, None).contains(&ClassBase::Generic)) { report_non_subscriptable(context, value_node.into(), value_ty, "__class_getitem__"); diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index a04f690338..e4a4948b62 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1157,7 +1157,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let argument_type = self.infer_expression(&arguments[0], TypeContext::default()); - let Some(callable_type) = argument_type.into_callable(db) else { + let Some(callable_type) = argument_type.try_upcast_to_callable(db) else { if let Some(builder) = self .context .report_lint(&INVALID_TYPE_FORM, arguments_slice) diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index a7515898d1..9079536435 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -165,7 +165,7 @@ impl<'db> Type<'db> { // from a protocol `Q` to be a subtype of `Q` to be a subtype of `Q` if it overrides // `Q`'s members in a Liskov-incompatible way. let type_to_test = self - .into_protocol_instance() + .as_protocol_instance() .and_then(ProtocolInstanceType::as_nominal_type) .map(Type::NominalInstance) .unwrap_or(self); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 2fb6157acb..20d505bcab 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -634,7 +634,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { // Add the narrowed values from the RHS first, to keep literals before broader types. builder = builder.add(rhs_values); - if let Some(lhs_union) = lhs_ty.into_union() { + if let Some(lhs_union) = lhs_ty.as_union() { for element in lhs_union.elements(self.db) { // Keep only the non-single-valued portion of the original type. if !element.is_single_valued(self.db) @@ -671,7 +671,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let mut single_builder = UnionBuilder::new(self.db); let mut rest_builder = UnionBuilder::new(self.db); - if let Some(lhs_union) = lhs_ty.into_union() { + if let Some(lhs_union) = lhs_ty.as_union() { for element in lhs_union.elements(self.db) { if element.is_single_valued(self.db) || element.is_literal_string() @@ -840,7 +840,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let callable_type = inference.expression_type(&**callable); if callable_type - .into_class_literal() + .as_class_literal() .is_some_and(|c| c.is_known(self.db, KnownClass::Type)) { let place = self.expect_place(&target); @@ -903,7 +903,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if function == KnownFunction::HasAttr { let attr = inference .expression_type(second_arg) - .into_string_literal()? + .as_string_literal()? .value(self.db); if !is_identifier(attr) { diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 529824b1ea..716b814aaa 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -641,7 +641,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { // unfortunately not sufficient to obtain the `Callable` supertypes of these types, due to the // complex interaction between `__new__`, `__init__` and metaclass `__call__`. let attribute_type = if self.name == "__call__" { - let Some(attribute_type) = other.into_callable(db) else { + let Some(attribute_type) = other.try_upcast_to_callable(db) else { return ConstraintSet::from(false); }; attribute_type diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 8547d41a8e..ed96430b48 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -55,7 +55,7 @@ fn infer_method_information<'db>( let method = infer_definition_types(db, definition) .declaration_type(definition) .inner_type() - .into_function_literal()?; + .as_function_literal()?; let class_def = index.expect_single_definition(class_node); let (class_literal, class_is_generic) = match infer_definition_types(db, class_def) From c69fa75cd5b2031f99e6d358343b18e22ceee5d9 Mon Sep 17 00:00:00 2001 From: Dan Parizher <105245560+danparizher@users.noreply.github.com> Date: Tue, 14 Oct 2025 04:06:17 -0400 Subject: [PATCH 029/113] Fix false negatives in `Truthiness::from_expr` for lambdas, generators, and f-strings (#20704) --- .../test/fixtures/flake8_bandit/S602.py | 7 ++ .../test/fixtures/flake8_bandit/S604.py | 16 ++++ .../test/fixtures/flake8_bandit/S609.py | 7 ++ .../test/fixtures/flake8_simplify/SIM222.py | 7 ++ ...s__flake8_bandit__tests__S602_S602.py.snap | 41 ++++++++++ ...s__flake8_bandit__tests__S604_S604.py.snap | 82 +++++++++++++++++++ ...s__flake8_bandit__tests__S609_S609.py.snap | 41 ++++++++++ ...ke8_simplify__tests__SIM222_SIM222.py.snap | 74 +++++++++++++++++ crates/ruff_python_ast/src/helpers.rs | 14 +++- 9 files changed, 285 insertions(+), 4 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S602.py b/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S602.py index 6c40b547e0..a9bab4f122 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S602.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S602.py @@ -33,3 +33,10 @@ class ShellConfig: def run(self, username): Popen("true", shell={**self.shell_defaults, **self.fetch_shell_config(username)}) + +# Additional truthiness cases for generator, lambda, and f-strings +Popen("true", shell=(i for i in ())) +Popen("true", shell=lambda: 0) +Popen("true", shell=f"{b''}") +x = 1 +Popen("true", shell=f"{x=}") diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S604.py b/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S604.py index 46131a04b0..49d906508b 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S604.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S604.py @@ -6,3 +6,19 @@ foo(shell=True) foo(shell={**{}}) foo(shell={**{**{}}}) + +# Truthy non-bool values for `shell` +foo(shell=(i for i in ())) +foo(shell=lambda: 0) + +# f-strings guaranteed non-empty +foo(shell=f"{b''}") +x = 1 +foo(shell=f"{x=}") + +# Additional truthiness cases for generator, lambda, and f-strings +foo(shell=(i for i in ())) +foo(shell=lambda: 0) +foo(shell=f"{b''}") +x = 1 +foo(shell=f"{x=}") diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S609.py b/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S609.py index 6f75dd7451..85de797042 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S609.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bandit/S609.py @@ -9,3 +9,10 @@ os.system("tar cf foo.tar bar/*") subprocess.Popen(["chmod", "+w", "*.py"], shell={**{}}) subprocess.Popen(["chmod", "+w", "*.py"], shell={**{**{}}}) + +# Additional truthiness cases for generator, lambda, and f-strings +subprocess.Popen("chmod +w foo*", shell=(i for i in ())) +subprocess.Popen("chmod +w foo*", shell=lambda: 0) +subprocess.Popen("chmod +w foo*", shell=f"{b''}") +x = 1 +subprocess.Popen("chmod +w foo*", shell=f"{x=}") diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222.py b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222.py index e2f909acf0..e1e299c98e 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222.py @@ -197,3 +197,10 @@ for x in {**a, **b} or [None]: # https://github.com/astral-sh/ruff/issues/7127 def f(a: "'b' or 'c'"): ... + +# https://github.com/astral-sh/ruff/issues/20703 +print(f"{b''}" or "bar") # SIM222 +x = 1 +print(f"{x=}" or "bar") # SIM222 +(lambda: 1) or True # SIM222 +(i for i in range(1)) or "bar" # SIM222 diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S602_S602.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S602_S602.py.snap index 2056011585..4115627c2d 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S602_S602.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S602_S602.py.snap @@ -127,3 +127,44 @@ S602 `subprocess` call with `shell=True` identified, security issue 21 | 22 | # Check dict display with only double-starred expressions can be falsey. | + +S602 `subprocess` call with truthy `shell` seems safe, but may be changed in the future; consider rewriting without `shell` + --> S602.py:38:1 + | +37 | # Additional truthiness cases for generator, lambda, and f-strings +38 | Popen("true", shell=(i for i in ())) + | ^^^^^ +39 | Popen("true", shell=lambda: 0) +40 | Popen("true", shell=f"{b''}") + | + +S602 `subprocess` call with truthy `shell` seems safe, but may be changed in the future; consider rewriting without `shell` + --> S602.py:39:1 + | +37 | # Additional truthiness cases for generator, lambda, and f-strings +38 | Popen("true", shell=(i for i in ())) +39 | Popen("true", shell=lambda: 0) + | ^^^^^ +40 | Popen("true", shell=f"{b''}") +41 | x = 1 + | + +S602 `subprocess` call with truthy `shell` seems safe, but may be changed in the future; consider rewriting without `shell` + --> S602.py:40:1 + | +38 | Popen("true", shell=(i for i in ())) +39 | Popen("true", shell=lambda: 0) +40 | Popen("true", shell=f"{b''}") + | ^^^^^ +41 | x = 1 +42 | Popen("true", shell=f"{x=}") + | + +S602 `subprocess` call with truthy `shell` seems safe, but may be changed in the future; consider rewriting without `shell` + --> S602.py:42:1 + | +40 | Popen("true", shell=f"{b''}") +41 | x = 1 +42 | Popen("true", shell=f"{x=}") + | ^^^^^ + | diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S604_S604.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S604_S604.py.snap index 80764d3418..798e916ba6 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S604_S604.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S604_S604.py.snap @@ -9,3 +9,85 @@ S604 Function call with `shell=True` parameter identified, security issue 6 | 7 | foo(shell={**{}}) | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:11:1 + | +10 | # Truthy non-bool values for `shell` +11 | foo(shell=(i for i in ())) + | ^^^ +12 | foo(shell=lambda: 0) + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:12:1 + | +10 | # Truthy non-bool values for `shell` +11 | foo(shell=(i for i in ())) +12 | foo(shell=lambda: 0) + | ^^^ +13 | +14 | # f-strings guaranteed non-empty + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:15:1 + | +14 | # f-strings guaranteed non-empty +15 | foo(shell=f"{b''}") + | ^^^ +16 | x = 1 +17 | foo(shell=f"{x=}") + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:17:1 + | +15 | foo(shell=f"{b''}") +16 | x = 1 +17 | foo(shell=f"{x=}") + | ^^^ +18 | +19 | # Additional truthiness cases for generator, lambda, and f-strings + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:20:1 + | +19 | # Additional truthiness cases for generator, lambda, and f-strings +20 | foo(shell=(i for i in ())) + | ^^^ +21 | foo(shell=lambda: 0) +22 | foo(shell=f"{b''}") + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:21:1 + | +19 | # Additional truthiness cases for generator, lambda, and f-strings +20 | foo(shell=(i for i in ())) +21 | foo(shell=lambda: 0) + | ^^^ +22 | foo(shell=f"{b''}") +23 | x = 1 + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:22:1 + | +20 | foo(shell=(i for i in ())) +21 | foo(shell=lambda: 0) +22 | foo(shell=f"{b''}") + | ^^^ +23 | x = 1 +24 | foo(shell=f"{x=}") + | + +S604 Function call with truthy `shell` parameter identified, security issue + --> S604.py:24:1 + | +22 | foo(shell=f"{b''}") +23 | x = 1 +24 | foo(shell=f"{x=}") + | ^^^ + | diff --git a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S609_S609.py.snap b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S609_S609.py.snap index 96282702f2..07c3f3b970 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S609_S609.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bandit/snapshots/ruff_linter__rules__flake8_bandit__tests__S609_S609.py.snap @@ -43,3 +43,44 @@ S609 Possible wildcard injection in call due to `*` usage 9 | 10 | subprocess.Popen(["chmod", "+w", "*.py"], shell={**{}}) | + +S609 Possible wildcard injection in call due to `*` usage + --> S609.py:14:18 + | +13 | # Additional truthiness cases for generator, lambda, and f-strings +14 | subprocess.Popen("chmod +w foo*", shell=(i for i in ())) + | ^^^^^^^^^^^^^^^ +15 | subprocess.Popen("chmod +w foo*", shell=lambda: 0) +16 | subprocess.Popen("chmod +w foo*", shell=f"{b''}") + | + +S609 Possible wildcard injection in call due to `*` usage + --> S609.py:15:18 + | +13 | # Additional truthiness cases for generator, lambda, and f-strings +14 | subprocess.Popen("chmod +w foo*", shell=(i for i in ())) +15 | subprocess.Popen("chmod +w foo*", shell=lambda: 0) + | ^^^^^^^^^^^^^^^ +16 | subprocess.Popen("chmod +w foo*", shell=f"{b''}") +17 | x = 1 + | + +S609 Possible wildcard injection in call due to `*` usage + --> S609.py:16:18 + | +14 | subprocess.Popen("chmod +w foo*", shell=(i for i in ())) +15 | subprocess.Popen("chmod +w foo*", shell=lambda: 0) +16 | subprocess.Popen("chmod +w foo*", shell=f"{b''}") + | ^^^^^^^^^^^^^^^ +17 | x = 1 +18 | subprocess.Popen("chmod +w foo*", shell=f"{x=}") + | + +S609 Possible wildcard injection in call due to `*` usage + --> S609.py:18:18 + | +16 | subprocess.Popen("chmod +w foo*", shell=f"{b''}") +17 | x = 1 +18 | subprocess.Popen("chmod +w foo*", shell=f"{x=}") + | ^^^^^^^^^^^^^^^ + | diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM222_SIM222.py.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM222_SIM222.py.snap index 691d91f976..f6c8bba110 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM222_SIM222.py.snap +++ b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__SIM222_SIM222.py.snap @@ -1062,3 +1062,77 @@ help: Replace with `"bar"` 170 | 171 | note: This is an unsafe fix and may change runtime behavior + +SIM222 [*] Use `f"{b''}"` instead of `f"{b''}" or ...` + --> SIM222.py:202:7 + | +201 | # https://github.com/astral-sh/ruff/issues/20703 +202 | print(f"{b''}" or "bar") # SIM222 + | ^^^^^^^^^^^^^^^^^ +203 | x = 1 +204 | print(f"{x=}" or "bar") # SIM222 + | +help: Replace with `f"{b''}"` +199 | def f(a: "'b' or 'c'"): ... +200 | +201 | # https://github.com/astral-sh/ruff/issues/20703 + - print(f"{b''}" or "bar") # SIM222 +202 + print(f"{b''}") # SIM222 +203 | x = 1 +204 | print(f"{x=}" or "bar") # SIM222 +205 | (lambda: 1) or True # SIM222 +note: This is an unsafe fix and may change runtime behavior + +SIM222 [*] Use `f"{x=}"` instead of `f"{x=}" or ...` + --> SIM222.py:204:7 + | +202 | print(f"{b''}" or "bar") # SIM222 +203 | x = 1 +204 | print(f"{x=}" or "bar") # SIM222 + | ^^^^^^^^^^^^^^^^ +205 | (lambda: 1) or True # SIM222 +206 | (i for i in range(1)) or "bar" # SIM222 + | +help: Replace with `f"{x=}"` +201 | # https://github.com/astral-sh/ruff/issues/20703 +202 | print(f"{b''}" or "bar") # SIM222 +203 | x = 1 + - print(f"{x=}" or "bar") # SIM222 +204 + print(f"{x=}") # SIM222 +205 | (lambda: 1) or True # SIM222 +206 | (i for i in range(1)) or "bar" # SIM222 +note: This is an unsafe fix and may change runtime behavior + +SIM222 [*] Use `lambda: 1` instead of `lambda: 1 or ...` + --> SIM222.py:205:1 + | +203 | x = 1 +204 | print(f"{x=}" or "bar") # SIM222 +205 | (lambda: 1) or True # SIM222 + | ^^^^^^^^^^^^^^^^^^^ +206 | (i for i in range(1)) or "bar" # SIM222 + | +help: Replace with `lambda: 1` +202 | print(f"{b''}" or "bar") # SIM222 +203 | x = 1 +204 | print(f"{x=}" or "bar") # SIM222 + - (lambda: 1) or True # SIM222 +205 + lambda: 1 # SIM222 +206 | (i for i in range(1)) or "bar" # SIM222 +note: This is an unsafe fix and may change runtime behavior + +SIM222 [*] Use `(i for i in range(1))` instead of `(i for i in range(1)) or ...` + --> SIM222.py:206:1 + | +204 | print(f"{x=}" or "bar") # SIM222 +205 | (lambda: 1) or True # SIM222 +206 | (i for i in range(1)) or "bar" # SIM222 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Replace with `(i for i in range(1))` +203 | x = 1 +204 | print(f"{x=}" or "bar") # SIM222 +205 | (lambda: 1) or True # SIM222 + - (i for i in range(1)) or "bar" # SIM222 +206 + (i for i in range(1)) # SIM222 +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs index 978a1ae984..9d5159a829 100644 --- a/crates/ruff_python_ast/src/helpers.rs +++ b/crates/ruff_python_ast/src/helpers.rs @@ -12,7 +12,7 @@ use crate::parenthesize::parenthesized_range; use crate::statement_visitor::StatementVisitor; use crate::visitor::Visitor; use crate::{ - self as ast, Arguments, AtomicNodeIndex, CmpOp, DictItem, ExceptHandler, Expr, + self as ast, Arguments, AtomicNodeIndex, CmpOp, DictItem, ExceptHandler, Expr, ExprNoneLiteral, InterpolatedStringElement, MatchCase, Operator, Pattern, Stmt, TypeParam, }; use crate::{AnyNodeRef, ExprContext}; @@ -1219,6 +1219,8 @@ impl Truthiness { F: Fn(&str) -> bool, { match expr { + Expr::Lambda(_) => Self::Truthy, + Expr::Generator(_) => Self::Truthy, Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => { if value.is_empty() { Self::Falsey @@ -1388,7 +1390,9 @@ fn is_non_empty_f_string(expr: &ast::ExprFString) -> bool { Expr::FString(f_string) => is_non_empty_f_string(f_string), // These literals may or may not be empty. Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => !value.is_empty(), - Expr::BytesLiteral(ast::ExprBytesLiteral { value, .. }) => !value.is_empty(), + // Confusingly, f"{b""}" renders as the string 'b""', which is non-empty. + // Therefore, any bytes interpolation is guaranteed non-empty when stringified. + Expr::BytesLiteral(_) => true, } } @@ -1397,7 +1401,9 @@ fn is_non_empty_f_string(expr: &ast::ExprFString) -> bool { ast::FStringPart::FString(f_string) => { f_string.elements.iter().all(|element| match element { InterpolatedStringElement::Literal(string_literal) => !string_literal.is_empty(), - InterpolatedStringElement::Interpolation(f_string) => inner(&f_string.expression), + InterpolatedStringElement::Interpolation(f_string) => { + f_string.debug_text.is_some() || inner(&f_string.expression) + } }) } }) @@ -1493,7 +1499,7 @@ pub fn pep_604_optional(expr: &Expr) -> Expr { ast::ExprBinOp { left: Box::new(expr.clone()), op: Operator::BitOr, - right: Box::new(Expr::NoneLiteral(ast::ExprNoneLiteral::default())), + right: Box::new(Expr::NoneLiteral(ExprNoneLiteral::default())), range: TextRange::default(), node_index: AtomicNodeIndex::NONE, } From ac2c5303775bcf52d40eea406939a6961774e6af Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 14 Oct 2025 11:47:50 +0200 Subject: [PATCH 030/113] [ty] Handle decorators which return unions of `Callable`s (#20858) ## Summary If a function is decorated with a decorator that returns a union of `Callable`s, also treat it as a union of function-like `Callable`s. Labeling as `internal`, since the previous change has not been released yet. ## Test Plan New regression test. --- .../mdtest/call/callables_as_descriptors.md | 22 ++++++++++++-- .../src/types/infer/builder.rs | 30 +++++++++++++++---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index cee7df8eca..2adfd228c5 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -145,22 +145,38 @@ class C2: C2().method_decorated(1) ``` +And with unions of `Callable` types: + +```py +from typing import Callable + +def expand(f: Callable[[C3, int], int]) -> Callable[[C3, int], int] | Callable[[C3, int], str]: + raise NotImplementedError + +class C3: + @expand + def method_decorated(self, x: int) -> int: + return x + +reveal_type(C3().method_decorated(1)) # revealed: int | str +``` + Note that we currently only apply this heuristic when calling a function such as `memoize` via the decorator syntax. This is inconsistent, because the above *should* be equivalent to the following, but here we emit errors: ```py -def memoize3(f: Callable[[C3, int], str]) -> Callable[[C3, int], str]: +def memoize3(f: Callable[[C4, int], str]) -> Callable[[C4, int], str]: raise NotImplementedError -class C3: +class C4: def method(self, x: int) -> str: return str(x) method_decorated = memoize3(method) # error: [missing-argument] # error: [invalid-argument-type] -C3().method_decorated(1) +C4().method_decorated(1) ``` The reason for this is that the heuristic is problematic. We don't *know* that the `Callable` in the diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index b09b3f0196..bae41b14b4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -2199,19 +2199,37 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .map(|bindings| bindings.return_type(self.db())) { Ok(return_ty) => { + fn into_function_like_callable<'d>( + db: &'d dyn Db, + ty: Type<'d>, + ) -> Option> { + match ty { + Type::Callable(callable) => Some(Type::Callable(CallableType::new( + db, + callable.signatures(db), + true, + ))), + Type::Union(union) => union + .try_map(db, |element| into_function_like_callable(db, *element)), + // Intersections are currently not handled here because that would require + // the decorator to be explicitly annotated as returning an intersection. + _ => None, + } + } + let is_input_function_like = inferred_ty .try_upcast_to_callable(self.db()) .and_then(Type::as_callable) .is_some_and(|callable| callable.is_function_like(self.db())); - if is_input_function_like && let Some(callable_type) = return_ty.as_callable() { + + if is_input_function_like + && let Some(return_ty_function_like) = + into_function_like_callable(self.db(), return_ty) + { // When a method on a class is decorated with a function that returns a `Callable`, assume that // the returned callable is also function-like. See "Decorating a method with a `Callable`-typed // decorator" in `callables_as_descriptors.md` for the extended explanation. - Type::Callable(CallableType::new( - self.db(), - callable_type.signatures(self.db()), - true, - )) + return_ty_function_like } else { return_ty } From 6341bb740378190abb80673bc4e577ebd28bb36d Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 14 Oct 2025 14:27:52 +0200 Subject: [PATCH 031/113] [ty] Treat `Callable` dunder members as bound method descriptors (#20860) ## Summary Dunder methods (at least the ones defined in the standard library) always take an instance of the class as the first parameter. So it seems reasonable to generally treat them as bound method descriptors if they are defined via a `Callable` type. This removes just a few false positives from the ecosystem, but solves three user-reported issues: closes https://github.com/astral-sh/ty/issues/908 closes https://github.com/astral-sh/ty/issues/1143 closes https://github.com/astral-sh/ty/issues/1209 In addition to the change here, I also considered [making `ClassVar`s bound method descriptors](https://github.com/astral-sh/ruff/pull/20861). However, there was zero ecosystem impact. So I think we can also close https://github.com/astral-sh/ty/issues/491 with this PR. closes https://github.com/astral-sh/ty/issues/491 ## Test Plan Added regression test --- .../mdtest/call/callables_as_descriptors.md | 34 +++++++++++++++++++ .../type_properties/is_assignable_to.md | 9 +++-- .../mdtest/type_properties/is_subtype_of.md | 9 +++-- crates/ty_python_semantic/src/types.rs | 17 ++++++++++ crates/ty_python_semantic/src/types/class.rs | 24 ++++++++++++- 5 files changed, 82 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index 2adfd228c5..eb45a6697e 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -204,3 +204,37 @@ class Calculator: reveal_type(Calculator().square_then_round(3.14)) # revealed: Unknown | int ``` + +## Use case: Treating dunder methods as bound-method descriptors + +pytorch defines a `__pow__` dunder attribute on [`TensorBase`] in a similar way to the following +example. We generally treat dunder attributes as bound-method descriptors since they all take a +`self` argument. This allows us to type-check the following code correctly: + +```py +from typing import Callable + +def pow_impl(tensor: Tensor, exponent: int) -> Tensor: + raise NotImplementedError + +class Tensor: + __pow__: Callable[[Tensor, int], Tensor] = pow_impl + +Tensor() ** 2 +``` + +The following example is also taken from a real world project. Here, the `__lt__` dunder attribute +is not declared. The attribute type is therefore inferred as `Unknown | Callable[…]`, but we still +treat it as a bound-method descriptor: + +```py +def make_comparison_operator(name: str) -> Callable[[Matrix, Matrix], bool]: + raise NotImplementedError + +class Matrix: + __lt__ = make_comparison_operator("lt") + +Matrix() < Matrix() +``` + +[`tensorbase`]: https://github.com/pytorch/pytorch/blob/f3913ea641d871f04fa2b6588a77f63efeeb9f10/torch/_tensor.py#L1084-L1092 diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md index 17da33f1d3..a39b6a6f16 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md @@ -1181,18 +1181,17 @@ static_assert(not is_assignable_to(EggsLegacy, Callable[..., Any])) # error: [s An instance type is assignable to a compatible callable type if the instance type's class has a callable `__call__` attribute. -TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may -change for better compatibility with mypy/pyright. - ```py +from __future__ import annotations + from typing import Callable from ty_extensions import static_assert, is_assignable_to -def call_impl(a: int) -> str: +def call_impl(a: A, x: int) -> str: return "" class A: - __call__: Callable[[int], str] = call_impl + __call__: Callable[[A, int], str] = call_impl static_assert(is_assignable_to(A, Callable[[int], str])) static_assert(not is_assignable_to(A, Callable[[int], int])) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md index 131bd5563b..6034a52529 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md @@ -1635,18 +1635,17 @@ f(a) An instance type can be a subtype of a compatible callable type if the instance type's class has a callable `__call__` attribute. -TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may -change for better compatibility with mypy/pyright. - ```py +from __future__ import annotations + from typing import Callable from ty_extensions import static_assert, is_subtype_of -def call_impl(a: int) -> str: +def call_impl(a: A, x: int) -> str: return "" class A: - __call__: Callable[[int], str] = call_impl + __call__: Callable[[A, int], str] = call_impl static_assert(is_subtype_of(A, Callable[[int], str])) static_assert(not is_subtype_of(A, Callable[[int], int])) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 32bcc7aa63..410192de9b 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -11116,6 +11116,23 @@ impl<'db> IntersectionType<'db> { } } + /// Map a type transformation over all positive elements of the intersection. Leave the + /// negative elements unchanged. + pub(crate) fn map_positive( + self, + db: &'db dyn Db, + mut transform_fn: impl FnMut(&Type<'db>) -> Type<'db>, + ) -> Type<'db> { + let mut builder = IntersectionBuilder::new(db); + for ty in self.positive(db) { + builder = builder.add_positive(transform_fn(ty)); + } + for ty in self.negative(db) { + builder = builder.add_negative(*ty); + } + builder.build() + } + pub(crate) fn map_with_boundness( self, db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 01647b1c01..b9b63a50a1 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2014,7 +2014,29 @@ impl<'db> ClassLiteral<'db> { name: &str, policy: MemberLookupPolicy, ) -> PlaceAndQualifiers<'db> { - self.class_member_inner(db, None, name, policy) + fn into_function_like_callable<'d>(db: &'d dyn Db, ty: Type<'d>) -> Type<'d> { + match ty { + Type::Callable(callable_ty) => { + Type::Callable(CallableType::new(db, callable_ty.signatures(db), true)) + } + Type::Union(union) => { + union.map(db, |element| into_function_like_callable(db, *element)) + } + Type::Intersection(intersection) => intersection + .map_positive(db, |element| into_function_like_callable(db, *element)), + _ => ty, + } + } + + let mut member = self.class_member_inner(db, None, name, policy); + + // We generally treat dunder attributes with `Callable` types as function-like callables. + // See `callables_as_descriptors.md` for more details. + if name.starts_with("__") && name.ends_with("__") { + member = member.map_type(|ty| into_function_like_callable(db, ty)); + } + + member } fn class_member_inner( From 441ba208763afbcf379135806e6381a81120a9d6 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 14 Oct 2025 14:33:48 +0200 Subject: [PATCH 032/113] [ty] Document when a rule was added (#20859) --- crates/ruff_dev/src/generate_ty_rules.rs | 31 +- crates/ty/docs/rules.md | 602 +++++++++++------- crates/ty_python_semantic/src/suppression.rs | 6 +- .../src/types/diagnostic.rs | 136 ++-- .../src/types/string_annotation.rs | 12 +- ty.schema.json | 2 +- 6 files changed, 485 insertions(+), 304 deletions(-) diff --git a/crates/ruff_dev/src/generate_ty_rules.rs b/crates/ruff_dev/src/generate_ty_rules.rs index aaa1021d96..22394de203 100644 --- a/crates/ruff_dev/src/generate_ty_rules.rs +++ b/crates/ruff_dev/src/generate_ty_rules.rs @@ -93,14 +93,39 @@ fn generate_markdown() -> String { }) .join("\n"); + let status_text = match lint.status() { + ty_python_semantic::lint::LintStatus::Stable { since } => { + format!( + r#"Added in {since}"# + ) + } + ty_python_semantic::lint::LintStatus::Preview { since } => { + format!( + r#"Preview (since {since})"# + ) + } + ty_python_semantic::lint::LintStatus::Deprecated { since, .. } => { + format!( + r#"Deprecated (since {since})"# + ) + } + ty_python_semantic::lint::LintStatus::Removed { since, .. } => { + format!( + r#"Removed (since {since})"# + ) + } + }; + let _ = writeln!( &mut output, r#" -Default level: [`{level}`](../rules.md#rule-levels "This lint has a default level of '{level}'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20{encoded_name}) · -[View source](https://github.com/astral-sh/ruff/blob/main/{file}#L{line}) +Default level: {level} · +{status_text} · +Related issues · +View source + {documentation} "#, level = lint.default_level(), diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index cc35151b54..8ae4ba1da5 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -5,11 +5,13 @@ ## `byte-string-type-annotation` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20byte-string-type-annotation) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L36) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for byte-strings in type annotation positions. @@ -34,11 +36,13 @@ def test(): -> "int": ## `call-non-callable` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20call-non-callable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L115) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for calls to non-callable objects. @@ -56,11 +60,13 @@ Calling a non-callable object will raise a `TypeError` at runtime. ## `conflicting-argument-forms` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-argument-forms) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L159) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks whether an argument is used as both a value and a type form in a call. @@ -86,11 +92,13 @@ f(int) # error ## `conflicting-declarations` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-declarations) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L185) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks whether a variable has been declared as two conflicting types. @@ -115,11 +123,13 @@ a = 1 ## `conflicting-metaclass` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-metaclass) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L210) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for class definitions where the metaclass of the class @@ -145,11 +155,13 @@ class C(A, B): ... ## `cyclic-class-definition` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20cyclic-class-definition) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L236) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for class definitions in stub files that inherit @@ -175,11 +187,13 @@ class B(A): ... ## `duplicate-base` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20duplicate-base) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L301) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for class definitions with duplicate bases. @@ -200,11 +214,13 @@ class B(A, A): ... ## `duplicate-kw-only` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20duplicate-kw-only) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L322) +Default level: error · +Added in 0.0.1-alpha.12 · +Related issues · +View source + **What it does** Checks for dataclass definitions with more than one field @@ -236,21 +252,25 @@ class A: # Crash at runtime ## `escape-character-in-forward-annotation` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20escape-character-in-forward-annotation) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L120) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + TODO #14889 ## `fstring-type-annotation` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20fstring-type-annotation) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L11) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for f-strings in type annotation positions. @@ -275,11 +295,13 @@ def test(): -> "int": ## `implicit-concatenated-string-type-annotation` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20implicit-concatenated-string-type-annotation) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L86) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for implicit concatenated strings in type annotation positions. @@ -304,11 +326,13 @@ def test(): -> "Literal[5]": ## `inconsistent-mro` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20inconsistent-mro) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L525) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for classes with an inconsistent [method resolution order] (MRO). @@ -332,11 +356,13 @@ class C(A, B): ... ## `index-out-of-bounds` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20index-out-of-bounds) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L549) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for attempts to use an out of bounds index to get an item from @@ -356,11 +382,13 @@ t[3] # IndexError: tuple index out of range ## `instance-layout-conflict` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20instance-layout-conflict) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L354) +Default level: error · +Added in 0.0.1-alpha.12 · +Related issues · +View source + **What it does** Checks for classes definitions which will fail at runtime due to @@ -443,11 +471,13 @@ an atypical memory layout. ## `invalid-argument-type` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-argument-type) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L594) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Detects call arguments whose type is not assignable to the corresponding typed parameter. @@ -468,11 +498,13 @@ func("foo") # error: [invalid-argument-type] ## `invalid-assignment` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-assignment) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L634) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for assignments where the type of the value @@ -494,11 +526,13 @@ a: int = '' ## `invalid-attribute-access` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-attribute-access) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1745) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for assignments to class variables from instances @@ -526,11 +560,13 @@ C.instance_var = 3 # error: Cannot assign to instance variable ## `invalid-await` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-await) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L656) +Default level: error · +Added in 0.0.1-alpha.19 · +Related issues · +View source + **What it does** Checks for `await` being used with types that are not [Awaitable]. @@ -560,11 +596,13 @@ asyncio.run(main()) ## `invalid-base` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-base) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L686) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for class definitions that have bases which are not instances of `type`. @@ -582,11 +620,13 @@ class A(42): ... # error: [invalid-base] ## `invalid-context-manager` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-context-manager) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L737) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for expressions used in `with` statements @@ -607,11 +647,13 @@ with 1: ## `invalid-declaration` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-declaration) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L758) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for declarations where the inferred type of an existing symbol @@ -634,11 +676,13 @@ a: str ## `invalid-exception-caught` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-exception-caught) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L781) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for exception handlers that catch non-exception classes. @@ -676,11 +720,13 @@ except ZeroDivisionError: ## `invalid-generic-class` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-generic-class) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L817) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for the creation of invalid generic classes @@ -707,11 +753,13 @@ class C[U](Generic[T]): ... ## `invalid-key` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-key) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L569) +Default level: error · +Added in 0.0.1-alpha.17 · +Related issues · +View source + **What it does** Checks for subscript accesses with invalid keys. @@ -736,11 +784,13 @@ alice["height"] # KeyError: 'height' ## `invalid-legacy-type-variable` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-legacy-type-variable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L843) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for the creation of invalid legacy `TypeVar`s @@ -769,11 +819,13 @@ def f(t: TypeVar("U")): ... ## `invalid-metaclass` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-metaclass) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L892) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for arguments to `metaclass=` that are invalid. @@ -801,11 +853,13 @@ class B(metaclass=f): ... ## `invalid-named-tuple` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-named-tuple) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L499) +Default level: error · +Added in 0.0.1-alpha.19 · +Related issues · +View source + **What it does** Checks for invalidly defined `NamedTuple` classes. @@ -831,11 +885,13 @@ TypeError: can only inherit from a NamedTuple type and Generic ## `invalid-overload` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-overload) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L919) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for various invalid `@overload` usages. @@ -879,11 +935,13 @@ def foo(x: int) -> int: ... ## `invalid-parameter-default` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-parameter-default) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1018) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for default values that can't be @@ -903,11 +961,13 @@ def f(a: int = ''): ... ## `invalid-protocol` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-protocol) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L436) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for protocol classes that will raise `TypeError` at runtime. @@ -935,11 +995,13 @@ TypeError: Protocols can only inherit from other protocols, got ## `invalid-raise` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-raise) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1038) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + Checks for `raise` statements that raise non-exceptions or use invalid causes for their raised exceptions. @@ -982,11 +1044,13 @@ def g(): ## `invalid-return-type` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-return-type) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L615) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Detects returned values that can't be assigned to the function's annotated return type. @@ -1005,11 +1069,13 @@ def func() -> int: ## `invalid-super-argument` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-super-argument) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1081) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Detects `super()` calls where: @@ -1049,21 +1115,25 @@ super(B, A) # error: `A` does not satisfy `issubclass(A, B)` ## `invalid-syntax-in-forward-annotation` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-syntax-in-forward-annotation) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L111) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + TODO #14889 ## `invalid-type-alias-type` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-alias-type) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L871) +Default level: error · +Added in 0.0.1-alpha.6 · +Related issues · +View source + **What it does** Checks for the creation of invalid `TypeAliasType`s @@ -1084,11 +1154,13 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus ## `invalid-type-checking-constant` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-checking-constant) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1120) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for a value other than `False` assigned to the `TYPE_CHECKING` variable, or an @@ -1112,11 +1184,13 @@ TYPE_CHECKING = '' ## `invalid-type-form` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-form) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1144) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for expressions that are used as [type expressions] @@ -1140,11 +1214,13 @@ b: Annotated[int] # `Annotated` expects at least two arguments ## `invalid-type-guard-call` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-guard-call) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1196) +Default level: error · +Added in 0.0.1-alpha.11 · +Related issues · +View source + **What it does** Checks for type guard function calls without a valid target. @@ -1172,11 +1248,13 @@ f(10) # Error ## `invalid-type-guard-definition` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-guard-definition) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1168) +Default level: error · +Added in 0.0.1-alpha.11 · +Related issues · +View source + **What it does** Checks for type guard functions without @@ -1204,11 +1282,13 @@ class C: ## `invalid-type-variable-constraints` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-variable-constraints) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1224) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for constrained [type variables] with only one constraint. @@ -1237,11 +1317,13 @@ T = TypeVar('T', bound=str) # valid bound TypeVar ## `missing-argument` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20missing-argument) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1253) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for missing required arguments in a call. @@ -1260,11 +1342,13 @@ func() # TypeError: func() missing 1 required positional argument: 'x' ## `missing-typed-dict-key` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20missing-typed-dict-key) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1844) +Default level: error · +Added in 0.0.1-alpha.20 · +Related issues · +View source + **What it does** Detects missing required keys in `TypedDict` constructor calls. @@ -1291,11 +1375,13 @@ alice["age"] # KeyError ## `no-matching-overload` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20no-matching-overload) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1272) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for calls to an overloaded function that do not match any of the overloads. @@ -1318,11 +1404,13 @@ func("string") # error: [no-matching-overload] ## `non-subscriptable` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20non-subscriptable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1295) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for subscripting objects that do not support subscripting. @@ -1340,11 +1428,13 @@ Subscripting an object that does not support it will raise a `TypeError` at runt ## `not-iterable` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20not-iterable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1313) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for objects that are not iterable but are used in a context that requires them to be. @@ -1364,11 +1454,13 @@ for i in 34: # TypeError: 'int' object is not iterable ## `parameter-already-assigned` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20parameter-already-assigned) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1364) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for calls which provide more than one argument for a single parameter. @@ -1389,11 +1481,13 @@ f(1, x=2) # Error raised here ## `positional-only-parameter-as-kwarg` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20positional-only-parameter-as-kwarg) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1599) +Default level: error · +Added in 0.0.1-alpha.22 · +Related issues · +View source + **What it does** Checks for keyword arguments in calls that match positional-only parameters of the callable. @@ -1414,11 +1508,13 @@ f(x=1) # Error raised here ## `raw-string-type-annotation` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20raw-string-type-annotation) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L61) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for raw-strings in type annotation positions. @@ -1443,11 +1539,13 @@ def test(): -> "int": ## `static-assert-error` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20static-assert-error) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1721) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Makes sure that the argument of `static_assert` is statically known to be true. @@ -1471,11 +1569,13 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr ## `subclass-of-final-class` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20subclass-of-final-class) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1455) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for classes that subclass final classes. @@ -1498,11 +1598,13 @@ class B(A): ... # Error raised here ## `too-many-positional-arguments` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20too-many-positional-arguments) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1500) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for calls that pass more positional arguments than the callable can accept. @@ -1523,11 +1625,13 @@ f("foo") # Error raised here ## `type-assertion-failure` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20type-assertion-failure) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1478) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for `assert_type()` and `assert_never()` calls where the actual type @@ -1549,11 +1653,13 @@ def _(x: int): ## `unavailable-implicit-super-arguments` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unavailable-implicit-super-arguments) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1521) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Detects invalid `super()` calls where implicit arguments like the enclosing class or first method argument are unavailable. @@ -1593,11 +1699,13 @@ class A: ## `unknown-argument` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unknown-argument) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1578) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for keyword arguments in calls that don't match any parameter of the callable. @@ -1618,11 +1726,13 @@ f(x=1, y=2) # Error raised here ## `unresolved-attribute` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-attribute) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1620) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for unresolved attributes. @@ -1644,11 +1754,13 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' ## `unresolved-import` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-import) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1642) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for import statements for which the module cannot be resolved. @@ -1667,11 +1779,13 @@ import foo # ModuleNotFoundError: No module named 'foo' ## `unresolved-reference` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-reference) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1661) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for references to names that are not defined. @@ -1690,11 +1804,13 @@ print(x) # NameError: name 'x' is not defined ## `unsupported-bool-conversion` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-bool-conversion) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1333) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for bool conversions where the object doesn't correctly implement `__bool__`. @@ -1725,11 +1841,13 @@ b1 < b2 < b1 # exception raised here ## `unsupported-operator` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-operator) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1680) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for binary expressions, comparisons, and unary expressions where @@ -1751,11 +1869,13 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' ## `zero-stepsize-in-slice` -Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20zero-stepsize-in-slice) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1702) +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for step size 0 in slices. @@ -1774,11 +1894,13 @@ l[1:10:0] # ValueError: slice step cannot be zero ## `ambiguous-protocol-member` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20ambiguous-protocol-member) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L464) +Default level: warn · +Added in 0.0.1-alpha.20 · +Related issues · +View source + **What it does** Checks for protocol classes with members that will lead to ambiguous interfaces. @@ -1813,11 +1935,13 @@ class SubProto(BaseProto, Protocol): ## `deprecated` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20deprecated) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L280) +Default level: warn · +Added in 0.0.1-alpha.16 · +Related issues · +View source + **What it does** Checks for uses of deprecated items @@ -1838,11 +1962,13 @@ old_func() # emits [deprecated] diagnostic ## `invalid-ignore-comment` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-ignore-comment) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Fsuppression.rs#L65) +Default level: warn · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for `type: ignore` and `ty: ignore` comments that are syntactically incorrect. @@ -1866,11 +1992,13 @@ a = 20 / 0 # type: ignore ## `possibly-missing-attribute` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-missing-attribute) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1385) +Default level: warn · +Added in 0.0.1-alpha.22 · +Related issues · +View source + **What it does** Checks for possibly missing attributes. @@ -1892,11 +2020,13 @@ A.c # AttributeError: type object 'A' has no attribute 'c' ## `possibly-missing-implicit-call` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-missing-implicit-call) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L133) +Default level: warn · +Added in 0.0.1-alpha.22 · +Related issues · +View source + **What it does** Checks for implicit calls to possibly missing methods. @@ -1922,11 +2052,13 @@ A()[0] # TypeError: 'A' object is not subscriptable ## `possibly-missing-import` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-missing-import) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1407) +Default level: warn · +Added in 0.0.1-alpha.22 · +Related issues · +View source + **What it does** Checks for imports of symbols that may be missing. @@ -1952,11 +2084,13 @@ from module import a # ImportError: cannot import name 'a' from 'module' ## `redundant-cast` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20redundant-cast) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1773) +Default level: warn · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Detects redundant `cast` calls where the value already has the target type. @@ -1977,11 +2111,13 @@ cast(int, f()) # Redundant ## `undefined-reveal` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20undefined-reveal) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1560) +Default level: warn · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for calls to `reveal_type` without importing it. @@ -1999,11 +2135,13 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined ## `unknown-rule` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unknown-rule) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Fsuppression.rs#L40) +Default level: warn · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for `ty: ignore[code]` where `code` isn't a known lint rule. @@ -2028,11 +2166,13 @@ a = 20 / 0 # ty: ignore[division-by-zero] ## `unresolved-global` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-global) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1794) +Default level: warn · +Added in 0.0.1-alpha.15 · +Related issues · +View source + **What it does** Detects variables declared as `global` in an inner scope that have no explicit @@ -2056,6 +2196,7 @@ def g(): ``` Use instead: + ```python x: int @@ -2068,6 +2209,7 @@ def g(): ``` Or: + ```python x: int | None = None @@ -2082,11 +2224,13 @@ def g(): ## `unsupported-base` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-base) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L704) +Default level: warn · +Added in 0.0.1-alpha.7 · +Related issues · +View source + **What it does** Checks for class definitions that have bases which are unsupported by ty. @@ -2119,11 +2263,13 @@ class D(C): ... # error: [unsupported-base] ## `useless-overload-body` -Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20useless-overload-body) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L962) +Default level: warn · +Added in 0.0.1-alpha.22 · +Related issues · +View source + **What it does** Checks for various `@overload`-decorated functions that have non-stub bodies. @@ -2180,11 +2326,13 @@ def foo(x: int | str) -> int | str: ## `division-by-zero` -Default level: [`ignore`](../rules.md#rule-levels "This lint has a default level of 'ignore'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20division-by-zero) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L262) +Default level: ignore · +Preview (since 0.0.1-alpha.1) · +Related issues · +View source + **What it does** It detects division by zero. @@ -2202,11 +2350,13 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. ## `possibly-unresolved-reference` -Default level: [`ignore`](../rules.md#rule-levels "This lint has a default level of 'ignore'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unresolved-reference) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1433) +Default level: ignore · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for references to names that are possibly not defined. @@ -2228,11 +2378,13 @@ print(x) # NameError: name 'x' is not defined ## `unused-ignore-comment` -Default level: [`ignore`](../rules.md#rule-levels "This lint has a default level of 'ignore'.") · -[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unused-ignore-comment) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Fsuppression.rs#L15) +Default level: ignore · +Added in 0.0.1-alpha.1 · +Related issues · +View source + **What it does** Checks for `type: ignore` or `ty: ignore` directives that are no longer applicable. diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index d0f5447cc0..cf33190c82 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -32,7 +32,7 @@ declare_lint! { /// ``` pub(crate) static UNUSED_IGNORE_COMMENT = { summary: "detects unused `type: ignore` comments", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Ignore, } } @@ -57,7 +57,7 @@ declare_lint! { /// ``` pub(crate) static UNKNOWN_RULE = { summary: "detects `ty: ignore` comments that reference unknown rules", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Warn, } } @@ -81,7 +81,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_IGNORE_COMMENT = { summary: "detects ignore comments that use invalid syntax", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Warn, } } diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 982c76672d..cb1b5b6a0d 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -125,7 +125,7 @@ declare_lint! { /// ``` pub(crate) static CALL_NON_CALLABLE = { summary: "detects calls to non-callable objects", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -151,7 +151,7 @@ declare_lint! { /// ``` pub(crate) static POSSIBLY_MISSING_IMPLICIT_CALL = { summary: "detects implicit calls to possibly missing methods", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.22"), default_level: Level::Warn, } } @@ -177,7 +177,7 @@ declare_lint! { /// ``` pub(crate) static CONFLICTING_ARGUMENT_FORMS = { summary: "detects when an argument is used as both a value and a type form in a call", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -202,7 +202,7 @@ declare_lint! { /// ``` pub(crate) static CONFLICTING_DECLARATIONS = { summary: "detects conflicting declarations", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -228,7 +228,7 @@ declare_lint! { /// ``` pub(crate) static CONFLICTING_METACLASS = { summary: "detects conflicting metaclasses", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -254,7 +254,7 @@ declare_lint! { /// [method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order pub(crate) static CYCLIC_CLASS_DEFINITION = { summary: "detects cyclic class definitions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -272,7 +272,7 @@ declare_lint! { /// ``` pub(crate) static DIVISION_BY_ZERO = { summary: "detects division by zero", - status: LintStatus::preview("1.0.0"), + status: LintStatus::preview("0.0.1-alpha.1"), default_level: Level::Ignore, } } @@ -293,7 +293,7 @@ declare_lint! { /// ``` pub(crate) static DEPRECATED = { summary: "detects uses of deprecated items", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.16"), default_level: Level::Warn, } } @@ -314,7 +314,7 @@ declare_lint! { /// ``` pub(crate) static DUPLICATE_BASE = { summary: "detects class definitions with duplicate bases", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -346,7 +346,7 @@ declare_lint! { /// ``` pub(crate) static DUPLICATE_KW_ONLY = { summary: "detects dataclass definitions with more than one usage of `KW_ONLY`", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.12"), default_level: Level::Error, } } @@ -428,7 +428,7 @@ declare_lint! { /// [Method Resolution Order]: https://docs.python.org/3/glossary.html#term-method-resolution-order pub(crate) static INSTANCE_LAYOUT_CONFLICT = { summary: "detects class definitions that raise `TypeError` due to instance layout conflict", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.12"), default_level: Level::Error, } } @@ -456,11 +456,12 @@ declare_lint! { /// ``` pub(crate) static INVALID_PROTOCOL = { summary: "detects invalid protocol class definitions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } +// Added in #17750. declare_lint! { /// ## What it does /// Checks for protocol classes with members that will lead to ambiguous interfaces. @@ -491,7 +492,7 @@ declare_lint! { /// ``` pub(crate) static AMBIGUOUS_PROTOCOL_MEMBER = { summary: "detects protocol classes with ambiguous interfaces", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.20"), default_level: Level::Warn, } } @@ -517,7 +518,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_NAMED_TUPLE = { summary: "detects invalid `NamedTuple` class definitions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.19"), default_level: Level::Error, } } @@ -541,7 +542,7 @@ declare_lint! { /// [method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order pub(crate) static INCONSISTENT_MRO = { summary: "detects class definitions with an inconsistent MRO", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -561,11 +562,12 @@ declare_lint! { /// ``` pub(crate) static INDEX_OUT_OF_BOUNDS = { summary: "detects index out of bounds errors", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } +// Added in #19763. declare_lint! { /// ## What it does /// Checks for subscript accesses with invalid keys. @@ -586,7 +588,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_KEY = { summary: "detects invalid subscript accesses", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.17"), default_level: Level::Error, } } @@ -607,7 +609,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_ARGUMENT_TYPE = { summary: "detects call arguments whose type is not assignable to the corresponding typed parameter", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -626,7 +628,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_RETURN_TYPE = { summary: "detects returned values that can't be assigned to the function's annotated return type", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -648,7 +650,7 @@ declare_lint! { /// [assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable pub(crate) static INVALID_ASSIGNMENT = { summary: "detects invalid assignments", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -678,7 +680,7 @@ declare_lint! { /// [Awaitable]: https://docs.python.org/3/library/collections.abc.html#collections.abc.Awaitable pub(crate) static INVALID_AWAIT = { summary: "detects awaiting on types that don't support it", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.19"), default_level: Level::Error, } } @@ -696,7 +698,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_BASE = { summary: "detects class bases that will cause the class definition to raise an exception at runtime", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -729,7 +731,7 @@ declare_lint! { /// [method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order pub(crate) static UNSUPPORTED_BASE = { summary: "detects class bases that are unsupported as ty could not feasibly calculate the class's MRO", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.7"), default_level: Level::Warn, } } @@ -750,7 +752,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_CONTEXT_MANAGER = { summary: "detects expressions used in with statements that don't implement the context manager protocol", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -773,7 +775,7 @@ declare_lint! { /// [assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable pub(crate) static INVALID_DECLARATION = { summary: "detects invalid declarations", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -809,7 +811,7 @@ declare_lint! { /// This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes) pub(crate) static INVALID_EXCEPTION_CAUGHT = { summary: "detects exception handlers that catch classes that do not inherit from `BaseException`", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -835,7 +837,7 @@ declare_lint! { /// - [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction) pub(crate) static INVALID_GENERIC_CLASS = { summary: "detects invalid generic classes", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -863,7 +865,7 @@ declare_lint! { /// - [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction) pub(crate) static INVALID_LEGACY_TYPE_VARIABLE = { summary: "detects invalid legacy type variables", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -884,7 +886,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_TYPE_ALIAS_TYPE = { summary: "detects invalid TypeAliasType definitions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.6"), default_level: Level::Error, } } @@ -911,7 +913,7 @@ declare_lint! { /// - [Python documentation: Metaclasses](https://docs.python.org/3/reference/datamodel.html#metaclasses) pub(crate) static INVALID_METACLASS = { summary: "detects invalid `metaclass=` arguments", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -954,7 +956,7 @@ declare_lint! { /// - [Python documentation: `@overload`](https://docs.python.org/3/library/typing.html#typing.overload) pub(crate) static INVALID_OVERLOAD = { summary: "detects invalid `@overload` usages", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1010,7 +1012,7 @@ declare_lint! { /// - [Python documentation: `@overload`](https://docs.python.org/3/library/typing.html#typing.overload) pub(crate) static USELESS_OVERLOAD_BODY = { summary: "detects `@overload`-decorated functions with non-stub bodies", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.22"), default_level: Level::Warn, } } @@ -1030,7 +1032,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_PARAMETER_DEFAULT = { summary: "detects default values that can't be assigned to the parameter's annotated type", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1073,7 +1075,7 @@ declare_lint! { /// - [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions) pub(crate) static INVALID_RAISE = { summary: "detects `raise` statements that raise invalid exceptions or use invalid causes", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1112,7 +1114,7 @@ declare_lint! { /// - [Python documentation: super()](https://docs.python.org/3/library/functions.html#super) pub(crate) static INVALID_SUPER_ARGUMENT = { summary: "detects invalid arguments for `super()`", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1136,7 +1138,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_TYPE_CHECKING_CONSTANT = { summary: "detects invalid `TYPE_CHECKING` constant assignments", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1160,7 +1162,7 @@ declare_lint! { /// [type expressions]: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions pub(crate) static INVALID_TYPE_FORM = { summary: "detects invalid type forms", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1188,7 +1190,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_TYPE_GUARD_DEFINITION = { summary: "detects malformed type guard functions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.11"), default_level: Level::Error, } } @@ -1216,7 +1218,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_TYPE_GUARD_CALL = { summary: "detects type guard function calls that has no narrowing effect", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.11"), default_level: Level::Error, } } @@ -1245,7 +1247,7 @@ declare_lint! { /// [type variables]: https://docs.python.org/3/library/typing.html#typing.TypeVar pub(crate) static INVALID_TYPE_VARIABLE_CONSTRAINTS = { summary: "detects invalid type variable constraints", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1264,7 +1266,7 @@ declare_lint! { /// ``` pub(crate) static MISSING_ARGUMENT = { summary: "detects missing required arguments in a call", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1287,7 +1289,7 @@ declare_lint! { /// ``` pub(crate) static NO_MATCHING_OVERLOAD = { summary: "detects calls that do not match any overload", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1305,7 +1307,7 @@ declare_lint! { /// ``` pub(crate) static NON_SUBSCRIPTABLE = { summary: "detects subscripting objects that do not support subscripting", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1325,7 +1327,7 @@ declare_lint! { /// ``` pub(crate) static NOT_ITERABLE = { summary: "detects iteration over an object that is not iterable", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1356,7 +1358,7 @@ declare_lint! { /// ``` pub(crate) static UNSUPPORTED_BOOL_CONVERSION = { summary: "detects boolean conversion where the object incorrectly implements `__bool__`", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1377,7 +1379,7 @@ declare_lint! { /// ``` pub(crate) static PARAMETER_ALREADY_ASSIGNED = { summary: "detects multiple arguments for the same parameter", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1399,7 +1401,7 @@ declare_lint! { /// ``` pub(crate) static POSSIBLY_MISSING_ATTRIBUTE = { summary: "detects references to possibly missing attributes", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.22"), default_level: Level::Warn, } } @@ -1425,7 +1427,7 @@ declare_lint! { /// ``` pub(crate) static POSSIBLY_MISSING_IMPORT = { summary: "detects possibly missing imports", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.22"), default_level: Level::Warn, } } @@ -1447,7 +1449,7 @@ declare_lint! { /// ``` pub(crate) static POSSIBLY_UNRESOLVED_REFERENCE = { summary: "detects references to possibly undefined names", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Ignore, } } @@ -1470,7 +1472,7 @@ declare_lint! { /// ``` pub(crate) static SUBCLASS_OF_FINAL_CLASS = { summary: "detects subclasses of final classes", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1492,7 +1494,7 @@ declare_lint! { /// ``` pub(crate) static TYPE_ASSERTION_FAILURE = { summary: "detects failed type assertions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1513,7 +1515,7 @@ declare_lint! { /// ``` pub(crate) static TOO_MANY_POSITIONAL_ARGUMENTS = { summary: "detects calls passing too many positional arguments", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1552,7 +1554,7 @@ declare_lint! { /// - [Python documentation: super()](https://docs.python.org/3/library/functions.html#super) pub(crate) static UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS = { summary: "detects invalid `super()` calls where implicit arguments are unavailable.", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1570,7 +1572,7 @@ declare_lint! { /// ``` pub static UNDEFINED_REVEAL = { summary: "detects usages of `reveal_type` without importing it", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Warn, } } @@ -1591,7 +1593,7 @@ declare_lint! { /// ``` pub(crate) static UNKNOWN_ARGUMENT = { summary: "detects unknown keyword arguments in calls", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1612,7 +1614,7 @@ declare_lint! { /// ``` pub(crate) static POSITIONAL_ONLY_PARAMETER_AS_KWARG = { summary: "detects positional-only parameters passed as keyword arguments", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.22"), default_level: Level::Error, } } @@ -1634,7 +1636,7 @@ declare_lint! { /// ``` pub(crate) static UNRESOLVED_ATTRIBUTE = { summary: "detects references to unresolved attributes", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1653,7 +1655,7 @@ declare_lint! { /// ``` pub(crate) static UNRESOLVED_IMPORT = { summary: "detects unresolved imports", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1672,7 +1674,7 @@ declare_lint! { /// ``` pub(crate) static UNRESOLVED_REFERENCE = { summary: "detects references to names that are not defined", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1694,7 +1696,7 @@ declare_lint! { /// ``` pub(crate) static UNSUPPORTED_OPERATOR = { summary: "detects binary, unary, or comparison expressions where the operands don't support the operator", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1713,7 +1715,7 @@ declare_lint! { /// ``` pub(crate) static ZERO_STEPSIZE_IN_SLICE = { summary: "detects a slice step size of zero", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1737,7 +1739,7 @@ declare_lint! { /// ``` pub(crate) static STATIC_ASSERT_ERROR = { summary: "Failed static assertion", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1765,7 +1767,7 @@ declare_lint! { /// ``` pub(crate) static INVALID_ATTRIBUTE_ACCESS = { summary: "Invalid attribute access", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -1786,7 +1788,7 @@ declare_lint! { /// ``` pub(crate) static REDUNDANT_CAST = { summary: "detects redundant `cast` calls", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Warn, } } @@ -1812,6 +1814,7 @@ declare_lint! { /// ``` /// /// Use instead: + /// /// ```python /// x: int /// @@ -1824,6 +1827,7 @@ declare_lint! { /// ``` /// /// Or: + /// /// ```python /// x: int | None = None /// @@ -1836,7 +1840,7 @@ declare_lint! { /// ``` pub(crate) static UNRESOLVED_GLOBAL = { summary: "detects `global` statements with no definition in the global scope", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.15"), default_level: Level::Warn, } } @@ -1863,7 +1867,7 @@ declare_lint! { /// ``` pub(crate) static MISSING_TYPED_DICT_KEY = { summary: "detects missing required keys in `TypedDict` constructors", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.20"), default_level: Level::Error, } } diff --git a/crates/ty_python_semantic/src/types/string_annotation.rs b/crates/ty_python_semantic/src/types/string_annotation.rs index 69f312f36a..7ea673f3aa 100644 --- a/crates/ty_python_semantic/src/types/string_annotation.rs +++ b/crates/ty_python_semantic/src/types/string_annotation.rs @@ -28,7 +28,7 @@ declare_lint! { /// ``` pub(crate) static FSTRING_TYPE_ANNOTATION = { summary: "detects F-strings in type annotation positions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -53,7 +53,7 @@ declare_lint! { /// ``` pub(crate) static BYTE_STRING_TYPE_ANNOTATION = { summary: "detects byte strings in type annotation positions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -78,7 +78,7 @@ declare_lint! { /// ``` pub(crate) static RAW_STRING_TYPE_ANNOTATION = { summary: "detects raw strings in type annotation positions", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -103,7 +103,7 @@ declare_lint! { /// ``` pub(crate) static IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION = { summary: "detects implicit concatenated strings in type annotations", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -112,7 +112,7 @@ declare_lint! { /// TODO #14889 pub(crate) static INVALID_SYNTAX_IN_FORWARD_ANNOTATION = { summary: "detects invalid syntax in forward annotations", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } @@ -121,7 +121,7 @@ declare_lint! { /// TODO #14889 pub(crate) static ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION = { summary: "detects forward type annotations with escape characters", - status: LintStatus::preview("1.0.0"), + status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } } diff --git a/ty.schema.json b/ty.schema.json index 50d96233fb..5e3323b517 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -947,7 +947,7 @@ }, "unresolved-global": { "title": "detects `global` statements with no definition in the global scope", - "description": "## What it does\nDetects variables declared as `global` in an inner scope that have no explicit\nbindings or declarations in the global scope.\n\n## Why is this bad?\nFunction bodies with `global` statements can run in any order (or not at all), which makes\nit hard for static analysis tools to infer the types of globals without\nexplicit definitions or declarations.\n\n## Example\n```python\ndef f():\n global x # unresolved global\n x = 42\n\ndef g():\n print(x) # unresolved reference\n```\n\nUse instead:\n```python\nx: int\n\ndef f():\n global x\n x = 42\n\ndef g():\n print(x)\n```\n\nOr:\n```python\nx: int | None = None\n\ndef f():\n global x\n x = 42\n\ndef g():\n print(x)\n```", + "description": "## What it does\nDetects variables declared as `global` in an inner scope that have no explicit\nbindings or declarations in the global scope.\n\n## Why is this bad?\nFunction bodies with `global` statements can run in any order (or not at all), which makes\nit hard for static analysis tools to infer the types of globals without\nexplicit definitions or declarations.\n\n## Example\n```python\ndef f():\n global x # unresolved global\n x = 42\n\ndef g():\n print(x) # unresolved reference\n```\n\nUse instead:\n\n```python\nx: int\n\ndef f():\n global x\n x = 42\n\ndef g():\n print(x)\n```\n\nOr:\n\n```python\nx: int | None = None\n\ndef f():\n global x\n x = 42\n\ndef g():\n print(x)\n```", "default": "warn", "oneOf": [ { From 9090aead0f963be9bd6d14c388a6cafc3cc17893 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Tue, 14 Oct 2025 13:48:47 +0100 Subject: [PATCH 033/113] [ty] Fix further issues in `super()` inference logic (#20843) --- .../resources/mdtest/class/super.md | 22 ++ ...licit_Super_Objec…_(b753048091f275c0).snap | 288 ++++++++++-------- ...licit_Super_Objec…_(f9e5e48e3a4a4c12).snap | 1 + crates/ty_python_semantic/src/types.rs | 6 + .../src/types/bound_super.rs | 37 ++- 5 files changed, 212 insertions(+), 142 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/class/super.md b/crates/ty_python_semantic/resources/mdtest/class/super.md index b943e4f21b..933e43bbd1 100644 --- a/crates/ty_python_semantic/resources/mdtest/class/super.md +++ b/crates/ty_python_semantic/resources/mdtest/class/super.md @@ -25,6 +25,8 @@ python-version = "3.12" ``` ```py +from __future__ import annotations + class A: def a(self): ... aa: int = 1 @@ -116,6 +118,26 @@ def _(x: object, y: SomeTypedDict, z: Callable[[int, str], bool]): # revealed: , dict[Literal["x", "y"], int | bytes]> reveal_type(super(object, y)) + +# The first argument to `super()` must be an actual class object; +# instances of `GenericAlias` are not accepted at runtime: +# +# error: [invalid-super-argument] +# revealed: Unknown +reveal_type(super(list[int], [])) +``` + +`super(pivot_class, owner)` can be called from inside methods, just like single-argument `super()`: + +```py +class Super: + def method(self) -> int: + return 42 + +class Sub(Super): + def method(self: Sub) -> int: + # revealed: , Sub> + return reveal_type(super(self.__class__, self)).method() ``` ### Implicit Super Object diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap index 339c9a59a7..014d05eff4 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap @@ -12,104 +12,121 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/class/super.md ## mdtest_snippet.py ``` - 1 | class A: - 2 | def a(self): ... - 3 | aa: int = 1 - 4 | - 5 | class B(A): - 6 | def b(self): ... - 7 | bb: int = 2 - 8 | - 9 | class C(B): -10 | def c(self): ... -11 | cc: int = 3 -12 | -13 | reveal_type(C.__mro__) # revealed: tuple[, , , ] -14 | -15 | super(C, C()).a -16 | super(C, C()).b -17 | super(C, C()).c # error: [unresolved-attribute] -18 | -19 | super(B, C()).a -20 | super(B, C()).b # error: [unresolved-attribute] -21 | super(B, C()).c # error: [unresolved-attribute] -22 | -23 | super(A, C()).a # error: [unresolved-attribute] -24 | super(A, C()).b # error: [unresolved-attribute] -25 | super(A, C()).c # error: [unresolved-attribute] -26 | -27 | reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown -28 | reveal_type(super(C, C()).b) # revealed: bound method C.b() -> Unknown -29 | reveal_type(super(C, C()).aa) # revealed: int -30 | reveal_type(super(C, C()).bb) # revealed: int -31 | import types -32 | from typing_extensions import Callable, TypeIs, Literal, TypedDict -33 | -34 | def f(): ... -35 | -36 | class Foo[T]: -37 | def method(self): ... -38 | @property -39 | def some_property(self): ... -40 | -41 | type Alias = int -42 | -43 | class SomeTypedDict(TypedDict): -44 | x: int -45 | y: bytes -46 | -47 | # revealed: , FunctionType> -48 | reveal_type(super(object, f)) -49 | # revealed: , WrapperDescriptorType> -50 | reveal_type(super(object, types.FunctionType.__get__)) -51 | # revealed: , GenericAlias> -52 | reveal_type(super(object, Foo[int])) -53 | # revealed: , _SpecialForm> -54 | reveal_type(super(object, Literal)) -55 | # revealed: , TypeAliasType> -56 | reveal_type(super(object, Alias)) -57 | # revealed: , MethodType> -58 | reveal_type(super(object, Foo().method)) -59 | # revealed: , property> -60 | reveal_type(super(object, Foo.some_property)) -61 | -62 | def g(x: object) -> TypeIs[list[object]]: -63 | return isinstance(x, list) -64 | -65 | def _(x: object, y: SomeTypedDict, z: Callable[[int, str], bool]): -66 | if hasattr(x, "bar"): -67 | # revealed: -68 | reveal_type(x) -69 | # error: [invalid-super-argument] -70 | # revealed: Unknown -71 | reveal_type(super(object, x)) -72 | -73 | # error: [invalid-super-argument] -74 | # revealed: Unknown -75 | reveal_type(super(object, z)) -76 | -77 | is_list = g(x) -78 | # revealed: TypeIs[list[object] @ x] -79 | reveal_type(is_list) -80 | # revealed: , bool> -81 | reveal_type(super(object, is_list)) -82 | -83 | # revealed: , dict[Literal["x", "y"], int | bytes]> -84 | reveal_type(super(object, y)) + 1 | from __future__ import annotations + 2 | + 3 | class A: + 4 | def a(self): ... + 5 | aa: int = 1 + 6 | + 7 | class B(A): + 8 | def b(self): ... + 9 | bb: int = 2 + 10 | + 11 | class C(B): + 12 | def c(self): ... + 13 | cc: int = 3 + 14 | + 15 | reveal_type(C.__mro__) # revealed: tuple[, , , ] + 16 | + 17 | super(C, C()).a + 18 | super(C, C()).b + 19 | super(C, C()).c # error: [unresolved-attribute] + 20 | + 21 | super(B, C()).a + 22 | super(B, C()).b # error: [unresolved-attribute] + 23 | super(B, C()).c # error: [unresolved-attribute] + 24 | + 25 | super(A, C()).a # error: [unresolved-attribute] + 26 | super(A, C()).b # error: [unresolved-attribute] + 27 | super(A, C()).c # error: [unresolved-attribute] + 28 | + 29 | reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown + 30 | reveal_type(super(C, C()).b) # revealed: bound method C.b() -> Unknown + 31 | reveal_type(super(C, C()).aa) # revealed: int + 32 | reveal_type(super(C, C()).bb) # revealed: int + 33 | import types + 34 | from typing_extensions import Callable, TypeIs, Literal, TypedDict + 35 | + 36 | def f(): ... + 37 | + 38 | class Foo[T]: + 39 | def method(self): ... + 40 | @property + 41 | def some_property(self): ... + 42 | + 43 | type Alias = int + 44 | + 45 | class SomeTypedDict(TypedDict): + 46 | x: int + 47 | y: bytes + 48 | + 49 | # revealed: , FunctionType> + 50 | reveal_type(super(object, f)) + 51 | # revealed: , WrapperDescriptorType> + 52 | reveal_type(super(object, types.FunctionType.__get__)) + 53 | # revealed: , GenericAlias> + 54 | reveal_type(super(object, Foo[int])) + 55 | # revealed: , _SpecialForm> + 56 | reveal_type(super(object, Literal)) + 57 | # revealed: , TypeAliasType> + 58 | reveal_type(super(object, Alias)) + 59 | # revealed: , MethodType> + 60 | reveal_type(super(object, Foo().method)) + 61 | # revealed: , property> + 62 | reveal_type(super(object, Foo.some_property)) + 63 | + 64 | def g(x: object) -> TypeIs[list[object]]: + 65 | return isinstance(x, list) + 66 | + 67 | def _(x: object, y: SomeTypedDict, z: Callable[[int, str], bool]): + 68 | if hasattr(x, "bar"): + 69 | # revealed: + 70 | reveal_type(x) + 71 | # error: [invalid-super-argument] + 72 | # revealed: Unknown + 73 | reveal_type(super(object, x)) + 74 | + 75 | # error: [invalid-super-argument] + 76 | # revealed: Unknown + 77 | reveal_type(super(object, z)) + 78 | + 79 | is_list = g(x) + 80 | # revealed: TypeIs[list[object] @ x] + 81 | reveal_type(is_list) + 82 | # revealed: , bool> + 83 | reveal_type(super(object, is_list)) + 84 | + 85 | # revealed: , dict[Literal["x", "y"], int | bytes]> + 86 | reveal_type(super(object, y)) + 87 | + 88 | # The first argument to `super()` must be an actual class object; + 89 | # instances of `GenericAlias` are not accepted at runtime: + 90 | # + 91 | # error: [invalid-super-argument] + 92 | # revealed: Unknown + 93 | reveal_type(super(list[int], [])) + 94 | class Super: + 95 | def method(self) -> int: + 96 | return 42 + 97 | + 98 | class Sub(Super): + 99 | def method(self: Sub) -> int: +100 | # revealed: , Sub> +101 | return reveal_type(super(self.__class__, self)).method() ``` # Diagnostics ``` error[unresolved-attribute]: Type `, C>` has no attribute `c` - --> src/mdtest_snippet.py:17:1 + --> src/mdtest_snippet.py:19:1 | -15 | super(C, C()).a -16 | super(C, C()).b -17 | super(C, C()).c # error: [unresolved-attribute] +17 | super(C, C()).a +18 | super(C, C()).b +19 | super(C, C()).c # error: [unresolved-attribute] | ^^^^^^^^^^^^^^^ -18 | -19 | super(B, C()).a +20 | +21 | super(B, C()).a | info: rule `unresolved-attribute` is enabled by default @@ -117,12 +134,12 @@ info: rule `unresolved-attribute` is enabled by default ``` error[unresolved-attribute]: Type `, C>` has no attribute `b` - --> src/mdtest_snippet.py:20:1 + --> src/mdtest_snippet.py:22:1 | -19 | super(B, C()).a -20 | super(B, C()).b # error: [unresolved-attribute] +21 | super(B, C()).a +22 | super(B, C()).b # error: [unresolved-attribute] | ^^^^^^^^^^^^^^^ -21 | super(B, C()).c # error: [unresolved-attribute] +23 | super(B, C()).c # error: [unresolved-attribute] | info: rule `unresolved-attribute` is enabled by default @@ -130,14 +147,14 @@ info: rule `unresolved-attribute` is enabled by default ``` error[unresolved-attribute]: Type `, C>` has no attribute `c` - --> src/mdtest_snippet.py:21:1 + --> src/mdtest_snippet.py:23:1 | -19 | super(B, C()).a -20 | super(B, C()).b # error: [unresolved-attribute] -21 | super(B, C()).c # error: [unresolved-attribute] +21 | super(B, C()).a +22 | super(B, C()).b # error: [unresolved-attribute] +23 | super(B, C()).c # error: [unresolved-attribute] | ^^^^^^^^^^^^^^^ -22 | -23 | super(A, C()).a # error: [unresolved-attribute] +24 | +25 | super(A, C()).a # error: [unresolved-attribute] | info: rule `unresolved-attribute` is enabled by default @@ -145,14 +162,14 @@ info: rule `unresolved-attribute` is enabled by default ``` error[unresolved-attribute]: Type `, C>` has no attribute `a` - --> src/mdtest_snippet.py:23:1 + --> src/mdtest_snippet.py:25:1 | -21 | super(B, C()).c # error: [unresolved-attribute] -22 | -23 | super(A, C()).a # error: [unresolved-attribute] +23 | super(B, C()).c # error: [unresolved-attribute] +24 | +25 | super(A, C()).a # error: [unresolved-attribute] | ^^^^^^^^^^^^^^^ -24 | super(A, C()).b # error: [unresolved-attribute] -25 | super(A, C()).c # error: [unresolved-attribute] +26 | super(A, C()).b # error: [unresolved-attribute] +27 | super(A, C()).c # error: [unresolved-attribute] | info: rule `unresolved-attribute` is enabled by default @@ -160,12 +177,12 @@ info: rule `unresolved-attribute` is enabled by default ``` error[unresolved-attribute]: Type `, C>` has no attribute `b` - --> src/mdtest_snippet.py:24:1 + --> src/mdtest_snippet.py:26:1 | -23 | super(A, C()).a # error: [unresolved-attribute] -24 | super(A, C()).b # error: [unresolved-attribute] +25 | super(A, C()).a # error: [unresolved-attribute] +26 | super(A, C()).b # error: [unresolved-attribute] | ^^^^^^^^^^^^^^^ -25 | super(A, C()).c # error: [unresolved-attribute] +27 | super(A, C()).c # error: [unresolved-attribute] | info: rule `unresolved-attribute` is enabled by default @@ -173,14 +190,14 @@ info: rule `unresolved-attribute` is enabled by default ``` error[unresolved-attribute]: Type `, C>` has no attribute `c` - --> src/mdtest_snippet.py:25:1 + --> src/mdtest_snippet.py:27:1 | -23 | super(A, C()).a # error: [unresolved-attribute] -24 | super(A, C()).b # error: [unresolved-attribute] -25 | super(A, C()).c # error: [unresolved-attribute] +25 | super(A, C()).a # error: [unresolved-attribute] +26 | super(A, C()).b # error: [unresolved-attribute] +27 | super(A, C()).c # error: [unresolved-attribute] | ^^^^^^^^^^^^^^^ -26 | -27 | reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown +28 | +29 | reveal_type(super(C, C()).a) # revealed: bound method C.a() -> Unknown | info: rule `unresolved-attribute` is enabled by default @@ -188,14 +205,14 @@ info: rule `unresolved-attribute` is enabled by default ``` error[invalid-super-argument]: `` is an abstract/structural type in `super(, )` call - --> src/mdtest_snippet.py:71:21 + --> src/mdtest_snippet.py:73:21 | -69 | # error: [invalid-super-argument] -70 | # revealed: Unknown -71 | reveal_type(super(object, x)) +71 | # error: [invalid-super-argument] +72 | # revealed: Unknown +73 | reveal_type(super(object, x)) | ^^^^^^^^^^^^^^^^ -72 | -73 | # error: [invalid-super-argument] +74 | +75 | # error: [invalid-super-argument] | info: rule `invalid-super-argument` is enabled by default @@ -203,14 +220,29 @@ info: rule `invalid-super-argument` is enabled by default ``` error[invalid-super-argument]: `(int, str, /) -> bool` is an abstract/structural type in `super(, (int, str, /) -> bool)` call - --> src/mdtest_snippet.py:75:17 + --> src/mdtest_snippet.py:77:17 | -73 | # error: [invalid-super-argument] -74 | # revealed: Unknown -75 | reveal_type(super(object, z)) +75 | # error: [invalid-super-argument] +76 | # revealed: Unknown +77 | reveal_type(super(object, z)) | ^^^^^^^^^^^^^^^^ -76 | -77 | is_list = g(x) +78 | +79 | is_list = g(x) + | +info: rule `invalid-super-argument` is enabled by default + +``` + +``` +error[invalid-super-argument]: `types.GenericAlias` instance `list[int]` is not a valid class + --> src/mdtest_snippet.py:93:13 + | +91 | # error: [invalid-super-argument] +92 | # revealed: Unknown +93 | reveal_type(super(list[int], [])) + | ^^^^^^^^^^^^^^^^^^^^ +94 | class Super: +95 | def method(self) -> int: | info: rule `invalid-super-argument` is enabled by default diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap index 846e4bdbb7..b13937e6e5 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Implicit_Super_Objec…_(f9e5e48e3a4a4c12).snap @@ -162,6 +162,7 @@ error[invalid-super-argument]: `S@method7` is not an instance or subclass of `` +help: Consider adding an upper bound to type variable `S` info: rule `invalid-super-argument` is enabled by default ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 410192de9b..7f9d182c69 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -7790,6 +7790,12 @@ pub enum TypeVarKind { TypingSelf, } +impl TypeVarKind { + const fn is_self(self) -> bool { + matches!(self, Self::TypingSelf) + } +} + /// The identity of a type variable. /// /// This represents the core identity of a typevar, independent of its bounds or constraints. Two diff --git a/crates/ty_python_semantic/src/types/bound_super.rs b/crates/ty_python_semantic/src/types/bound_super.rs index 07ce3fbc46..9bacb1454c 100644 --- a/crates/ty_python_semantic/src/types/bound_super.rs +++ b/crates/ty_python_semantic/src/types/bound_super.rs @@ -5,7 +5,7 @@ use ruff_db::diagnostic::Diagnostic; use ruff_python_ast::AnyNodeRef; use crate::{ - Db, + Db, DisplaySettings, place::{Place, PlaceAndQualifiers}, types::{ ClassBase, ClassType, DynamicType, IntersectionBuilder, KnownClass, MemberLookupPolicy, @@ -75,10 +75,16 @@ impl<'db> BoundSuperError<'db> { } BoundSuperError::InvalidPivotClassType { pivot_class } => { if let Some(builder) = context.report_lint(&INVALID_SUPER_ARGUMENT, node) { - builder.into_diagnostic(format_args!( - "`{pivot_class}` is not a valid class", - pivot_class = pivot_class.display(context.db()), - )); + match pivot_class { + Type::GenericAlias(alias) => builder.into_diagnostic(format_args!( + "`types.GenericAlias` instance `{}` is not a valid class", + alias.display_with(context.db(), DisplaySettings::default()), + )), + _ => builder.into_diagnostic(format_args!( + "`{pivot_class}` is not a valid class", + pivot_class = pivot_class.display(context.db()), + )), + }; } } BoundSuperError::FailingConditionCheck { @@ -102,6 +108,14 @@ impl<'db> BoundSuperError<'db> { bound_or_constraints_union.display(context.db()), pivot_class = pivot_class.display(context.db()), )); + if typevar_context.bound_or_constraints(context.db()).is_none() + && !typevar_context.kind(context.db()).is_self() + { + diagnostic.help(format_args!( + "Consider adding an upper bound to type variable `{}`", + typevar_context.name(context.db()) + )); + } } } } @@ -412,15 +426,10 @@ impl<'db> BoundSuperType<'db> { // but are valid as pivot classes, e.g. unsubscripted `typing.Generic` let pivot_class = match pivot_class_type { Type::ClassLiteral(class) => ClassBase::Class(ClassType::NonGeneric(class)), - Type::GenericAlias(class) => ClassBase::Class(ClassType::Generic(class)), - Type::SubclassOf(subclass_of) if subclass_of.subclass_of().is_dynamic() => { - ClassBase::Dynamic( - subclass_of - .subclass_of() - .into_dynamic() - .expect("Checked in branch arm"), - ) - } + Type::SubclassOf(subclass_of) => match subclass_of.subclass_of() { + SubclassOfInner::Class(class) => ClassBase::Class(class), + SubclassOfInner::Dynamic(dynamic) => ClassBase::Dynamic(dynamic), + }, Type::SpecialForm(SpecialFormType::Protocol) => ClassBase::Protocol, Type::SpecialForm(SpecialFormType::Generic) => ClassBase::Generic, Type::SpecialForm(SpecialFormType::TypedDict) => ClassBase::TypedDict, From 1ed9b215b981d0283022185f988957d22a02b918 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:14:59 -0400 Subject: [PATCH 034/113] Update Black tests (#20794) Summary -- ```shell git clone git@github.com:psf/black.git ../other/black crates/ruff_python_formatter/resources/test/fixtures/import_black_tests.py ../other/black ``` Then ran our tests and accepted the snapshots I had to make a small fix to our tuple normalization logic for `del` statements in the second commit, otherwise the tests were panicking at a changed AST. I think the new implementation is closer to the intention described in the nearby comment anyway, though. The first commit adds the new Python, settings, and `.expect` files, the next three commits make some small fixes to help get the tests running, and then the fifth commit accepts all but one of the new snapshots. The last commit includes the new unsupported syntax error for one f-string example, tracked in #20774. Test Plan -- Newly imported tests. I went through all of the new snapshots and added review comments below. I think they're all expected, except a few cases I wasn't 100% sure about. --- .../test/fixtures/black/cases/annotations.py | 7 + .../black/cases/annotations.py.expect | 7 + .../backslash_before_indent.options.json | 2 +- .../test/fixtures/black/cases/cantfit.py | 35 ++ .../fixtures/black/cases/cantfit.py.expect | 61 ++ .../cases/context_managers_38.options.json | 2 +- .../black/cases/context_managers_39.py | 6 + .../black/cases/context_managers_39.py.expect | 5 + .../fixtures/black/cases/docstring_newline.py | 3 + .../black/cases/docstring_newline.py.expect | 3 + ...p9.options.json => fmtskip10.options.json} | 0 .../test/fixtures/black/cases/fmtskip10.py | 8 + .../fixtures/black/cases/fmtskip10.py.expect | 8 + .../test/fixtures/black/cases/fmtskip11.py | 6 + .../fixtures/black/cases/fmtskip11.py.expect | 6 + .../black/cases/format_unicode_escape_seq.py | 15 + .../cases/format_unicode_escape_seq.py.expect | 15 + .../fixtures/black/cases/fstring.options.json | 1 - .../black/cases/fstring_quotations.py | 34 ++ .../black/cases/fstring_quotations.py.expect | 29 + ...ef_return_type_trailing_comma.options.json | 2 +- .../cases/generics_wrapping.options.json | 1 + .../fixtures/black/cases/generics_wrapping.py | 133 +++++ .../black/cases/generics_wrapping.py.expect | 170 ++++++ .../fixtures/black/cases/line_ranges_basic.py | 2 +- .../black/cases/line_ranges_basic.py.expect | 2 +- .../cases/line_ranges_decorator_edge_case.py | 8 + .../line_ranges_decorator_edge_case.py.expect | 8 + .../cases/long_strings__type_annotations.py | 28 + .../long_strings__type_annotations.py.expect | 26 + .../black/cases/module_docstring_2.py | 2 +- .../black/cases/module_docstring_2.py.expect | 2 +- ...dule_docstring_after_comment.options.json} | 0 .../cases/module_docstring_after_comment.py | 9 + .../module_docstring_after_comment.py.expect | 10 + .../no_blank_line_before_docstring.py.expect | 1 - ...pattern_matching_with_if_stmt.options.json | 2 +- .../cases/pep604_union_types_line_breaks.py | 2 +- .../pep604_union_types_line_breaks.py.expect | 2 +- ...typed_star_arg_type_var_tuple.options.json | 1 + .../pep646_typed_star_arg_type_var_tuple.py | 5 + ...46_typed_star_arg_type_var_tuple.py.expect | 5 + .../fixtures/black/cases/pep_572_py310.py | 5 + .../black/cases/pep_572_py310.py.expect | 5 + .../test/fixtures/black/cases/pep_701.py | 5 +- .../fixtures/black/cases/pep_701.py.expect | 4 +- .../cases/prefer_rhs_split_reformatted.py | 9 + .../prefer_rhs_split_reformatted.py.expect | 11 + .../black/cases/preview_comments7.py.expect | 1 - ...ions.json => preview_fstring.options.json} | 0 .../fixtures/black/cases/preview_fstring.py | 1 + .../black/cases/preview_fstring.py.expect | 1 + ...preview_import_line_collapse.options.json} | 0 .../cases/preview_import_line_collapse.py | 90 +++ .../preview_import_line_collapse.py.expect | 85 +++ .../black/cases/preview_long_dict_values.py | 100 +++- .../cases/preview_long_dict_values.py.expect | 98 ++- .../black/cases/preview_long_strings.py | 16 +- .../cases/preview_long_strings.py.expect | 54 +- .../cases/preview_long_strings__regression.py | 1 + ...preview_long_strings__regression.py.expect | 10 +- .../black/cases/preview_multiline_strings.py | 67 +++ .../cases/preview_multiline_strings.py.expect | 103 ++++ ...ltiline_lone_list_item_parens.options.json | 1 + ..._remove_multiline_lone_list_item_parens.py | 136 +++++ ..._multiline_lone_list_item_parens.py.expect | 106 ++++ ...preview_wrap_comprehension_in.options.json | 1 + .../cases/preview_wrap_comprehension_in.py | 69 +++ .../preview_wrap_comprehension_in.py.expect | 88 +++ .../test/fixtures/black/cases/python37.py | 1 + .../fixtures/black/cases/python37.py.expect | 1 + .../remove_except_types_parens.options.json | 1 + .../black/cases/remove_except_types_parens.py | 123 ++++ .../remove_except_types_parens.py.expect | 123 ++++ ...except_types_parens_pre_py314.options.json | 1 + .../remove_except_types_parens_pre_py314.py | 111 ++++ ...ve_except_types_parens_pre_py314.py.expect | 111 ++++ .../cases/remove_lone_list_item_parens.py | 80 +++ .../remove_lone_list_item_parens.py.expect | 74 +++ ...edundant_parens_in_case_guard.options.json | 2 +- .../black/cases/remove_with_brackets.py | 13 + .../cases/remove_with_brackets.py.expect | 13 + ...c_trailing_comma_generic_wrap.options.json | 1 + .../skip_magic_trailing_comma_generic_wrap.py | 97 +++ ...agic_trailing_comma_generic_wrap.py.expect | 62 ++ .../cases/target_version_flag.options.json | 1 + .../black/cases/target_version_flag.py | 4 + .../black/cases/target_version_flag.py.expect | 3 + .../fixtures/black/cases/tuple_with_stmt.py | 30 + .../black/cases/tuple_with_stmt.py.expect | 30 + .../black/cases/type_expansion.options.json | 1 + .../fixtures/black/cases/type_expansion.py | 13 + .../black/cases/type_expansion.py.expect | 42 ++ .../black/cases/type_param_defaults.py | 2 + .../black/cases/type_param_defaults.py.expect | 26 +- .../test/fixtures/black/cases/type_params.py | 4 + .../black/cases/type_params.py.expect | 10 + .../fixtures/black/cases/walrus_in_dict.py | 2 +- .../black/cases/walrus_in_dict.py.expect | 2 +- .../test/fixtures/import_black_tests.py | 4 +- .../ruff_python_formatter/tests/fixtures.rs | 17 +- .../ruff_python_formatter/tests/normalizer.rs | 20 +- ...black_compatibility@cases__cantfit.py.snap | 204 +++++++ ...ack_compatibility@cases__fmtskip10.py.snap | 76 +++ ...black_compatibility@cases__fstring.py.snap | 14 + ...tibility@cases__fstring_quotations.py.snap | 126 ++++ ...atibility@cases__generics_wrapping.py.snap | 561 ++++++++++++++++++ ...es__long_strings__type_annotations.py.snap | 114 ++++ ...es__no_blank_line_before_docstring.py.snap | 122 ---- ...es__pep604_union_types_line_breaks.py.snap | 7 +- ...black_compatibility@cases__pep_701.py.snap | 27 +- ...ases__prefer_rhs_split_reformatted.py.snap | 123 ++++ ...atibility@cases__preview_comments7.py.snap | 15 +- ...ases__preview_import_line_collapse.py.snap | 325 ++++++++++ ...ty@cases__preview_long_dict_values.py.snap | 336 ++++++++++- ...bility@cases__preview_long_strings.py.snap | 153 +++-- ...__preview_long_strings__regression.py.snap | 31 +- ...y@cases__preview_multiline_strings.py.snap | 300 +++++++++- ...ve_multiline_lone_list_item_parens.py.snap | 535 +++++++++++++++++ ...ses__preview_wrap_comprehension_in.py.snap | 335 +++++++++++ ...@cases__remove_except_types_parens.py.snap | 427 +++++++++++++ ...ases__remove_lone_list_item_parens.py.snap | 283 +++++++++ ...ibility@cases__type_param_defaults.py.snap | 70 +-- 123 files changed, 6607 insertions(+), 343 deletions(-) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py.expect rename crates/ruff_python_formatter/resources/test/fixtures/black/cases/{fmtskip9.options.json => fmtskip10.options.json} (100%) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py.expect delete mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py.expect rename crates/ruff_python_formatter/resources/test/fixtures/black/cases/{is_simple_lookup_for_doublestar_expression.options.json => module_docstring_after_comment.options.json} (100%) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py.expect rename crates/ruff_python_formatter/resources/test/fixtures/black/cases/{module_docstring_2.options.json => preview_fstring.options.json} (100%) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py.expect rename crates/ruff_python_formatter/resources/test/fixtures/black/cases/{typed_params_trailing_comma.options.json => preview_import_line_collapse.options.json} (100%) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py.expect create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py.expect create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__cantfit.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fmtskip10.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring_quotations.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__generics_wrapping.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__long_strings__type_annotations.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__no_blank_line_before_docstring.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__prefer_rhs_split_reformatted.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_import_line_collapse.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_remove_multiline_lone_list_item_parens.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_wrap_comprehension_in.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_lone_list_item_parens.py.snap diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py new file mode 100644 index 0000000000..acba780f0d --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py @@ -0,0 +1,7 @@ +# regression test for #1765 +class Foo: + def foo(self): + if True: + content_ids: Mapping[ + str, Optional[ContentId] + ] = self.publisher_content_store.store_config_contents(files) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py.expect new file mode 100644 index 0000000000..c256da26b7 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/annotations.py.expect @@ -0,0 +1,7 @@ +# regression test for #1765 +class Foo: + def foo(self): + if True: + content_ids: Mapping[str, Optional[ContentId]] = ( + self.publisher_content_store.store_config_contents(files) + ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/backslash_before_indent.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/backslash_before_indent.options.json index 54724b66c5..717b73130f 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/backslash_before_indent.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/backslash_before_indent.options.json @@ -1 +1 @@ -{"target_version": "3.10"} +{"target_version": "3.10"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py new file mode 100644 index 0000000000..df8ae398bf --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py @@ -0,0 +1,35 @@ +# long variable name +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 0 +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 1 # with a comment +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = [ + 1, 2, 3 +] +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function() +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + arg1, arg2, arg3 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +# long function name +normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying() +normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + arg1, arg2, arg3 +) +normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +string_variable_name = ( + "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do" # noqa +) +for key in """ + hostname + port + username +""".split(): + if key in self.connect_kwargs: + raise ValueError(err.format(key)) +concatenated_strings = "some strings that are " "concatenated implicitly, so if you put them on separate " "lines it will fit" +del concatenated_strings, string_variable_name, normal_function_name, normal_name, need_more_to_make_the_line_long_enough +del ([], name_1, name_2), [(), [], name_4, name_3], name_1[[name_2 for name_1 in name_0]] +del (), diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py.expect new file mode 100644 index 0000000000..708959ceec --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py.expect @@ -0,0 +1,61 @@ +# long variable name +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( + 0 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( + 1 # with a comment +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = [ + 1, + 2, + 3, +] +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( + function() +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + arg1, arg2, arg3 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +# long function name +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying() +) +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + arg1, arg2, arg3 + ) +) +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 + ) +) +string_variable_name = "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do" # noqa +for key in """ + hostname + port + username +""".split(): + if key in self.connect_kwargs: + raise ValueError(err.format(key)) +concatenated_strings = ( + "some strings that are " + "concatenated implicitly, so if you put them on separate " + "lines it will fit" +) +del ( + concatenated_strings, + string_variable_name, + normal_function_name, + normal_name, + need_more_to_make_the_line_long_enough, +) +del ( + ([], name_1, name_2), + [(), [], name_4, name_3], + name_1[[name_2 for name_1 in name_0]], +) +del ((),) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_38.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_38.options.json index fa1bf06046..01777c6d8f 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_38.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_38.options.json @@ -1 +1 @@ -{"target_version": "3.8"} +{"target_version": "3.8"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py index f0ae005363..ec4ceb907c 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py @@ -82,3 +82,9 @@ async def func(): argument1, argument2, argument3="some_value" ): pass + + + +# don't remove the brackets here, it changes the meaning of the code. +with (x, y) as z: + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py.expect index 1b8e865933..ccf073c802 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/context_managers_39.py.expect @@ -83,3 +83,8 @@ async def func(): some_other_function(argument1, argument2, argument3="some_value"), ): pass + + +# don't remove the brackets here, it changes the meaning of the code. +with (x, y) as z: + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py new file mode 100644 index 0000000000..75b8db4817 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py @@ -0,0 +1,3 @@ +""" +87 characters ............................................................................ +""" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py.expect new file mode 100644 index 0000000000..75b8db4817 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/docstring_newline.py.expect @@ -0,0 +1,3 @@ +""" +87 characters ............................................................................ +""" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip9.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.options.json similarity index 100% rename from crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip9.options.json rename to crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.options.json diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py new file mode 100644 index 0000000000..f271d57aa8 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py @@ -0,0 +1,8 @@ +def foo(): return "mock" # fmt: skip +if True: print("yay") # fmt: skip +for i in range(10): print(i) # fmt: skip + +j = 1 # fmt: skip +while j < 10: j += 1 # fmt: skip + +b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py.expect new file mode 100644 index 0000000000..f271d57aa8 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py.expect @@ -0,0 +1,8 @@ +def foo(): return "mock" # fmt: skip +if True: print("yay") # fmt: skip +for i in range(10): print(i) # fmt: skip + +j = 1 # fmt: skip +while j < 10: j += 1 # fmt: skip + +b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py new file mode 100644 index 0000000000..5d3f7874e5 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py @@ -0,0 +1,6 @@ +def foo(): + pass + + +# comment 1 # fmt: skip +# comment 2 diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py.expect new file mode 100644 index 0000000000..5d3f7874e5 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip11.py.expect @@ -0,0 +1,6 @@ +def foo(): + pass + + +# comment 1 # fmt: skip +# comment 2 diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py new file mode 100644 index 0000000000..70699c5663 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py @@ -0,0 +1,15 @@ +x = "\x1F" +x = "\\x1B" +x = "\\\x1B" +x = "\U0001F60E" +x = "\u0001F60E" +x = r"\u0001F60E" +x = "don't format me" +x = "\xA3" +x = "\u2717" +x = "\uFaCe" +x = "\N{ox}\N{OX}" +x = "\N{lAtIn smaLL letteR x}" +x = "\N{CYRILLIC small LETTER BYELORUSSIAN-UKRAINIAN I}" +x = b"\x1Fdon't byte" +x = rb"\x1Fdon't format" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py.expect new file mode 100644 index 0000000000..d12e858bf0 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/format_unicode_escape_seq.py.expect @@ -0,0 +1,15 @@ +x = "\x1f" +x = "\\x1B" +x = "\\\x1b" +x = "\U0001f60e" +x = "\u0001F60E" +x = r"\u0001F60E" +x = "don't format me" +x = "\xa3" +x = "\u2717" +x = "\uface" +x = "\N{OX}\N{OX}" +x = "\N{LATIN SMALL LETTER X}" +x = "\N{CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I}" +x = b"\x1fdon't byte" +x = rb"\x1Fdon't format" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring.options.json deleted file mode 100644 index a97114e048..0000000000 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring.options.json +++ /dev/null @@ -1 +0,0 @@ -{"target_version": "3.12"} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py new file mode 100644 index 0000000000..d82ce7980f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py @@ -0,0 +1,34 @@ +# Regression tests for long f-strings, including examples from issue #3623 + +a = ( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +) + +a = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + \ + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + +a = f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + \ + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + +a = ( + f'bbbbbbb"{"b"}"' + 'aaaaaaaa' +) + +a = ( + f'"{"b"}"' +) + +a = ( + f'\"{"b"}\"' +) + +a = ( + r'\"{"b"}\"' +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py.expect new file mode 100644 index 0000000000..30f456cd22 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py.expect @@ -0,0 +1,29 @@ +# Regression tests for long f-strings, including examples from issue #3623 + +a = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +a = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = f'bbbbbbb"{"b"}"' "aaaaaaaa" + +a = f'"{"b"}"' + +a = f'"{"b"}"' + +a = r'\"{"b"}\"' diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/funcdef_return_type_trailing_comma.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/funcdef_return_type_trailing_comma.options.json index 1266ed1e66..717b73130f 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/funcdef_return_type_trailing_comma.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/funcdef_return_type_trailing_comma.options.json @@ -1 +1 @@ -{"preview": "enabled", "target_version": "3.10"} \ No newline at end of file +{"target_version": "3.10"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.options.json new file mode 100644 index 0000000000..4295ffe1df --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.options.json @@ -0,0 +1 @@ +{"target_version": "3.12"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py new file mode 100644 index 0000000000..ab85998300 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py @@ -0,0 +1,133 @@ +def plain[T, B](a: T, b: T) -> T: + return a + +def arg_magic[T, B](a: T, b: T,) -> T: + return a + +def type_param_magic[T, B,](a: T, b: T) -> T: + return a + +def both_magic[T, B,](a: T, b: T,) -> T: + return a + + +def plain_multiline[ + T, + B +]( + a: T, + b: T +) -> T: + return a + +def arg_magic_multiline[ + T, + B +]( + a: T, + b: T, +) -> T: + return a + +def type_param_magic_multiline[ + T, + B, +]( + a: T, + b: T +) -> T: + return a + +def both_magic_multiline[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_mixed1[ + T, + B +](a: T, b: T) -> T: + return a + +def plain_mixed2[T, B]( + a: T, + b: T +) -> T: + return a + +def arg_magic_mixed1[ + T, + B +](a: T, b: T,) -> T: + return a + +def arg_magic_mixed2[T, B]( + a: T, + b: T, +) -> T: + return a + +def type_param_magic_mixed1[ + T, + B, +](a: T, b: T) -> T: + return a + +def type_param_magic_mixed2[T, B,]( + a: T, + b: T +) -> T: + return a + +def both_magic_mixed1[ + T, + B, +](a: T, b: T,) -> T: + return a + +def both_magic_mixed2[T, B,]( + a: T, + b: T, +) -> T: + return a + +def something_something_function[ + T: Model +](param: list[int], other_param: type[T], *, some_other_param: bool = True) -> QuerySet[ + T +]: + pass + + +def func[A_LOT_OF_GENERIC_TYPES: AreBeingDefinedHere, LIKE_THIS, AND_THIS, ANOTHER_ONE, AND_YET_ANOTHER_ONE: ThisOneHasTyping](a: T, b: T, c: T, d: T, e: T, f: T, g: T, h: T, i: T, j: T, k: T, l: T, m: T, n: T, o: T, p: T) -> T: + return a + + +def with_random_comments[ + Z + # bye +](): + return a + + +def func[ + T, # comment + U # comment + , + Z: # comment + int +](): pass + + +def func[ + T, # comment but it's long so it doesn't just move to the end of the line + U # comment comment comm comm ent ent + , + Z: # comment ent ent comm comm comment + int +](): pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py.expect new file mode 100644 index 0000000000..1631dd320a --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py.expect @@ -0,0 +1,170 @@ +def plain[T, B](a: T, b: T) -> T: + return a + + +def arg_magic[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def both_magic[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_multiline[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_multiline[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic_multiline[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def both_magic_multiline[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_mixed1[T, B](a: T, b: T) -> T: + return a + + +def plain_mixed2[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_mixed1[T, B]( + a: T, + b: T, +) -> T: + return a + + +def arg_magic_mixed2[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic_mixed1[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def type_param_magic_mixed2[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def both_magic_mixed1[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def both_magic_mixed2[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def something_something_function[T: Model]( + param: list[int], other_param: type[T], *, some_other_param: bool = True +) -> QuerySet[T]: + pass + + +def func[ + A_LOT_OF_GENERIC_TYPES: AreBeingDefinedHere, + LIKE_THIS, + AND_THIS, + ANOTHER_ONE, + AND_YET_ANOTHER_ONE: ThisOneHasTyping, +]( + a: T, + b: T, + c: T, + d: T, + e: T, + f: T, + g: T, + h: T, + i: T, + j: T, + k: T, + l: T, + m: T, + n: T, + o: T, + p: T, +) -> T: + return a + + +def with_random_comments[ + Z + # bye +](): + return a + + +def func[T, U, Z: int](): # comment # comment # comment + pass + + +def func[ + T, # comment but it's long so it doesn't just move to the end of the line + U, # comment comment comm comm ent ent + Z: int, # comment ent ent comm comm comment +](): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py index 3dc4e3af71..c39bb99bcf 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py @@ -6,7 +6,7 @@ def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parame def foo3(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass -# Adding some unformatted code covering a wide range of syntaxes. +# Adding some unformated code covering a wide range of syntaxes. if True: # Incorrectly indented prefix comments. diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py.expect index 01c9c002e3..7fdfdfd0db 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py.expect @@ -28,7 +28,7 @@ def foo3( def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass -# Adding some unformatted code covering a wide range of syntaxes. +# Adding some unformated code covering a wide range of syntaxes. if True: # Incorrectly indented prefix comments. diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py new file mode 100644 index 0000000000..483fbe8c57 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py @@ -0,0 +1,8 @@ +# flags: --line-ranges=6-7 +class Foo: + + @overload + def foo(): ... + + def fox(self): + print() diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py.expect new file mode 100644 index 0000000000..483fbe8c57 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_decorator_edge_case.py.expect @@ -0,0 +1,8 @@ +# flags: --line-ranges=6-7 +class Foo: + + @overload + def foo(): ... + + def fox(self): + print() diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py new file mode 100644 index 0000000000..4907fec71c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py @@ -0,0 +1,28 @@ +def func( + arg1, + arg2, +) -> Set["this_is_a_very_long_module_name.AndAVeryLongClasName" + ".WithAVeryVeryVeryVeryVeryLongSubClassName"]: + pass + + +def func( + argument: ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" + ), +) -> ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" +): + pass + + +def func( + argument: ( + "int |" + "str" + ), +) -> Set["int |" + " str"]: + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py.expect new file mode 100644 index 0000000000..679df21eed --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py.expect @@ -0,0 +1,26 @@ +def func( + arg1, + arg2, +) -> Set[ + "this_is_a_very_long_module_name.AndAVeryLongClasName" + ".WithAVeryVeryVeryVeryVeryLongSubClassName" +]: + pass + + +def func( + argument: ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" + ), +) -> ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" +): + pass + + +def func( + argument: "int |" "str", +) -> Set["int |" " str"]: + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py index 0e29b14bf4..724e28cd4e 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py @@ -1,6 +1,6 @@ """I am a very helpful module docstring. -With trailing spaces (only removed with unify_docstring_detection on): +With trailing spaces: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py.expect index 7c31ef75cf..9b270c521b 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.py.expect @@ -1,6 +1,6 @@ """I am a very helpful module docstring. -With trailing spaces (only removed with unify_docstring_detection on): +With trailing spaces: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/is_simple_lookup_for_doublestar_expression.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.options.json similarity index 100% rename from crates/ruff_python_formatter/resources/test/fixtures/black/cases/is_simple_lookup_for_doublestar_expression.options.json rename to crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.options.json diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py new file mode 100644 index 0000000000..7374829c44 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py @@ -0,0 +1,9 @@ +#!/python + +# regression test for #4762 +""" +docstring +""" +from __future__ import annotations + +import os diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py.expect new file mode 100644 index 0000000000..50c57b9d8a --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_after_comment.py.expect @@ -0,0 +1,10 @@ +#!/python + +# regression test for #4762 +""" +docstring +""" + +from __future__ import annotations + +import os diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/no_blank_line_before_docstring.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/no_blank_line_before_docstring.py.expect index 3e1118fba6..fa042f4933 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/no_blank_line_before_docstring.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/no_blank_line_before_docstring.py.expect @@ -25,5 +25,4 @@ class MultilineDocstringsAsWell: class SingleQuotedDocstring: - "I'm a docstring but I don't even get triple quotes." diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pattern_matching_with_if_stmt.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pattern_matching_with_if_stmt.options.json index 1266ed1e66..717b73130f 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pattern_matching_with_if_stmt.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pattern_matching_with_if_stmt.options.json @@ -1 +1 @@ -{"preview": "enabled", "target_version": "3.10"} \ No newline at end of file +{"target_version": "3.10"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py index 930759735b..bd3e48417b 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py @@ -19,7 +19,7 @@ z: (Short z: (int) = 2.3 z: ((int)) = foo() -# In case I go for not enforcing parentheses, this might get improved at the same time +# In case I go for not enforcing parantheses, this might get improved at the same time x = ( z == 9999999999999999999999999999999999999999 diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py.expect index e9c2f75f7e..ab0a4d9677 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py.expect @@ -28,7 +28,7 @@ z: Short | Short2 | Short3 | Short4 = 8 z: int = 2.3 z: int = foo() -# In case I go for not enforcing parentheses, this might get improved at the same time +# In case I go for not enforcing parantheses, this might get improved at the same time x = ( z == 9999999999999999999999999999999999999999 diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.options.json new file mode 100644 index 0000000000..dcb1b48257 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.options.json @@ -0,0 +1 @@ +{"target_version": "3.11"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py new file mode 100644 index 0000000000..cd44304ce3 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py @@ -0,0 +1,5 @@ +def fn(*args: *tuple[*A, B]) -> None: + pass + + +fn.__annotations__ diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py.expect new file mode 100644 index 0000000000..cd44304ce3 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep646_typed_star_arg_type_var_tuple.py.expect @@ -0,0 +1,5 @@ +def fn(*args: *tuple[*A, B]) -> None: + pass + + +fn.__annotations__ diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py index 3487ac8536..defb0f99e3 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py @@ -13,3 +13,8 @@ f(a := b + c for c in range(10)) f((a := b + c for c in range(10)), x) f(y=(a := b + c for c in range(10))) f(x, (a := b + c for c in range(10)), y=z, **q) + + +# Don't remove parens when assignment expr is one of the exprs in a with statement +with x, (a := b): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py.expect index 3487ac8536..defb0f99e3 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_572_py310.py.expect @@ -13,3 +13,8 @@ f(a := b + c for c in range(10)) f((a := b + c for c in range(10)), x) f(y=(a := b + c for c in range(10))) f(x, (a := b + c for c in range(10)), y=z, **q) + + +# Don't remove parens when assignment expr is one of the exprs in a with statement +with x, (a := b): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py index 8224f38ea2..eff207ff8a 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py @@ -73,8 +73,9 @@ x = f"a{2+2:=^{foo(x+y**2):something else}}b" x = f"a{2+2:=^{foo(x+y**2):something else}one more}b" f'{(abc:=10)}' -f"This is a really long string, but just make sure that you reflow fstrings { - 2+2:d}" +f"""This is a really long string, but just make sure that you reflow fstrings { + 2+2:d +}""" f"This is a really long string, but just make sure that you reflow fstrings correctly {2+2:d}" f"{2+2=}" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py.expect index 74a6ecd5e7..85b8db2ff6 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep_701.py.expect @@ -73,9 +73,9 @@ x = f"a{2+2:=^{foo(x+y**2):something else}}b" x = f"a{2+2:=^{foo(x+y**2):something else}one more}b" f"{(abc:=10)}" -f"This is a really long string, but just make sure that you reflow fstrings { +f"""This is a really long string, but just make sure that you reflow fstrings { 2+2:d -}" +}""" f"This is a really long string, but just make sure that you reflow fstrings correctly {2+2:d}" f"{2+2=}" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py index 6d91909dfe..7cd0478474 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py @@ -10,3 +10,12 @@ first_value, (m1, m2,), third_value = xxxxxx_yyyyyy_zzzzzz_wwwwww_uuuuuuu_vvvvvv # Make when when the left side of assignment plus the opening paren "... = (" is # exactly line length limit + 1, it won't be split like that. xxxxxxxxx_yyy_zzzzzzzz[xx.xxxxxx(x_yyy_zzzzzz.xxxxx[0]), x_yyy_zzzzzz.xxxxxx(xxxx=1)] = 1 + +# Regression test for #1187 +print( + dict( + a=1, + b=2 if some_kind_of_data is not None else some_other_kind_of_data, # some explanation of why this is actually necessary + c=3, + ) +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py.expect index 9a9ef1356e..392cb3ba15 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py.expect @@ -19,3 +19,14 @@ xxxxxxxxx_yyy_zzzzzzzz[ xx.xxxxxx(x_yyy_zzzzzz.xxxxx[0]), x_yyy_zzzzzz.xxxxxx(xxxx=1) ] = 1 + +# Regression test for #1187 +print( + dict( + a=1, + b=( + 2 if some_kind_of_data is not None else some_other_kind_of_data + ), # some explanation of why this is actually necessary + c=3, + ) +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_comments7.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_comments7.py.expect index 9e583ab571..bddab7e5da 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_comments7.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_comments7.py.expect @@ -29,7 +29,6 @@ from com.my_lovely_company.my_lovely_team.my_lovely_project.my_lovely_component MyLovelyCompanyTeamProjectComponent as component, # DRY ) - result = 1 # look ma, no comment migration xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx result = 1 # look ma, no comment migration xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.options.json similarity index 100% rename from crates/ruff_python_formatter/resources/test/fixtures/black/cases/module_docstring_2.options.json rename to crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.options.json diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py new file mode 100644 index 0000000000..152cae13f0 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py @@ -0,0 +1 @@ +f"{''=}" f'{""=}' diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py.expect new file mode 100644 index 0000000000..152cae13f0 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_fstring.py.expect @@ -0,0 +1 @@ +f"{''=}" f'{""=}' diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/typed_params_trailing_comma.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.options.json similarity index 100% rename from crates/ruff_python_formatter/resources/test/fixtures/black/cases/typed_params_trailing_comma.options.json rename to crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.options.json diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py new file mode 100644 index 0000000000..08195ec4cb --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py @@ -0,0 +1,90 @@ +from middleman.authentication import validate_oauth_token + + +logger = logging.getLogger(__name__) + + +# case 2 comment after import +from middleman.authentication import validate_oauth_token +#comment + +logger = logging.getLogger(__name__) + + +# case 3 comment after import +from middleman.authentication import validate_oauth_token +# comment +logger = logging.getLogger(__name__) + + +from middleman.authentication import validate_oauth_token + + + +logger = logging.getLogger(__name__) + + +# case 4 try catch with import after import +import os +import os + + + +try: + import os +except Exception: + pass + +try: + import os + def func(): + a = 1 +except Exception: + pass + + +# case 5 multiple imports +import os +import os + +import os +import os + + + + + +for i in range(10): + print(i) + + +# case 6 import in function +def func(): + print() + import os + def func(): + pass + print() + + +def func(): + import os + a = 1 + print() + + +def func(): + import os + + + a = 1 + print() + + +def func(): + import os + + + + a = 1 + print() diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py.expect new file mode 100644 index 0000000000..c6dea1666e --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py.expect @@ -0,0 +1,85 @@ +from middleman.authentication import validate_oauth_token + +logger = logging.getLogger(__name__) + + +# case 2 comment after import +from middleman.authentication import validate_oauth_token + +# comment + +logger = logging.getLogger(__name__) + + +# case 3 comment after import +from middleman.authentication import validate_oauth_token + +# comment +logger = logging.getLogger(__name__) + + +from middleman.authentication import validate_oauth_token + +logger = logging.getLogger(__name__) + + +# case 4 try catch with import after import +import os +import os + +try: + import os +except Exception: + pass + +try: + import os + + def func(): + a = 1 + +except Exception: + pass + + +# case 5 multiple imports +import os +import os + +import os +import os + +for i in range(10): + print(i) + + +# case 6 import in function +def func(): + print() + import os + + def func(): + pass + + print() + + +def func(): + import os + + a = 1 + print() + + +def func(): + import os + + a = 1 + print() + + +def func(): + import os + + a = 1 + print() diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py index 04355270d9..a6e137e1f4 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py @@ -1,3 +1,24 @@ +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "foo": bar, + "foo": bar, + "foo": ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} + my_dict = { "something_something": r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t" @@ -5,23 +26,90 @@ my_dict = { r"signiferumque, duo ea vocibus consetetur scriptorem. Facer \t", } +# Function calls as keys +tasks = { + get_key_name( + foo, + bar, + baz, + ): src, + loop.run_in_executor(): src, + loop.run_in_executor(xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx): src, + loop.run_in_executor( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxx + ): src, + loop.run_in_executor(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} + +# Dictionary comprehensions +tasks = { + key_name: ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {key_name: foobar for src in sources} +tasks = { + get_key_name( + src, + ): "foo" + for src in sources +} +tasks = { + get_key_name( + foo, + bar, + baz, + ): src + for src in sources +} +tasks = { + get_key_name(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {get_key_name(): foobar for src in sources} + + +# Delimiters inside the value +def foo(): + def bar(): + x = { + common.models.DateTimeField: datetime(2020, 1, 31, tzinfo=utc) + timedelta( + days=i + ), + } + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + "foobar": (123 + 456), + } + x = { + "foobar": (123) + 456, + } + + my_dict = { "a key in my dict": a_very_long_variable * and_a_very_long_function_call() / 100000.0 } - my_dict = { "a key in my dict": a_very_long_variable * and_a_very_long_function_call() * and_another_long_func() / 100000.0 } - my_dict = { "a key in my dict": MyClass.some_attribute.first_call().second_call().third_call(some_args="some value") } { - 'xxxxxx': + "xxxxxx": xxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxx( xxxxxxxxxxxxxx={ - 'x': + "x": xxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxx( xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=( xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx @@ -29,8 +117,8 @@ my_dict = { xxxxxxxxxxxxx=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx .xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx( xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx={ - 'x': x.xx, - 'x': x.x, + "x": x.xx, + "x": x.x, })))) }), } diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py.expect index 40582b3632..7d7e2fad5e 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py.expect @@ -1,3 +1,24 @@ +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "foo": bar, + "foo": bar, + "foo": ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} + my_dict = { "something_something": ( r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t" @@ -6,12 +27,80 @@ my_dict = { ), } +# Function calls as keys +tasks = { + get_key_name( + foo, + bar, + baz, + ): src, + loop.run_in_executor(): src, + loop.run_in_executor(xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx): src, + loop.run_in_executor( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxx + ): src, + loop.run_in_executor(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} + +# Dictionary comprehensions +tasks = { + key_name: ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {key_name: foobar for src in sources} +tasks = { + get_key_name( + src, + ): "foo" + for src in sources +} +tasks = { + get_key_name( + foo, + bar, + baz, + ): src + for src in sources +} +tasks = { + get_key_name(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {get_key_name(): foobar for src in sources} + + +# Delimiters inside the value +def foo(): + def bar(): + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + "foobar": 123 + 456, + } + x = { + "foobar": (123) + 456, + } + + my_dict = { "a key in my dict": ( a_very_long_variable * and_a_very_long_function_call() / 100000.0 ) } - my_dict = { "a key in my dict": ( a_very_long_variable @@ -20,7 +109,6 @@ my_dict = { / 100000.0 ) } - my_dict = { "a key in my dict": ( MyClass.some_attribute.first_call() @@ -51,8 +139,8 @@ my_dict = { class Random: def func(): - random_service.status.active_states.inactive = ( - make_new_top_level_state_from_dict({ + random_service.status.active_states.inactive = make_new_top_level_state_from_dict( + { "topLevelBase": { "secondaryBase": { "timestamp": 1234, @@ -63,5 +151,5 @@ class Random: ), } }, - }) + } ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py index 017f679115..411c03f540 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py @@ -278,7 +278,7 @@ string_with_escaped_nameescape = ( "........................................................................... \\N{LAO KO LA}" ) -msg = lambda x: f"this is a very very very long lambda value {x} that doesn't fit on a single line" +msg = lambda x: f"this is a very very very very long lambda value {x} that doesn't fit on a single line" dict_with_lambda_values = { "join": lambda j: ( @@ -327,3 +327,17 @@ log.info(f'''Skipping: {"a" == 'b'} {desc["ms_name"]} {money=} {dte=} {pos_share log.info(f'''Skipping: {'a' == "b"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}''') log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""") + +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx", +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" + ) +} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py.expect index 4b7bdd86d2..f1b0985ef1 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py.expect @@ -508,11 +508,9 @@ string_with_escaped_nameescape = ( " \\N{LAO KO LA}" ) -msg = ( - lambda x: ( - f"this is a very very very long lambda value {x} that doesn't fit on a single" - " line" - ) +msg = lambda x: ( + f"this is a very very very very long lambda value {x} that doesn't fit on a" + " single line" ) dict_with_lambda_values = { @@ -537,43 +535,43 @@ code = ( call(body="%s %s" % (",".join(items), suffix)) log.info( - "Skipping:" - f' {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]=} {desc["exposure_max"]=}' + f'Skipping: {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=}' + f' {desc["status"]=} {desc["exposure_max"]=}' ) log.info( - "Skipping:" - f" {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']=} {desc['exposure_max']=}" + f"Skipping: {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=}" + f" {desc['status']=} {desc['exposure_max']=}" ) log.info( - "Skipping:" - f" {desc['db_id']} {foo('bar',x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" + f'Skipping: {desc["db_id"]} {foo("bar",x=123)} {"foo" != "bar"} {(x := "abc=")}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=}' + f' {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=}' + f' {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f" {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" + f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( @@ -592,3 +590,17 @@ log.info( log.info( f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) + +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py index 74b6cd43a2..ac0d3a3585 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py @@ -551,6 +551,7 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. +# Regressed again by https://github.com/psf/black/pull/4498 s = ( "With single quote: ' " f" {my_dict['foo']}" diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py.expect index 15c91275af..bfbfaedef0 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py.expect @@ -672,9 +672,15 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. -s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" +# Regressed again by https://github.com/psf/black/pull/4498 +s = ( + "With single quote: ' " + f" {my_dict['foo']}" + ' With double quote: " ' + f' {my_dict["bar"]}' +) s = ( "Lorem Ipsum is simply dummy text of the printing and typesetting" - f" industry:'{my_dict['foo']}'" + f' industry:\'{my_dict["foo"]}\'' ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py index 82a8657d6e..8293424771 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py @@ -180,3 +180,70 @@ test assert some_var == expected_result, f""" expected: {expected_result} actual: {some_var}""" + + +def foo(): + a = { + xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx: { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxxxxx": """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + }, + } + + +xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx = { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": """ +a +a +a +a +a""", +} + +a = """ +""" if """ +""" == """ +""" else """ +""" + +a = """ +""" if b else """ +""" + +a = """ +""" if """ +""" == """ +""" else b + +a = b if """ +""" == """ +""" else """ +""" + +a = """ +""" if b else c + +a = c if b else """ +""" + +a = b if """ +""" == """ +""" else c diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py.expect index 942ee085ea..0259c82b3e 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py.expect @@ -214,3 +214,106 @@ test assert some_var == expected_result, f""" expected: {expected_result} actual: {some_var}""" + + +def foo(): + a = { + xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx: { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + }, + } + + +xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx = { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), +} + +a = ( + """ +""" + if """ +""" + == """ +""" + else """ +""" +) + +a = ( + """ +""" + if b + else """ +""" +) + +a = ( + """ +""" + if """ +""" + == """ +""" + else b +) + +a = ( + b + if """ +""" + == """ +""" + else """ +""" +) + +a = ( + """ +""" + if b + else c +) + +a = ( + c + if b + else """ +""" +) + +a = ( + b + if """ +""" + == """ +""" + else c +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.options.json new file mode 100644 index 0000000000..ce7c52b163 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.options.json @@ -0,0 +1 @@ +{"preview": "enabled"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py new file mode 100644 index 0000000000..8c24f9e829 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py @@ -0,0 +1,136 @@ +items = [(x for x in [1])] + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2"} + if some_var == "" + else {"key": "val"} + ) +] +items = [ + ( + "123456890123457890123468901234567890" + if some_var == "long strings" + else "123467890123467890" + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ) +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ) +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ) +] + +# Shouldn't remove trailing commas +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ), +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ), +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ), +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ), +] + +# Shouldn't add parentheses +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Shouldn't crash with comments +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py.expect new file mode 100644 index 0000000000..e4e9641e16 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py.expect @@ -0,0 +1,106 @@ +items = [(x for x in [1])] + +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] +items = [ + "123456890123457890123468901234567890" + if some_var == "long strings" + else "123467890123467890" +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} +] +items = [ + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" +] +items = [ + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name +] + +# Shouldn't remove trailing commas +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ), +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ), +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ), +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ), +] + +# Shouldn't add parentheses +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Shouldn't crash with comments +items = [ # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] # comment + +items = [ # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] # comment + +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} +] + +items = [ # comment # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] # comment # comment diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.options.json new file mode 100644 index 0000000000..9f5eb7c209 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.options.json @@ -0,0 +1 @@ +{"preview": "enabled", "line_width": 79} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py new file mode 100644 index 0000000000..ec7e802674 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py @@ -0,0 +1,69 @@ +[a for graph_path_expression in refined_constraint.condition_as_predicate.variables] +[ + a + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression + in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +[ + (foobar_very_long_key, foobar_very_long_value) + for foobar_very_long_key, foobar_very_long_value in foobar_very_long_dictionary.items() +] + +# Don't split the `in` if it's not too long +lcomp3 = [ + element.split("\n", 1)[0] + for element in collection.select_elements() + # right + if element is not None +] + +# Don't remove parens around ternaries +expected = [i for i in (a if b else c)] + +# Nested arrays +# First in will not be split because it would still be too long +[[ + x + for x in bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + for y in xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +]] + +# Multiple comprehensions, only split the second `in` +graph_path_expressions_in_local_constraint_refinements = [ + graph_path_expression + for refined_constraint in self._local_constraint_refinements.values() + if refined_constraint is not None + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] + +# Dictionary comprehensions +dict_with_really_long_names = { + really_really_long_key_name: an_even_longer_really_really_long_key_value + for really_really_long_key_name, an_even_longer_really_really_long_key_value in really_really_really_long_dict_name.items() +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in dictionary_with_super_really_long_name +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name + in dictionary_with_super_really_long_name +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key in ( + dictionary + ) +} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py.expect new file mode 100644 index 0000000000..00c23c0b5c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py.expect @@ -0,0 +1,88 @@ +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +[ + (foobar_very_long_key, foobar_very_long_value) + for foobar_very_long_key, foobar_very_long_value in ( + foobar_very_long_dictionary.items() + ) +] + +# Don't split the `in` if it's not too long +lcomp3 = [ + element.split("\n", 1)[0] + for element in collection.select_elements() + # right + if element is not None +] + +# Don't remove parens around ternaries +expected = [i for i in (a if b else c)] + +# Nested arrays +# First in will not be split because it would still be too long +[ + [ + x + for x in bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + for y in ( + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ) + ] +] + +# Multiple comprehensions, only split the second `in` +graph_path_expressions_in_local_constraint_refinements = [ + graph_path_expression + for refined_constraint in self._local_constraint_refinements.values() + if refined_constraint is not None + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +# Dictionary comprehensions +dict_with_really_long_names = { + really_really_long_key_name: an_even_longer_really_really_long_key_value + for really_really_long_key_name, an_even_longer_really_really_long_key_value in ( + really_really_really_long_dict_name.items() + ) +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in ( + dictionary_with_super_really_long_name + ) +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in ( + dictionary_with_super_really_long_name + ) +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key in dictionary +} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py index 3fcf6e0ffc..d0ef56e1fa 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py @@ -10,6 +10,7 @@ def g(): async def func(): + await ... if test: out_batched = [ i diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py.expect index 3fcf6e0ffc..d0ef56e1fa 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/python37.py.expect @@ -10,6 +10,7 @@ def g(): async def func(): + await ... if test: out_batched = [ i diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.options.json new file mode 100644 index 0000000000..084ace6a74 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.options.json @@ -0,0 +1 @@ +{"preview": "enabled", "target_version": "3.14"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py new file mode 100644 index 0000000000..92914e7442 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py @@ -0,0 +1,123 @@ +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except (ValueError): + pass + +try: + pass +except* (ValueError): + pass + +# parenthesis are removed +try: + pass +except (ValueError) as e: + pass + +try: + pass +except* (ValueError) as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# remains unchanged +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt): + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt): + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except (ValueError if True else TypeError): + pass + +try: + pass +except* (ValueError if True else TypeError): + pass + +# inner except: parenthesis are removed +# outer except: parenthsis are not removed +try: + try: + pass + except (TypeError, KeyboardInterrupt): + pass +except (ValueError,): + pass + +try: + try: + pass + except* (TypeError, KeyboardInterrupt): + pass +except* (ValueError,): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py.expect new file mode 100644 index 0000000000..9b7021868f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py.expect @@ -0,0 +1,123 @@ +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError as e: + pass + +try: + pass +except* ValueError as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# remains unchanged +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are removed +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except ValueError if True else TypeError: + pass + +try: + pass +except* ValueError if True else TypeError: + pass + +# inner except: parenthesis are removed +# outer except: parenthsis are not removed +try: + try: + pass + except TypeError, KeyboardInterrupt: + pass +except (ValueError,): + pass + +try: + try: + pass + except* TypeError, KeyboardInterrupt: + pass +except* (ValueError,): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.options.json new file mode 100644 index 0000000000..938747847c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.options.json @@ -0,0 +1 @@ +{"preview": "enabled", "target_version": "3.11"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py new file mode 100644 index 0000000000..0164513c1f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py @@ -0,0 +1,111 @@ +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except (ValueError): + pass + +try: + pass +except* (ValueError): + pass + +# parenthesis are removed +try: + pass +except (ValueError) as e: + pass + +try: + pass +except* (ValueError) as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt): + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt): + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except (ValueError if True else TypeError): + pass + +try: + pass +except* (ValueError if True else TypeError): + pass + +# parenthesis are not removed +try: + try: + pass + except (TypeError, KeyboardInterrupt): + pass +except (ValueError,): + pass + +try: + try: + pass + except* (TypeError, KeyboardInterrupt): + pass +except* (ValueError,): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py.expect new file mode 100644 index 0000000000..86bdb37d3e --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens_pre_py314.py.expect @@ -0,0 +1,111 @@ +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError as e: + pass + +try: + pass +except* ValueError as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt): + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt): + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except ValueError if True else TypeError: + pass + +try: + pass +except* ValueError if True else TypeError: + pass + +# parenthesis are not removed +try: + try: + pass + except (TypeError, KeyboardInterrupt): + pass +except (ValueError,): + pass + +try: + try: + pass + except* (TypeError, KeyboardInterrupt): + pass +except* (ValueError,): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py new file mode 100644 index 0000000000..22a829da68 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py @@ -0,0 +1,80 @@ +items = [(123)] +items = [(True)] +items = [(((((True)))))] +items = [(((((True,)))))] +items = [((((()))))] +items = [(x for x in [1])] +items = {(123)} +items = {(True)} +items = {(((((True)))))} + +# Requires `hug_parens_with_braces_and_square_brackets` unstable style to remove parentheses +# around multiline values +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2"} + if some_var == "" + else {"key": "val"} + ) +] + +# Comments should not cause crashes +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py.expect new file mode 100644 index 0000000000..71cfcd7026 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py.expect @@ -0,0 +1,74 @@ +items = [123] +items = [True] +items = [True] +items = [(True,)] +items = [()] +items = [(x for x in [1])] +items = {123} +items = {True} +items = {True} + +# Requires `hug_parens_with_braces_and_square_brackets` unstable style to remove parentheses +# around multiline values +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Comments should not cause crashes +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_redundant_parens_in_case_guard.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_redundant_parens_in_case_guard.options.json index 172715c6e4..cd2518bb62 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_redundant_parens_in_case_guard.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_redundant_parens_in_case_guard.options.json @@ -1 +1 @@ -{"preview": "enabled", "line_width": 79, "target_version": "3.10"} \ No newline at end of file +{"line_width": 79, "target_version": "3.10"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py index 9634bab444..6113e5679c 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py @@ -52,3 +52,16 @@ with ((((open("bla.txt")))) as f): with ((((CtxManager1()))) as example1, (((CtxManager2()))) as example2): ... + +# regression tests for #3678 +with (a, *b): + pass + +with (a, (b, *c)): + pass + +with (a for b in c): + pass + +with (a, (b for c in d)): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py.expect index e70d01b18d..f08cd13ff1 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_with_brackets.py.expect @@ -61,3 +61,16 @@ with open("bla.txt") as f: with CtxManager1() as example1, CtxManager2() as example2: ... + +# regression tests for #3678 +with (a, *b): + pass + +with a, (b, *c): + pass + +with (a for b in c): + pass + +with a, (b for c in d): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.options.json new file mode 100644 index 0000000000..4d80c0fb92 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.options.json @@ -0,0 +1 @@ +{"target_version": "3.12", "magic_trailing_comma": "ignore"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py new file mode 100644 index 0000000000..8d2a6b9299 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py @@ -0,0 +1,97 @@ +def plain[T, B](a: T, b: T) -> T: + return a + +def arg_magic[T, B](a: T, b: T,) -> T: + return a + +def type_param_magic[T, B,](a: T, b: T) -> T: + return a + +def both_magic[T, B,](a: T, b: T,) -> T: + return a + + +def plain_multiline[ + T, + B +]( + a: T, + b: T +) -> T: + return a + +def arg_magic_multiline[ + T, + B +]( + a: T, + b: T, +) -> T: + return a + +def type_param_magic_multiline[ + T, + B, +]( + a: T, + b: T +) -> T: + return a + +def both_magic_multiline[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_mixed1[ + T, + B +](a: T, b: T) -> T: + return a + +def plain_mixed2[T, B]( + a: T, + b: T +) -> T: + return a + +def arg_magic_mixed1[ + T, + B +](a: T, b: T,) -> T: + return a + +def arg_magic_mixed2[T, B]( + a: T, + b: T, +) -> T: + return a + +def type_param_magic_mixed1[ + T, + B, +](a: T, b: T) -> T: + return a + +def type_param_magic_mixed2[T, B,]( + a: T, + b: T +) -> T: + return a + +def both_magic_mixed1[ + T, + B, +](a: T, b: T,) -> T: + return a + +def both_magic_mixed2[T, B,]( + a: T, + b: T, +) -> T: + return a diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py.expect new file mode 100644 index 0000000000..54bab4e46b --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/skip_magic_trailing_comma_generic_wrap.py.expect @@ -0,0 +1,62 @@ +def plain[T, B](a: T, b: T) -> T: + return a + + +def arg_magic[T, B](a: T, b: T) -> T: + return a + + +def type_param_magic[T, B](a: T, b: T) -> T: + return a + + +def both_magic[T, B](a: T, b: T) -> T: + return a + + +def plain_multiline[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_multiline[T, B](a: T, b: T) -> T: + return a + + +def type_param_magic_multiline[T, B](a: T, b: T) -> T: + return a + + +def both_magic_multiline[T, B](a: T, b: T) -> T: + return a + + +def plain_mixed1[T, B](a: T, b: T) -> T: + return a + + +def plain_mixed2[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_mixed1[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_mixed2[T, B](a: T, b: T) -> T: + return a + + +def type_param_magic_mixed1[T, B](a: T, b: T) -> T: + return a + + +def type_param_magic_mixed2[T, B](a: T, b: T) -> T: + return a + + +def both_magic_mixed1[T, B](a: T, b: T) -> T: + return a + + +def both_magic_mixed2[T, B](a: T, b: T) -> T: + return a diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.options.json new file mode 100644 index 0000000000..4295ffe1df --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.options.json @@ -0,0 +1 @@ +{"target_version": "3.12"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py new file mode 100644 index 0000000000..ff72fbe4b9 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py @@ -0,0 +1,4 @@ +# this is invalid in versions below py312 +class ClassA[T: str]: + def method1(self) -> T: + ... diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py.expect new file mode 100644 index 0000000000..6795f0fe09 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/target_version_flag.py.expect @@ -0,0 +1,3 @@ +# this is invalid in versions below py312 +class ClassA[T: str]: + def method1(self) -> T: ... diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py new file mode 100644 index 0000000000..885a300f44 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py @@ -0,0 +1,30 @@ +# don't remove the brackets here, it changes the meaning of the code. +# even though the code will always trigger a runtime error +with (name_5, name_4), name_5: + pass + + +with c, (a, b): + pass + + +with c, (a, b), d: + pass + + +with c, (a, b, e, f, g), d: + pass + + +def test_tuple_as_contextmanager(): + from contextlib import nullcontext + + try: + with (nullcontext(), nullcontext()), nullcontext(): + pass + except TypeError: + # test passed + pass + else: + # this should be a type error + assert False diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py.expect new file mode 100644 index 0000000000..885a300f44 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/tuple_with_stmt.py.expect @@ -0,0 +1,30 @@ +# don't remove the brackets here, it changes the meaning of the code. +# even though the code will always trigger a runtime error +with (name_5, name_4), name_5: + pass + + +with c, (a, b): + pass + + +with c, (a, b), d: + pass + + +with c, (a, b, e, f, g), d: + pass + + +def test_tuple_as_contextmanager(): + from contextlib import nullcontext + + try: + with (nullcontext(), nullcontext()), nullcontext(): + pass + except TypeError: + # test passed + pass + else: + # this should be a type error + assert False diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.options.json b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.options.json new file mode 100644 index 0000000000..7b38e093e4 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.options.json @@ -0,0 +1 @@ +{"preview": "enabled", "target_version": "3.12"} \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py new file mode 100644 index 0000000000..037f401511 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py @@ -0,0 +1,13 @@ +def f1[T: (int, str)](a,): pass + +def f2[T: (int, str)](a: int, b,): pass + +def g1[T: (int,)](a,): pass + +def g2[T: (int, str, bytes)](a,): pass + +def g3[T: ((int, str), (bytes,))](a,): pass + +def g4[T: (int, (str, bytes))](a,): pass + +def g5[T: ((int,),)](a: int, b,): pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py.expect new file mode 100644 index 0000000000..61ddf22789 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_expansion.py.expect @@ -0,0 +1,42 @@ +def f1[T: (int, str)]( + a, +): + pass + + +def f2[T: (int, str)]( + a: int, + b, +): + pass + + +def g1[T: (int,)]( + a, +): + pass + + +def g2[T: (int, str, bytes)]( + a, +): + pass + + +def g3[T: ((int, str), (bytes,))]( + a, +): + pass + + +def g4[T: (int, (str, bytes))]( + a, +): + pass + + +def g5[T: ((int,),)]( + a: int, + b, +): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py index de25e7c9a9..ff5166c1cf 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py @@ -17,3 +17,5 @@ def trailing_comma1[T=int,](a: str): def trailing_comma2[T=int](a: str,): pass + +def weird_syntax[T=lambda: 42, **P=lambda: 43, *Ts=lambda: 44](): pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py.expect index af09a67e5c..1d9a2ee545 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py.expect @@ -13,25 +13,31 @@ type something_that_is_long[ ] = something_that_is_long -def simple[ - T = something_that_is_long -](short1: int, short2: str, short3: bytes) -> float: +def simple[T = something_that_is_long]( + short1: int, short2: str, short3: bytes +) -> float: pass -def longer[ - something_that_is_long = something_that_is_long -](something_that_is_long: something_that_is_long) -> something_that_is_long: +def longer[something_that_is_long = something_that_is_long]( + something_that_is_long: something_that_is_long, +) -> something_that_is_long: pass def trailing_comma1[ T = int, -](a: str): +]( + a: str, +): pass -def trailing_comma2[ - T = int -](a: str,): +def trailing_comma2[T = int]( + a: str, +): + pass + + +def weird_syntax[T = lambda: 42, **P = lambda: 43, *Ts = lambda: 44](): pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py index 7ce47eb01f..ba4385116e 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py @@ -11,3 +11,7 @@ def even_longer[WhatIsTheLongestTypeVarNameYouCanThinkOfEnoughToMakeBlackSplitTh def it_gets_worse[WhatIsTheLongestTypeVarNameYouCanThinkOfEnoughToMakeBlackSplitThisLine, ItCouldBeGenericOverMultipleTypeVars](): pass def magic[Trailing, Comma,](): pass + +def weird_syntax[T: lambda: 42, U: a or b](): pass + +def name_3[name_0: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa if aaaaaaaaaaa else name_3](): pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py.expect index 72b1e032f4..d14276130e 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_params.py.expect @@ -38,3 +38,13 @@ def magic[ Comma, ](): pass + + +def weird_syntax[T: lambda: 42, U: a or b](): + pass + + +def name_3[ + name_0: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa if aaaaaaaaaaa else name_3 +](): + pass diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py index 3e3e63421f..1723d32d20 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py @@ -1,4 +1,4 @@ -# This is testing an issue that is specific to the preview style +# This is testing an issue that is specific to the preview style (wrap_long_dict_values_in_parens) { "is_update": (up := commit.hash in update_hashes) } diff --git a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py.expect b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py.expect index 03aa2598fe..603ab02701 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py.expect +++ b/crates/ruff_python_formatter/resources/test/fixtures/black/cases/walrus_in_dict.py.expect @@ -1,2 +1,2 @@ -# This is testing an issue that is specific to the preview style +# This is testing an issue that is specific to the preview style (wrap_long_dict_values_in_parens) {"is_update": (up := commit.hash in update_hashes)} diff --git a/crates/ruff_python_formatter/resources/test/fixtures/import_black_tests.py b/crates/ruff_python_formatter/resources/test/fixtures/import_black_tests.py index a9cbb2cc6d..c32bd71af7 100755 --- a/crates/ruff_python_formatter/resources/test/fixtures/import_black_tests.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/import_black_tests.py @@ -113,10 +113,10 @@ IGNORE_LIST = [ # Specs for which to override the formatter options OPTIONS_OVERRIDES = { "context_managers_38.py": { - "target_version": "py38" + "target_version": "3.8" }, "context_managers_autodetect_38.py" : { - "target_version": "py38" + "target_version": "3.8" } } diff --git a/crates/ruff_python_formatter/tests/fixtures.rs b/crates/ruff_python_formatter/tests/fixtures.rs index a5f25dfcd1..776b272d21 100644 --- a/crates/ruff_python_formatter/tests/fixtures.rs +++ b/crates/ruff_python_formatter/tests/fixtures.rs @@ -108,7 +108,8 @@ fn black_compatibility() { let expected_output = fs::read_to_string(&expected_path) .unwrap_or_else(|_| panic!("Expected Black output file '{expected_path:?}' to exist")); - ensure_unchanged_ast(&content, &formatted_code, &options, input_path); + let unsupported_syntax_errors = + ensure_unchanged_ast(&content, &formatted_code, &options, input_path); if formatted_code == expected_output { // Black and Ruff formatting matches. Delete any existing snapshot files because the Black output @@ -163,6 +164,20 @@ fn black_compatibility() { write!(snapshot, "{}", Header::new("Black Output")).unwrap(); write!(snapshot, "{}", CodeFrame::new("python", &expected_output)).unwrap(); + if !unsupported_syntax_errors.is_empty() { + write!(snapshot, "{}", Header::new("New Unsupported Syntax Errors")).unwrap(); + writeln!( + snapshot, + "{}", + DisplayDiagnostics::new( + &DummyFileResolver, + &DisplayDiagnosticConfig::default().format(DiagnosticFormat::Full), + &unsupported_syntax_errors + ) + ) + .unwrap(); + } + insta::with_settings!({ omit_expression => true, input_file => input_path, diff --git a/crates/ruff_python_formatter/tests/normalizer.rs b/crates/ruff_python_formatter/tests/normalizer.rs index 2b943948eb..d8fb8fd54e 100644 --- a/crates/ruff_python_formatter/tests/normalizer.rs +++ b/crates/ruff_python_formatter/tests/normalizer.rs @@ -1,8 +1,5 @@ +use regex::Regex; use std::sync::LazyLock; -use { - itertools::Either::{Left, Right}, - regex::Regex, -}; use ruff_python_ast::{ self as ast, BytesLiteralFlags, Expr, FStringFlags, FStringPart, InterpolatedStringElement, @@ -46,18 +43,9 @@ impl Transformer for Normalizer { fn visit_stmt(&self, stmt: &mut Stmt) { if let Stmt::Delete(delete) = stmt { // Treat `del a, b` and `del (a, b)` equivalently. - delete.targets = delete - .targets - .clone() - .into_iter() - .flat_map(|target| { - if let Expr::Tuple(tuple) = target { - Left(tuple.elts.into_iter()) - } else { - Right(std::iter::once(target)) - } - }) - .collect(); + if let [Expr::Tuple(tuple)] = delete.targets.as_slice() { + delete.targets = tuple.elts.clone(); + } } transformer::walk_stmt(self, stmt); diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__cantfit.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__cantfit.py.snap new file mode 100644 index 0000000000..68496fbb9a --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__cantfit.py.snap @@ -0,0 +1,204 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/cantfit.py +--- +## Input + +```python +# long variable name +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 0 +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 1 # with a comment +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = [ + 1, 2, 3 +] +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function() +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + arg1, arg2, arg3 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +# long function name +normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying() +normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + arg1, arg2, arg3 +) +normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +string_variable_name = ( + "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do" # noqa +) +for key in """ + hostname + port + username +""".split(): + if key in self.connect_kwargs: + raise ValueError(err.format(key)) +concatenated_strings = "some strings that are " "concatenated implicitly, so if you put them on separate " "lines it will fit" +del concatenated_strings, string_variable_name, normal_function_name, normal_name, need_more_to_make_the_line_long_enough +del ([], name_1, name_2), [(), [], name_4, name_3], name_1[[name_2 for name_1 in name_0]] +del (), +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -1,18 +1,12 @@ + # long variable name +-this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( +- 0 +-) +-this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( +- 1 # with a comment +-) ++this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 0 ++this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 1 # with a comment + this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = [ + 1, + 2, + 3, + ] +-this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( +- function() +-) ++this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function() + this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + arg1, arg2, arg3 + ) +@@ -58,4 +52,4 @@ + [(), [], name_4, name_3], + name_1[[name_2 for name_1 in name_0]], + ) +-del ((),) ++del () +``` + +## Ruff Output + +```python +# long variable name +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 0 +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = 1 # with a comment +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = [ + 1, + 2, + 3, +] +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function() +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + arg1, arg2, arg3 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +# long function name +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying() +) +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + arg1, arg2, arg3 + ) +) +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 + ) +) +string_variable_name = "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do" # noqa +for key in """ + hostname + port + username +""".split(): + if key in self.connect_kwargs: + raise ValueError(err.format(key)) +concatenated_strings = ( + "some strings that are " + "concatenated implicitly, so if you put them on separate " + "lines it will fit" +) +del ( + concatenated_strings, + string_variable_name, + normal_function_name, + normal_name, + need_more_to_make_the_line_long_enough, +) +del ( + ([], name_1, name_2), + [(), [], name_4, name_3], + name_1[[name_2 for name_1 in name_0]], +) +del () +``` + +## Black Output + +```python +# long variable name +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( + 0 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( + 1 # with a comment +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = [ + 1, + 2, + 3, +] +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = ( + function() +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + arg1, arg2, arg3 +) +this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it = function( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 +) +# long function name +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying() +) +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + arg1, arg2, arg3 + ) +) +normal_name = ( + but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying( + [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3 + ) +) +string_variable_name = "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do" # noqa +for key in """ + hostname + port + username +""".split(): + if key in self.connect_kwargs: + raise ValueError(err.format(key)) +concatenated_strings = ( + "some strings that are " + "concatenated implicitly, so if you put them on separate " + "lines it will fit" +) +del ( + concatenated_strings, + string_variable_name, + normal_function_name, + normal_name, + need_more_to_make_the_line_long_enough, +) +del ( + ([], name_1, name_2), + [(), [], name_4, name_3], + name_1[[name_2 for name_1 in name_0]], +) +del ((),) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fmtskip10.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fmtskip10.py.snap new file mode 100644 index 0000000000..81404baffc --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fmtskip10.py.snap @@ -0,0 +1,76 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/fmtskip10.py +--- +## Input + +```python +def foo(): return "mock" # fmt: skip +if True: print("yay") # fmt: skip +for i in range(10): print(i) # fmt: skip + +j = 1 # fmt: skip +while j < 10: j += 1 # fmt: skip + +b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -1,8 +1,14 @@ +-def foo(): return "mock" # fmt: skip +-if True: print("yay") # fmt: skip +-for i in range(10): print(i) # fmt: skip ++def foo(): ++ return "mock" # fmt: skip ++ ++ ++if True: ++ print("yay") # fmt: skip ++for i in range(10): ++ print(i) # fmt: skip + +-j = 1 # fmt: skip +-while j < 10: j += 1 # fmt: skip ++j = 1 # fmt: skip ++while j < 10: ++ j += 1 # fmt: skip + +-b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip ++b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip +``` + +## Ruff Output + +```python +def foo(): + return "mock" # fmt: skip + + +if True: + print("yay") # fmt: skip +for i in range(10): + print(i) # fmt: skip + +j = 1 # fmt: skip +while j < 10: + j += 1 # fmt: skip + +b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip +``` + +## Black Output + +```python +def foo(): return "mock" # fmt: skip +if True: print("yay") # fmt: skip +for i in range(10): print(i) # fmt: skip + +j = 1 # fmt: skip +while j < 10: j += 1 # fmt: skip + +b = [c for c in "A very long string that would normally generate some kind of collapse, since it is this long"] # fmt: skip +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap index 82def26815..6ff1f73fd7 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap @@ -72,3 +72,17 @@ f'Hello \'{tricky + "example"}\'' f"Tried directories {str(rootdirs)} \ but none started with prefix {parentdir_prefix}" ``` + +## New Unsupported Syntax Errors + +error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) + --> fstring.py:6:9 + | +4 | f"some f-string with {a} {few():.2f} {formatted.values!r}" +5 | f"some f-string with {a} {few(''):.2f} {formatted.values!r}" +6 | f"{f'''{"nested"} inner'''} outer" + | ^ +7 | f'"{f"{nested} inner"}" outer' +8 | f"space between opening braces: { {a for a in (1, 2, 3)} }" + | +warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring_quotations.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring_quotations.py.snap new file mode 100644 index 0000000000..0657669f2e --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring_quotations.py.snap @@ -0,0 +1,126 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/fstring_quotations.py +--- +## Input + +```python +# Regression tests for long f-strings, including examples from issue #3623 + +a = ( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +) + +a = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + \ + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + +a = f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + \ + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + +a = ( + f'bbbbbbb"{"b"}"' + 'aaaaaaaa' +) + +a = ( + f'"{"b"}"' +) + +a = ( + f'\"{"b"}\"' +) + +a = ( + r'\"{"b"}\"' +) +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -20,7 +20,7 @@ + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + ) + +-a = f'bbbbbbb"{"b"}"' "aaaaaaaa" ++a = f'bbbbbbb"{"b"}"aaaaaaaa' + + a = f'"{"b"}"' + +``` + +## Ruff Output + +```python +# Regression tests for long f-strings, including examples from issue #3623 + +a = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +a = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = f'bbbbbbb"{"b"}"aaaaaaaa' + +a = f'"{"b"}"' + +a = f'"{"b"}"' + +a = r'\"{"b"}\"' +``` + +## Black Output + +```python +# Regression tests for long f-strings, including examples from issue #3623 + +a = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +a = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = ( + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' + + f'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"{"b"}"' +) + +a = f'bbbbbbb"{"b"}"' "aaaaaaaa" + +a = f'"{"b"}"' + +a = f'"{"b"}"' + +a = r'\"{"b"}\"' +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__generics_wrapping.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__generics_wrapping.py.snap new file mode 100644 index 0000000000..6df7d9b964 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__generics_wrapping.py.snap @@ -0,0 +1,561 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/generics_wrapping.py +--- +## Input + +```python +def plain[T, B](a: T, b: T) -> T: + return a + +def arg_magic[T, B](a: T, b: T,) -> T: + return a + +def type_param_magic[T, B,](a: T, b: T) -> T: + return a + +def both_magic[T, B,](a: T, b: T,) -> T: + return a + + +def plain_multiline[ + T, + B +]( + a: T, + b: T +) -> T: + return a + +def arg_magic_multiline[ + T, + B +]( + a: T, + b: T, +) -> T: + return a + +def type_param_magic_multiline[ + T, + B, +]( + a: T, + b: T +) -> T: + return a + +def both_magic_multiline[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_mixed1[ + T, + B +](a: T, b: T) -> T: + return a + +def plain_mixed2[T, B]( + a: T, + b: T +) -> T: + return a + +def arg_magic_mixed1[ + T, + B +](a: T, b: T,) -> T: + return a + +def arg_magic_mixed2[T, B]( + a: T, + b: T, +) -> T: + return a + +def type_param_magic_mixed1[ + T, + B, +](a: T, b: T) -> T: + return a + +def type_param_magic_mixed2[T, B,]( + a: T, + b: T +) -> T: + return a + +def both_magic_mixed1[ + T, + B, +](a: T, b: T,) -> T: + return a + +def both_magic_mixed2[T, B,]( + a: T, + b: T, +) -> T: + return a + +def something_something_function[ + T: Model +](param: list[int], other_param: type[T], *, some_other_param: bool = True) -> QuerySet[ + T +]: + pass + + +def func[A_LOT_OF_GENERIC_TYPES: AreBeingDefinedHere, LIKE_THIS, AND_THIS, ANOTHER_ONE, AND_YET_ANOTHER_ONE: ThisOneHasTyping](a: T, b: T, c: T, d: T, e: T, f: T, g: T, h: T, i: T, j: T, k: T, l: T, m: T, n: T, o: T, p: T) -> T: + return a + + +def with_random_comments[ + Z + # bye +](): + return a + + +def func[ + T, # comment + U # comment + , + Z: # comment + int +](): pass + + +def func[ + T, # comment but it's long so it doesn't just move to the end of the line + U # comment comment comm comm ent ent + , + Z: # comment ent ent comm comm comment + int +](): pass +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -12,9 +12,7 @@ + def type_param_magic[ + T, + B, +-]( +- a: T, b: T +-) -> T: ++](a: T, b: T) -> T: + return a + + +@@ -42,9 +40,7 @@ + def type_param_magic_multiline[ + T, + B, +-]( +- a: T, b: T +-) -> T: ++](a: T, b: T) -> T: + return a + + +@@ -83,18 +79,14 @@ + def type_param_magic_mixed1[ + T, + B, +-]( +- a: T, b: T +-) -> T: ++](a: T, b: T) -> T: + return a + + + def type_param_magic_mixed2[ + T, + B, +-]( +- a: T, b: T +-) -> T: ++](a: T, b: T) -> T: + return a + + +@@ -158,13 +150,19 @@ + return a + + +-def func[T, U, Z: int](): # comment # comment # comment ++def func[ ++ T, # comment ++ U, # comment ++ Z: # comment ++ int, ++](): + pass + + + def func[ + T, # comment but it's long so it doesn't just move to the end of the line + U, # comment comment comm comm ent ent +- Z: int, # comment ent ent comm comm comment ++ Z: # comment ent ent comm comm comment ++ int, + ](): + pass +``` + +## Ruff Output + +```python +def plain[T, B](a: T, b: T) -> T: + return a + + +def arg_magic[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic[ + T, + B, +](a: T, b: T) -> T: + return a + + +def both_magic[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_multiline[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_multiline[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic_multiline[ + T, + B, +](a: T, b: T) -> T: + return a + + +def both_magic_multiline[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_mixed1[T, B](a: T, b: T) -> T: + return a + + +def plain_mixed2[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_mixed1[T, B]( + a: T, + b: T, +) -> T: + return a + + +def arg_magic_mixed2[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic_mixed1[ + T, + B, +](a: T, b: T) -> T: + return a + + +def type_param_magic_mixed2[ + T, + B, +](a: T, b: T) -> T: + return a + + +def both_magic_mixed1[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def both_magic_mixed2[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def something_something_function[T: Model]( + param: list[int], other_param: type[T], *, some_other_param: bool = True +) -> QuerySet[T]: + pass + + +def func[ + A_LOT_OF_GENERIC_TYPES: AreBeingDefinedHere, + LIKE_THIS, + AND_THIS, + ANOTHER_ONE, + AND_YET_ANOTHER_ONE: ThisOneHasTyping, +]( + a: T, + b: T, + c: T, + d: T, + e: T, + f: T, + g: T, + h: T, + i: T, + j: T, + k: T, + l: T, + m: T, + n: T, + o: T, + p: T, +) -> T: + return a + + +def with_random_comments[ + Z + # bye +](): + return a + + +def func[ + T, # comment + U, # comment + Z: # comment + int, +](): + pass + + +def func[ + T, # comment but it's long so it doesn't just move to the end of the line + U, # comment comment comm comm ent ent + Z: # comment ent ent comm comm comment + int, +](): + pass +``` + +## Black Output + +```python +def plain[T, B](a: T, b: T) -> T: + return a + + +def arg_magic[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def both_magic[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_multiline[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_multiline[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic_multiline[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def both_magic_multiline[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def plain_mixed1[T, B](a: T, b: T) -> T: + return a + + +def plain_mixed2[T, B](a: T, b: T) -> T: + return a + + +def arg_magic_mixed1[T, B]( + a: T, + b: T, +) -> T: + return a + + +def arg_magic_mixed2[T, B]( + a: T, + b: T, +) -> T: + return a + + +def type_param_magic_mixed1[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def type_param_magic_mixed2[ + T, + B, +]( + a: T, b: T +) -> T: + return a + + +def both_magic_mixed1[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def both_magic_mixed2[ + T, + B, +]( + a: T, + b: T, +) -> T: + return a + + +def something_something_function[T: Model]( + param: list[int], other_param: type[T], *, some_other_param: bool = True +) -> QuerySet[T]: + pass + + +def func[ + A_LOT_OF_GENERIC_TYPES: AreBeingDefinedHere, + LIKE_THIS, + AND_THIS, + ANOTHER_ONE, + AND_YET_ANOTHER_ONE: ThisOneHasTyping, +]( + a: T, + b: T, + c: T, + d: T, + e: T, + f: T, + g: T, + h: T, + i: T, + j: T, + k: T, + l: T, + m: T, + n: T, + o: T, + p: T, +) -> T: + return a + + +def with_random_comments[ + Z + # bye +](): + return a + + +def func[T, U, Z: int](): # comment # comment # comment + pass + + +def func[ + T, # comment but it's long so it doesn't just move to the end of the line + U, # comment comment comm comm ent ent + Z: int, # comment ent ent comm comm comment +](): + pass +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__long_strings__type_annotations.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__long_strings__type_annotations.py.snap new file mode 100644 index 0000000000..903cb24cdb --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__long_strings__type_annotations.py.snap @@ -0,0 +1,114 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/long_strings__type_annotations.py +--- +## Input + +```python +def func( + arg1, + arg2, +) -> Set["this_is_a_very_long_module_name.AndAVeryLongClasName" + ".WithAVeryVeryVeryVeryVeryLongSubClassName"]: + pass + + +def func( + argument: ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" + ), +) -> ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" +): + pass + + +def func( + argument: ( + "int |" + "str" + ), +) -> Set["int |" + " str"]: + pass +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -21,6 +21,6 @@ + + + def func( +- argument: "int |" "str", +-) -> Set["int |" " str"]: ++ argument: ("int |str"), ++) -> Set["int | str"]: + pass +``` + +## Ruff Output + +```python +def func( + arg1, + arg2, +) -> Set[ + "this_is_a_very_long_module_name.AndAVeryLongClasName" + ".WithAVeryVeryVeryVeryVeryLongSubClassName" +]: + pass + + +def func( + argument: ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" + ), +) -> ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" +): + pass + + +def func( + argument: ("int |str"), +) -> Set["int | str"]: + pass +``` + +## Black Output + +```python +def func( + arg1, + arg2, +) -> Set[ + "this_is_a_very_long_module_name.AndAVeryLongClasName" + ".WithAVeryVeryVeryVeryVeryLongSubClassName" +]: + pass + + +def func( + argument: ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" + ), +) -> ( + "VeryLongClassNameWithAwkwardGenericSubtype[int] |" + "VeryLongClassNameWithAwkwardGenericSubtype[str]" +): + pass + + +def func( + argument: "int |" "str", +) -> Set["int |" " str"]: + pass +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__no_blank_line_before_docstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__no_blank_line_before_docstring.py.snap deleted file mode 100644 index 379f746fae..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__no_blank_line_before_docstring.py.snap +++ /dev/null @@ -1,122 +0,0 @@ ---- -source: crates/ruff_python_formatter/tests/fixtures.rs -input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/no_blank_line_before_docstring.py -snapshot_kind: text ---- -## Input - -```python -def line_before_docstring(): - - """Please move me up""" - - -class LineBeforeDocstring: - - """Please move me up""" - - -class EvenIfThereIsAMethodAfter: - - """I'm the docstring""" - def method(self): - pass - - -class TwoLinesBeforeDocstring: - - - """I want to be treated the same as if I were closer""" - - -class MultilineDocstringsAsWell: - - """I'm so far - - and on so many lines... - """ - -class SingleQuotedDocstring: - - "I'm a docstring but I don't even get triple quotes." -``` - -## Black Differences - -```diff ---- Black -+++ Ruff -@@ -25,5 +25,4 @@ - - - class SingleQuotedDocstring: -- - "I'm a docstring but I don't even get triple quotes." -``` - -## Ruff Output - -```python -def line_before_docstring(): - """Please move me up""" - - -class LineBeforeDocstring: - """Please move me up""" - - -class EvenIfThereIsAMethodAfter: - """I'm the docstring""" - - def method(self): - pass - - -class TwoLinesBeforeDocstring: - """I want to be treated the same as if I were closer""" - - -class MultilineDocstringsAsWell: - """I'm so far - - and on so many lines... - """ - - -class SingleQuotedDocstring: - "I'm a docstring but I don't even get triple quotes." -``` - -## Black Output - -```python -def line_before_docstring(): - """Please move me up""" - - -class LineBeforeDocstring: - """Please move me up""" - - -class EvenIfThereIsAMethodAfter: - """I'm the docstring""" - - def method(self): - pass - - -class TwoLinesBeforeDocstring: - """I want to be treated the same as if I were closer""" - - -class MultilineDocstringsAsWell: - """I'm so far - - and on so many lines... - """ - - -class SingleQuotedDocstring: - - "I'm a docstring but I don't even get triple quotes." -``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep604_union_types_line_breaks.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep604_union_types_line_breaks.py.snap index dc61db96dc..f5eba916b2 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep604_union_types_line_breaks.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep604_union_types_line_breaks.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/pep604_union_types_line_breaks.py -snapshot_kind: text --- ## Input @@ -27,7 +26,7 @@ z: (Short z: (int) = 2.3 z: ((int)) = foo() -# In case I go for not enforcing parentheses, this might get improved at the same time +# In case I go for not enforcing parantheses, this might get improved at the same time x = ( z == 9999999999999999999999999999999999999999 @@ -166,7 +165,7 @@ z: Short | Short2 | Short3 | Short4 = 8 z: int = 2.3 z: int = foo() -# In case I go for not enforcing parentheses, this might get improved at the same time +# In case I go for not enforcing parantheses, this might get improved at the same time x = ( z == 9999999999999999999999999999999999999999 @@ -270,7 +269,7 @@ z: Short | Short2 | Short3 | Short4 = 8 z: int = 2.3 z: int = foo() -# In case I go for not enforcing parentheses, this might get improved at the same time +# In case I go for not enforcing parantheses, this might get improved at the same time x = ( z == 9999999999999999999999999999999999999999 diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep_701.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep_701.py.snap index 63d6544faa..0b1894126c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep_701.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__pep_701.py.snap @@ -80,8 +80,9 @@ x = f"a{2+2:=^{foo(x+y**2):something else}}b" x = f"a{2+2:=^{foo(x+y**2):something else}one more}b" f'{(abc:=10)}' -f"This is a really long string, but just make sure that you reflow fstrings { - 2+2:d}" +f"""This is a really long string, but just make sure that you reflow fstrings { + 2+2:d +}""" f"This is a really long string, but just make sure that you reflow fstrings correctly {2+2:d}" f"{2+2=}" @@ -168,7 +169,7 @@ rf"\{"a"}" x = """foo {{ {2 + 2}bar baz""" -@@ -28,55 +26,48 @@ +@@ -28,55 +26,50 @@ x = f"""foo {{ {2 + 2}bar {{ baz""" @@ -231,16 +232,16 @@ rf"\{"a"}" +x = f"a{2 + 2:=^{foo(x + y**2):something else}one more}b" +f"{(abc := 10)}" --f"This is a really long string, but just make sure that you reflow fstrings { + f"""This is a really long string, but just make sure that you reflow fstrings { - 2+2:d --}" ++ 2 + 2:d + }""" -f"This is a really long string, but just make sure that you reflow fstrings correctly {2+2:d}" -+f"This is a really long string, but just make sure that you reflow fstrings {2 + 2:d}" +f"This is a really long string, but just make sure that you reflow fstrings correctly {2 + 2:d}" f"{2+2=}" f"{2+2 = }" -@@ -88,14 +79,10 @@ +@@ -88,14 +81,10 @@ %d }""" @@ -257,7 +258,7 @@ rf"\{"a"}" ) f"`escape` only permitted in {{'html', 'latex', 'latex-math'}}, \ -@@ -105,8 +92,10 @@ +@@ -105,8 +94,10 @@ rf"\{{\}}" f""" @@ -270,7 +271,7 @@ rf"\{"a"}" """ value: str = f"""foo -@@ -124,13 +113,15 @@ +@@ -124,13 +115,15 @@ f'{{\\"kind\\":\\"ConfigMap\\",\\"metadata\\":{{\\"annotations\\":{{}},\\"name\\":\\"cluster-info\\",\\"namespace\\":\\"amazon-cloudwatch\\"}}}}' @@ -364,7 +365,9 @@ x = f"a{2 + 2:=^{foo(x + y**2):something else}}b" x = f"a{2 + 2:=^{foo(x + y**2):something else}one more}b" f"{(abc := 10)}" -f"This is a really long string, but just make sure that you reflow fstrings {2 + 2:d}" +f"""This is a really long string, but just make sure that you reflow fstrings { + 2 + 2:d +}""" f"This is a really long string, but just make sure that you reflow fstrings correctly {2 + 2:d}" f"{2+2=}" @@ -503,9 +506,9 @@ x = f"a{2+2:=^{foo(x+y**2):something else}}b" x = f"a{2+2:=^{foo(x+y**2):something else}one more}b" f"{(abc:=10)}" -f"This is a really long string, but just make sure that you reflow fstrings { +f"""This is a really long string, but just make sure that you reflow fstrings { 2+2:d -}" +}""" f"This is a really long string, but just make sure that you reflow fstrings correctly {2+2:d}" f"{2+2=}" diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__prefer_rhs_split_reformatted.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__prefer_rhs_split_reformatted.py.snap new file mode 100644 index 0000000000..a21a90481e --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__prefer_rhs_split_reformatted.py.snap @@ -0,0 +1,123 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/prefer_rhs_split_reformatted.py +--- +## Input + +```python +# Test cases separate from `prefer_rhs_split.py` that contains unformatted source. + +# Left hand side fits in a single line but will still be exploded by the +# magic trailing comma. +first_value, (m1, m2,), third_value = xxxxxx_yyyyyy_zzzzzz_wwwwww_uuuuuuu_vvvvvvvvvvv( + arg1, + arg2, +) + +# Make when when the left side of assignment plus the opening paren "... = (" is +# exactly line length limit + 1, it won't be split like that. +xxxxxxxxx_yyy_zzzzzzzz[xx.xxxxxx(x_yyy_zzzzzz.xxxxx[0]), x_yyy_zzzzzz.xxxxxx(xxxx=1)] = 1 + +# Regression test for #1187 +print( + dict( + a=1, + b=2 if some_kind_of_data is not None else some_other_kind_of_data, # some explanation of why this is actually necessary + c=3, + ) +) +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -24,9 +24,9 @@ + print( + dict( + a=1, +- b=( +- 2 if some_kind_of_data is not None else some_other_kind_of_data +- ), # some explanation of why this is actually necessary ++ b=2 ++ if some_kind_of_data is not None ++ else some_other_kind_of_data, # some explanation of why this is actually necessary + c=3, + ) + ) +``` + +## Ruff Output + +```python +# Test cases separate from `prefer_rhs_split.py` that contains unformatted source. + +# Left hand side fits in a single line but will still be exploded by the +# magic trailing comma. +( + first_value, + ( + m1, + m2, + ), + third_value, +) = xxxxxx_yyyyyy_zzzzzz_wwwwww_uuuuuuu_vvvvvvvvvvv( + arg1, + arg2, +) + +# Make when when the left side of assignment plus the opening paren "... = (" is +# exactly line length limit + 1, it won't be split like that. +xxxxxxxxx_yyy_zzzzzzzz[ + xx.xxxxxx(x_yyy_zzzzzz.xxxxx[0]), x_yyy_zzzzzz.xxxxxx(xxxx=1) +] = 1 + +# Regression test for #1187 +print( + dict( + a=1, + b=2 + if some_kind_of_data is not None + else some_other_kind_of_data, # some explanation of why this is actually necessary + c=3, + ) +) +``` + +## Black Output + +```python +# Test cases separate from `prefer_rhs_split.py` that contains unformatted source. + +# Left hand side fits in a single line but will still be exploded by the +# magic trailing comma. +( + first_value, + ( + m1, + m2, + ), + third_value, +) = xxxxxx_yyyyyy_zzzzzz_wwwwww_uuuuuuu_vvvvvvvvvvv( + arg1, + arg2, +) + +# Make when when the left side of assignment plus the opening paren "... = (" is +# exactly line length limit + 1, it won't be split like that. +xxxxxxxxx_yyy_zzzzzzzz[ + xx.xxxxxx(x_yyy_zzzzzz.xxxxx[0]), x_yyy_zzzzzz.xxxxxx(xxxx=1) +] = 1 + +# Regression test for #1187 +print( + dict( + a=1, + b=( + 2 if some_kind_of_data is not None else some_other_kind_of_data + ), # some explanation of why this is actually necessary + c=3, + ) +) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_comments7.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_comments7.py.snap index 36f29c9177..ab98186210 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_comments7.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_comments7.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_comments7.py -snapshot_kind: text --- ## Input @@ -157,7 +156,12 @@ square = Square(4) # type: Optional[Square] ```diff --- Black +++ Ruff -@@ -34,13 +34,9 @@ +@@ -29,17 +29,14 @@ + MyLovelyCompanyTeamProjectComponent as component, # DRY + ) + ++ + result = 1 # look ma, no comment migration xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx result = 1 # look ma, no comment migration xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx @@ -173,7 +177,7 @@ square = Square(4) # type: Optional[Square] def func(): -@@ -52,12 +48,19 @@ +@@ -51,12 +48,19 @@ 0.0789, a[-1], # type: ignore ) @@ -194,7 +198,7 @@ square = Square(4) # type: Optional[Square] 0.0456, 0.0789, 0.0123, -@@ -91,53 +94,39 @@ +@@ -90,53 +94,39 @@ # metadata_version errors. ( {}, @@ -264,7 +268,7 @@ square = Square(4) # type: Optional[Square] ), ], ) -@@ -150,8 +139,8 @@ +@@ -149,8 +139,8 @@ # Regression test for https://github.com/psf/black/issues/3756. [ @@ -466,7 +470,6 @@ from com.my_lovely_company.my_lovely_team.my_lovely_project.my_lovely_component MyLovelyCompanyTeamProjectComponent as component, # DRY ) - result = 1 # look ma, no comment migration xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx result = 1 # look ma, no comment migration xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_import_line_collapse.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_import_line_collapse.py.snap new file mode 100644 index 0000000000..535c0d3426 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_import_line_collapse.py.snap @@ -0,0 +1,325 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_import_line_collapse.py +--- +## Input + +```python +from middleman.authentication import validate_oauth_token + + +logger = logging.getLogger(__name__) + + +# case 2 comment after import +from middleman.authentication import validate_oauth_token +#comment + +logger = logging.getLogger(__name__) + + +# case 3 comment after import +from middleman.authentication import validate_oauth_token +# comment +logger = logging.getLogger(__name__) + + +from middleman.authentication import validate_oauth_token + + + +logger = logging.getLogger(__name__) + + +# case 4 try catch with import after import +import os +import os + + + +try: + import os +except Exception: + pass + +try: + import os + def func(): + a = 1 +except Exception: + pass + + +# case 5 multiple imports +import os +import os + +import os +import os + + + + + +for i in range(10): + print(i) + + +# case 6 import in function +def func(): + print() + import os + def func(): + pass + print() + + +def func(): + import os + a = 1 + print() + + +def func(): + import os + + + a = 1 + print() + + +def func(): + import os + + + + a = 1 + print() +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -1,11 +1,11 @@ + from middleman.authentication import validate_oauth_token + ++ + logger = logging.getLogger(__name__) + + + # case 2 comment after import + from middleman.authentication import validate_oauth_token +- + # comment + + logger = logging.getLogger(__name__) +@@ -20,6 +20,7 @@ + + from middleman.authentication import validate_oauth_token + ++ + logger = logging.getLogger(__name__) + + +@@ -27,6 +28,7 @@ + import os + import os + ++ + try: + import os + except Exception: +@@ -49,6 +51,7 @@ + import os + import os + ++ + for i in range(10): + print(i) + +``` + +## Ruff Output + +```python +from middleman.authentication import validate_oauth_token + + +logger = logging.getLogger(__name__) + + +# case 2 comment after import +from middleman.authentication import validate_oauth_token +# comment + +logger = logging.getLogger(__name__) + + +# case 3 comment after import +from middleman.authentication import validate_oauth_token + +# comment +logger = logging.getLogger(__name__) + + +from middleman.authentication import validate_oauth_token + + +logger = logging.getLogger(__name__) + + +# case 4 try catch with import after import +import os +import os + + +try: + import os +except Exception: + pass + +try: + import os + + def func(): + a = 1 + +except Exception: + pass + + +# case 5 multiple imports +import os +import os + +import os +import os + + +for i in range(10): + print(i) + + +# case 6 import in function +def func(): + print() + import os + + def func(): + pass + + print() + + +def func(): + import os + + a = 1 + print() + + +def func(): + import os + + a = 1 + print() + + +def func(): + import os + + a = 1 + print() +``` + +## Black Output + +```python +from middleman.authentication import validate_oauth_token + +logger = logging.getLogger(__name__) + + +# case 2 comment after import +from middleman.authentication import validate_oauth_token + +# comment + +logger = logging.getLogger(__name__) + + +# case 3 comment after import +from middleman.authentication import validate_oauth_token + +# comment +logger = logging.getLogger(__name__) + + +from middleman.authentication import validate_oauth_token + +logger = logging.getLogger(__name__) + + +# case 4 try catch with import after import +import os +import os + +try: + import os +except Exception: + pass + +try: + import os + + def func(): + a = 1 + +except Exception: + pass + + +# case 5 multiple imports +import os +import os + +import os +import os + +for i in range(10): + print(i) + + +# case 6 import in function +def func(): + print() + import os + + def func(): + pass + + print() + + +def func(): + import os + + a = 1 + print() + + +def func(): + import os + + a = 1 + print() + + +def func(): + import os + + a = 1 + print() +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap index f679636113..93f28b8669 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap @@ -1,11 +1,31 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_dict_values.py -snapshot_kind: text --- ## Input ```python +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "foo": bar, + "foo": bar, + "foo": ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} + my_dict = { "something_something": r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t" @@ -13,23 +33,90 @@ my_dict = { r"signiferumque, duo ea vocibus consetetur scriptorem. Facer \t", } +# Function calls as keys +tasks = { + get_key_name( + foo, + bar, + baz, + ): src, + loop.run_in_executor(): src, + loop.run_in_executor(xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx): src, + loop.run_in_executor( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxx + ): src, + loop.run_in_executor(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} + +# Dictionary comprehensions +tasks = { + key_name: ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {key_name: foobar for src in sources} +tasks = { + get_key_name( + src, + ): "foo" + for src in sources +} +tasks = { + get_key_name( + foo, + bar, + baz, + ): src + for src in sources +} +tasks = { + get_key_name(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {get_key_name(): foobar for src in sources} + + +# Delimiters inside the value +def foo(): + def bar(): + x = { + common.models.DateTimeField: datetime(2020, 1, 31, tzinfo=utc) + timedelta( + days=i + ), + } + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + "foobar": (123 + 456), + } + x = { + "foobar": (123) + 456, + } + + my_dict = { "a key in my dict": a_very_long_variable * and_a_very_long_function_call() / 100000.0 } - my_dict = { "a key in my dict": a_very_long_variable * and_a_very_long_function_call() * and_another_long_func() / 100000.0 } - my_dict = { "a key in my dict": MyClass.some_attribute.first_call().second_call().third_call(some_args="some value") } { - 'xxxxxx': + "xxxxxx": xxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxx( xxxxxxxxxxxxxx={ - 'x': + "x": xxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxx( xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=( xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx @@ -37,8 +124,8 @@ my_dict = { xxxxxxxxxxxxx=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx .xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx( xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx={ - 'x': x.xx, - 'x': x.x, + "x": x.xx, + "x": x.x, })))) }), } @@ -69,7 +156,9 @@ class Random: ```diff --- Black +++ Ruff -@@ -1,32 +1,26 @@ +@@ -20,11 +20,9 @@ + } + my_dict = { - "something_something": ( - r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t" @@ -81,6 +170,31 @@ class Random: + r"signiferumque, duo ea vocibus consetetur scriptorem. Facer \t", } + # Function calls as keys +@@ -79,9 +77,8 @@ + def foo(): + def bar(): + x = { +- common.models.DateTimeField: ( +- datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) +- ), ++ common.models.DateTimeField: datetime(2020, 1, 31, tzinfo=utc) ++ + timedelta(days=i), + } + x = { + common.models.DateTimeField: ( +@@ -89,7 +86,7 @@ + ), + } + x = { +- "foobar": 123 + 456, ++ "foobar": (123 + 456), + } + x = { + "foobar": (123) + 456, +@@ -97,24 +94,20 @@ + + my_dict = { - "a key in my dict": ( - a_very_long_variable * and_a_very_long_function_call() / 100000.0 @@ -89,7 +203,6 @@ class Random: + * and_a_very_long_function_call() + / 100000.0 } - my_dict = { - "a key in my dict": ( - a_very_long_variable @@ -102,7 +215,6 @@ class Random: + * and_another_long_func() + / 100000.0 } - my_dict = { - "a key in my dict": ( - MyClass.some_attribute.first_call() @@ -115,7 +227,16 @@ class Random: } { -@@ -58,9 +52,9 @@ +@@ -139,17 +132,17 @@ + + class Random: + def func(): +- random_service.status.active_states.inactive = make_new_top_level_state_from_dict( +- { ++ random_service.status.active_states.inactive = ( ++ make_new_top_level_state_from_dict({ + "topLevelBase": { + "secondaryBase": { "timestamp": 1234, "latitude": 1, "longitude": 2, @@ -127,31 +248,120 @@ class Random: + ).ToJsonString(), } }, - }) +- } ++ }) + ) ``` ## Ruff Output ```python +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "foo": bar, + "foo": bar, + "foo": ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} + my_dict = { "something_something": r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t" r"signiferumque, duo ea vocibus consetetur scriptorem. Facer \t" r"signiferumque, duo ea vocibus consetetur scriptorem. Facer \t", } +# Function calls as keys +tasks = { + get_key_name( + foo, + bar, + baz, + ): src, + loop.run_in_executor(): src, + loop.run_in_executor(xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx): src, + loop.run_in_executor( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxx + ): src, + loop.run_in_executor(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} + +# Dictionary comprehensions +tasks = { + key_name: ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {key_name: foobar for src in sources} +tasks = { + get_key_name( + src, + ): "foo" + for src in sources +} +tasks = { + get_key_name( + foo, + bar, + baz, + ): src + for src in sources +} +tasks = { + get_key_name(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {get_key_name(): foobar for src in sources} + + +# Delimiters inside the value +def foo(): + def bar(): + x = { + common.models.DateTimeField: datetime(2020, 1, 31, tzinfo=utc) + + timedelta(days=i), + } + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + "foobar": (123 + 456), + } + x = { + "foobar": (123) + 456, + } + + my_dict = { "a key in my dict": a_very_long_variable * and_a_very_long_function_call() / 100000.0 } - my_dict = { "a key in my dict": a_very_long_variable * and_a_very_long_function_call() * and_another_long_func() / 100000.0 } - my_dict = { "a key in my dict": MyClass.some_attribute.first_call() .second_call() @@ -199,6 +409,27 @@ class Random: ## Black Output ```python +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "foo": bar, + "foo": bar, + "foo": ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} + my_dict = { "something_something": ( r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t" @@ -207,12 +438,80 @@ my_dict = { ), } +# Function calls as keys +tasks = { + get_key_name( + foo, + bar, + baz, + ): src, + loop.run_in_executor(): src, + loop.run_in_executor(xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx): src, + loop.run_in_executor( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxx + ): src, + loop.run_in_executor(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ), +} + +# Dictionary comprehensions +tasks = { + key_name: ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {key_name: foobar for src in sources} +tasks = { + get_key_name( + src, + ): "foo" + for src in sources +} +tasks = { + get_key_name( + foo, + bar, + baz, + ): src + for src in sources +} +tasks = { + get_key_name(): ( + xx_xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxxxxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx + ) + for src in sources +} +tasks = {get_key_name(): foobar for src in sources} + + +# Delimiters inside the value +def foo(): + def bar(): + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + common.models.DateTimeField: ( + datetime(2020, 1, 31, tzinfo=utc) + timedelta(days=i) + ), + } + x = { + "foobar": 123 + 456, + } + x = { + "foobar": (123) + 456, + } + + my_dict = { "a key in my dict": ( a_very_long_variable * and_a_very_long_function_call() / 100000.0 ) } - my_dict = { "a key in my dict": ( a_very_long_variable @@ -221,7 +520,6 @@ my_dict = { / 100000.0 ) } - my_dict = { "a key in my dict": ( MyClass.some_attribute.first_call() @@ -252,8 +550,8 @@ my_dict = { class Random: def func(): - random_service.status.active_states.inactive = ( - make_new_top_level_state_from_dict({ + random_service.status.active_states.inactive = make_new_top_level_state_from_dict( + { "topLevelBase": { "secondaryBase": { "timestamp": 1234, @@ -264,6 +562,6 @@ class Random: ), } }, - }) + } ) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap index 3b7b47cb74..7b36236110 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings.py -snapshot_kind: text --- ## Input @@ -286,7 +285,7 @@ string_with_escaped_nameescape = ( "........................................................................... \\N{LAO KO LA}" ) -msg = lambda x: f"this is a very very very long lambda value {x} that doesn't fit on a single line" +msg = lambda x: f"this is a very very very very long lambda value {x} that doesn't fit on a single line" dict_with_lambda_values = { "join": lambda j: ( @@ -335,6 +334,20 @@ log.info(f'''Skipping: {"a" == 'b'} {desc["ms_name"]} {money=} {dte=} {pos_share log.info(f'''Skipping: {'a' == "b"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}''') log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""") + +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx", +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" + ) +} ``` ## Black Differences @@ -841,7 +854,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share long_unmergable_string_with_pragma = ( "This is a really long string that can't be merged because it has a likely pragma at the end" # type: ignore -@@ -468,51 +358,24 @@ +@@ -468,49 +358,24 @@ " of it." ) @@ -893,16 +906,15 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share -) +string_with_escaped_nameescape = "........................................................................... \\N{LAO KO LA}" - msg = ( -- lambda x: ( -- f"this is a very very very long lambda value {x} that doesn't fit on a single" -- " line" -- ) -+ lambda x: f"this is a very very very long lambda value {x} that doesn't fit on a single line" +-msg = lambda x: ( +- f"this is a very very very very long lambda value {x} that doesn't fit on a" +- " single line" ++msg = ( ++ lambda x: f"this is a very very very very long lambda value {x} that doesn't fit on a single line" ) dict_with_lambda_values = { -@@ -524,65 +387,58 @@ +@@ -522,65 +387,58 @@ # Complex string concatenations with a method call in the middle. code = ( @@ -927,50 +939,50 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share +call(body=("%s %s" % ((",".join(items)), suffix))) log.info( -- "Skipping:" -- f' {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]=} {desc["exposure_max"]=}' +- f'Skipping: {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=}' +- f' {desc["status"]=} {desc["exposure_max"]=}' + f'Skipping: {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]=} {desc["exposure_max"]=}' ) log.info( -- "Skipping:" -- f" {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']=} {desc['exposure_max']=}" +- f"Skipping: {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=}" +- f" {desc['status']=} {desc['exposure_max']=}" + f"Skipping: {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']=} {desc['exposure_max']=}" ) log.info( -- "Skipping:" -- f" {desc['db_id']} {foo('bar',x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" +- f'Skipping: {desc["db_id"]} {foo("bar",x=123)} {"foo" != "bar"} {(x := "abc=")}' +- f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( -- "Skipping:" -- f' {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' +- f'Skipping: {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=}' +- f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( -- "Skipping:" -- f' {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' +- f'Skipping: {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=}' +- f' {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( -- "Skipping:" -- f' {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' +- f'Skipping: {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=}' +- f' {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( -- "Skipping:" -- f" {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" +- f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=}' +- f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( -- "Skipping:" -- f' {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' +- f'Skipping: {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=}' +- f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) @@ -986,13 +998,30 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share ) log.info( -@@ -590,5 +446,5 @@ +@@ -588,7 +446,7 @@ ) log.info( - f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" + f"""Skipping: {"a" == "b"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" ) + + x = { +@@ -597,10 +455,10 @@ + ) + } + x = { +- "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( +- "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" +- ), ++ "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx", + } + x = { +- "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" ++ "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( ++ "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" ++ ) + } ``` ## Ruff Output @@ -1375,7 +1404,7 @@ string_with_escaped_nameescape = ".............................................. string_with_escaped_nameescape = "........................................................................... \\N{LAO KO LA}" msg = ( - lambda x: f"this is a very very very long lambda value {x} that doesn't fit on a single line" + lambda x: f"this is a very very very very long lambda value {x} that doesn't fit on a single line" ) dict_with_lambda_values = { @@ -1448,6 +1477,20 @@ log.info( log.info( f"""Skipping: {"a" == "b"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}""" ) + +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx", +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" + ) +} ``` ## Black Output @@ -1963,11 +2006,9 @@ string_with_escaped_nameescape = ( " \\N{LAO KO LA}" ) -msg = ( - lambda x: ( - f"this is a very very very long lambda value {x} that doesn't fit on a single" - " line" - ) +msg = lambda x: ( + f"this is a very very very very long lambda value {x} that doesn't fit on a" + " single line" ) dict_with_lambda_values = { @@ -1992,43 +2033,43 @@ code = ( call(body="%s %s" % (",".join(items), suffix)) log.info( - "Skipping:" - f' {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]=} {desc["exposure_max"]=}' + f'Skipping: {desc["db_id"]=} {desc["ms_name"]} {money=} {dte=} {pos_share=}' + f' {desc["status"]=} {desc["exposure_max"]=}' ) log.info( - "Skipping:" - f" {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']=} {desc['exposure_max']=}" + f"Skipping: {desc['db_id']=} {desc['ms_name']} {money=} {dte=} {pos_share=}" + f" {desc['status']=} {desc['exposure_max']=}" ) log.info( - "Skipping:" - f" {desc['db_id']} {foo('bar',x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" + f'Skipping: {desc["db_id"]} {foo("bar",x=123)} {"foo" != "bar"} {(x := "abc=")}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {desc["db_id"]} {desc["ms_name"]} {money=} {(x := "abc=")=}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {desc["db_id"]} {foo("bar",x=123)=} {money=} {dte=} {pos_share=}' + f' {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {foo("asdf")=} {desc["ms_name"]} {money=} {dte=} {pos_share=}' + f' {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f" {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" + f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( - "Skipping:" - f' {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f'Skipping: {"a" == "b" == "c" == "d"=} {desc["ms_name"]} {money=} {dte=}' + f' {pos_share=} {desc["status"]} {desc["exposure_max"]}' ) log.info( @@ -2047,4 +2088,18 @@ log.info( log.info( f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}""" ) + +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ) +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxxx{xx}xxx_xxxxx_xxxxxxxxx_xxxxxxxxxxxx_xxxx" + ), +} +x = { + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": "xx:xxxxxxxxxxxxxxxxx_xxxxx_xxxxxxx_xxxxxxxxxx" +} ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap index 7f61408c3c..615da9cd12 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_long_strings__regression.py -snapshot_kind: text --- ## Input @@ -559,6 +558,7 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. +# Regressed again by https://github.com/psf/black/pull/4498 s = ( "With single quote: ' " f" {my_dict['foo']}" @@ -684,7 +684,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + ( + "xxxxxxxxxx xxxx xx xxxxxx(%x) xx %x xxxx xx xxx %x.xx" + % (len(self) + 1, xxxx.xxxxxxxxxx, xxxx.xxxxxxxxxx) - ) ++ ) + + ( + " %.3f (%s) to %.3f (%s).\n" + % ( @@ -693,7 +693,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + x, + xxxx.xxxxxxxxxxxxxx(xx), + ) -+ ) + ) ) @@ -1171,13 +1171,21 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ) # Regression test for https://github.com/psf/black/issues/3455. -@@ -674,7 +621,4 @@ +@@ -673,14 +620,6 @@ + # Regression test for https://github.com/psf/black/issues/3506. - s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" + # Regressed again by https://github.com/psf/black/pull/4498 +-s = ( +- "With single quote: ' " +- f" {my_dict['foo']}" +- ' With double quote: " ' +- f' {my_dict["bar"]}' +-) ++s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" -s = ( - "Lorem Ipsum is simply dummy text of the printing and typesetting" -- f" industry:'{my_dict['foo']}'" +- f' industry:\'{my_dict["foo"]}\'' -) +s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` @@ -1806,6 +1814,7 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. +# Regressed again by https://github.com/psf/black/pull/4498 s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" @@ -2488,10 +2497,16 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. -s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" +# Regressed again by https://github.com/psf/black/pull/4498 +s = ( + "With single quote: ' " + f" {my_dict['foo']}" + ' With double quote: " ' + f' {my_dict["bar"]}' +) s = ( "Lorem Ipsum is simply dummy text of the printing and typesetting" - f" industry:'{my_dict['foo']}'" + f' industry:\'{my_dict["foo"]}\'' ) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap index 8d58214ce1..353d573496 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_multiline_strings.py -snapshot_kind: text --- ## Input @@ -188,6 +187,73 @@ test assert some_var == expected_result, f""" expected: {expected_result} actual: {some_var}""" + + +def foo(): + a = { + xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx: { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxxxxx": """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + }, + } + + +xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx = { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": """ +a +a +a +a +a""", +} + +a = """ +""" if """ +""" == """ +""" else """ +""" + +a = """ +""" if b else """ +""" + +a = """ +""" if """ +""" == """ +""" else b + +a = b if """ +""" == """ +""" else """ +""" + +a = """ +""" if b else c + +a = c if b else """ +""" + +a = b if """ +""" == """ +""" else c ``` ## Black Differences @@ -378,6 +444,36 @@ actual: {some_var}""" assert some_var == expected_result, """ test +@@ -224,10 +267,8 @@ + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), +- "xxxxxxxx": ( +- """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx +- xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" +- ), ++ "xxxxxxxx": """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx ++ xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""", + }, + } + +@@ -246,14 +287,12 @@ + a + a""" + ), +- "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( +- """ ++ "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": """ + a + a + a + a +-a""" +- ), ++a""", + } + + a = ( ``` ## Ruff Output @@ -642,6 +738,105 @@ test assert some_var == expected_result, f""" expected: {expected_result} actual: {some_var}""" + + +def foo(): + a = { + xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx: { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxxxxx": """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""", + }, + } + + +xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx = { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": """ +a +a +a +a +a""", +} + +a = ( + """ +""" + if """ +""" + == """ +""" + else """ +""" +) + +a = ( + """ +""" + if b + else """ +""" +) + +a = ( + """ +""" + if """ +""" + == """ +""" + else b +) + +a = ( + b + if """ +""" + == """ +""" + else """ +""" +) + +a = ( + """ +""" + if b + else c +) + +a = ( + c + if b + else """ +""" +) + +a = ( + b + if """ +""" + == """ +""" + else c +) ``` ## Black Output @@ -863,4 +1058,107 @@ test assert some_var == expected_result, f""" expected: {expected_result} actual: {some_var}""" + + +def foo(): + a = { + xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx: { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + }, + } + + +xxxx_xxxxxxx.xxxxxx_xxxxxxxxxxxx_xxxxxx_xx_xxx_xxxxxx = { + "xxxxx": """Sxxxxx xxxxxxxxxxxx xxx xxxxx (xxxxxx xxx xxxxxxx)""", + "xxxxxxxx": ( + """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx + xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" + ), + "xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), + "xx_xxxxx_xxxxxxxxxx_xxxxxxxxx_xx": ( + """ +a +a +a +a +a""" + ), +} + +a = ( + """ +""" + if """ +""" + == """ +""" + else """ +""" +) + +a = ( + """ +""" + if b + else """ +""" +) + +a = ( + """ +""" + if """ +""" + == """ +""" + else b +) + +a = ( + b + if """ +""" + == """ +""" + else """ +""" +) + +a = ( + """ +""" + if b + else c +) + +a = ( + c + if b + else """ +""" +) + +a = ( + b + if """ +""" + == """ +""" + else c +) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_remove_multiline_lone_list_item_parens.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_remove_multiline_lone_list_item_parens.py.snap new file mode 100644 index 0000000000..1abf5ac61e --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_remove_multiline_lone_list_item_parens.py.snap @@ -0,0 +1,535 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_remove_multiline_lone_list_item_parens.py +--- +## Input + +```python +items = [(x for x in [1])] + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2"} + if some_var == "" + else {"key": "val"} + ) +] +items = [ + ( + "123456890123457890123468901234567890" + if some_var == "long strings" + else "123467890123467890" + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ) +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ) +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ) +] + +# Shouldn't remove trailing commas +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ), +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ), +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ), +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ), +] + +# Shouldn't add parentheses +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Shouldn't crash with comments +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -1,30 +1,40 @@ + items = [(x for x in [1])] + + items = [ +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) + ] +-items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] ++items = [({"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"})] + items = [ +- "123456890123457890123468901234567890" +- if some_var == "long strings" +- else "123467890123467890" ++ ( ++ "123456890123457890123468901234567890" ++ if some_var == "long strings" ++ else "123467890123467890" ++ ) + ] + items = [ +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- and some_var == "long strings" +- and {"key": "val"} ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ and some_var == "long strings" ++ and {"key": "val"} ++ ) + ] + items = [ +- "123456890123457890123468901234567890" +- and some_var == "long strings" +- and "123467890123467890" ++ ( ++ "123456890123457890123468901234567890" ++ and some_var == "long strings" ++ and "123467890123467890" ++ ) + ] + items = [ +- long_variable_name +- and even_longer_variable_name +- and yet_another_very_long_variable_name ++ ( ++ long_variable_name ++ and even_longer_variable_name ++ and yet_another_very_long_variable_name ++ ) + ] + + # Shouldn't remove trailing commas +@@ -66,41 +76,55 @@ + items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + + # Shouldn't crash with comments +-items = [ # comment +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} ++items = [ ++ ( # comment ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) + ] + items = [ +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} +-] # comment ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) # comment ++] + + items = [ # comment +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) + ] + items = [ +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) + ] # comment + + items = [ +- {"key1": "val1", "key2": "val2", "key3": "val3"} # comment +- if some_var == "long strings" +- else {"key": "val"} ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} # comment ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) + ] + +-items = [ # comment # comment +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} ++items = [ # comment ++ ( # comment ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) + ] + items = [ +- {"key1": "val1", "key2": "val2", "key3": "val3"} +- if some_var == "long strings" +- else {"key": "val"} +-] # comment # comment ++ ( ++ {"key1": "val1", "key2": "val2", "key3": "val3"} ++ if some_var == "long strings" ++ else {"key": "val"} ++ ) # comment ++] # comment +``` + +## Ruff Output + +```python +items = [(x for x in [1])] + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [({"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"})] +items = [ + ( + "123456890123457890123468901234567890" + if some_var == "long strings" + else "123467890123467890" + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ) +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ) +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ) +] + +# Shouldn't remove trailing commas +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ), +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ), +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ), +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ), +] + +# Shouldn't add parentheses +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Shouldn't crash with comments +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment +``` + +## Black Output + +```python +items = [(x for x in [1])] + +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] +items = [ + "123456890123457890123468901234567890" + if some_var == "long strings" + else "123467890123467890" +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} +] +items = [ + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" +] +items = [ + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name +] + +# Shouldn't remove trailing commas +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ), +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + and some_var == "long strings" + and {"key": "val"} + ), +] +items = [ + ( + "123456890123457890123468901234567890" + and some_var == "long strings" + and "123467890123467890" + ), +] +items = [ + ( + long_variable_name + and even_longer_variable_name + and yet_another_very_long_variable_name + ), +] + +# Shouldn't add parentheses +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Shouldn't crash with comments +items = [ # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] # comment + +items = [ # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] # comment + +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} +] + +items = [ # comment # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] +items = [ + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} +] # comment # comment +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_wrap_comprehension_in.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_wrap_comprehension_in.py.snap new file mode 100644 index 0000000000..94553912b7 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_wrap_comprehension_in.py.snap @@ -0,0 +1,335 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_wrap_comprehension_in.py +--- +## Input + +```python +[a for graph_path_expression in refined_constraint.condition_as_predicate.variables] +[ + a + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression + in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +[ + (foobar_very_long_key, foobar_very_long_value) + for foobar_very_long_key, foobar_very_long_value in foobar_very_long_dictionary.items() +] + +# Don't split the `in` if it's not too long +lcomp3 = [ + element.split("\n", 1)[0] + for element in collection.select_elements() + # right + if element is not None +] + +# Don't remove parens around ternaries +expected = [i for i in (a if b else c)] + +# Nested arrays +# First in will not be split because it would still be too long +[[ + x + for x in bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + for y in xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +]] + +# Multiple comprehensions, only split the second `in` +graph_path_expressions_in_local_constraint_refinements = [ + graph_path_expression + for refined_constraint in self._local_constraint_refinements.values() + if refined_constraint is not None + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] + +# Dictionary comprehensions +dict_with_really_long_names = { + really_really_long_key_name: an_even_longer_really_really_long_key_value + for really_really_long_key_name, an_even_longer_really_really_long_key_value in really_really_really_long_dict_name.items() +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in dictionary_with_super_really_long_name +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name + in dictionary_with_super_really_long_name +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key in ( + dictionary + ) +} +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -1,20 +1,14 @@ + [ + a +- for graph_path_expression in ( +- refined_constraint.condition_as_predicate.variables +- ) ++ for graph_path_expression in refined_constraint.condition_as_predicate.variables + ] + [ + a +- for graph_path_expression in ( +- refined_constraint.condition_as_predicate.variables +- ) ++ for graph_path_expression in refined_constraint.condition_as_predicate.variables + ] + [ + a +- for graph_path_expression in ( +- refined_constraint.condition_as_predicate.variables +- ) ++ for graph_path_expression in refined_constraint.condition_as_predicate.variables + ] + [ + a +@@ -25,9 +19,7 @@ + + [ + (foobar_very_long_key, foobar_very_long_value) +- for foobar_very_long_key, foobar_very_long_value in ( +- foobar_very_long_dictionary.items() +- ) ++ for foobar_very_long_key, foobar_very_long_value in foobar_very_long_dictionary.items() + ] + + # Don't split the `in` if it's not too long +@@ -47,9 +39,7 @@ + [ + x + for x in bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +- for y in ( +- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +- ) ++ for y in xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ] + ] + +@@ -58,31 +48,23 @@ + graph_path_expression + for refined_constraint in self._local_constraint_refinements.values() + if refined_constraint is not None +- for graph_path_expression in ( +- refined_constraint.condition_as_predicate.variables +- ) ++ for graph_path_expression in refined_constraint.condition_as_predicate.variables + ] + + # Dictionary comprehensions + dict_with_really_long_names = { + really_really_long_key_name: an_even_longer_really_really_long_key_value +- for really_really_long_key_name, an_even_longer_really_really_long_key_value in ( +- really_really_really_long_dict_name.items() +- ) ++ for really_really_long_key_name, an_even_longer_really_really_long_key_value in really_really_really_long_dict_name.items() + } + { + key_with_super_really_long_name: key_with_super_really_long_name +- for key_with_super_really_long_name in ( +- dictionary_with_super_really_long_name +- ) ++ for key_with_super_really_long_name in dictionary_with_super_really_long_name + } + { + key_with_super_really_long_name: key_with_super_really_long_name +- for key_with_super_really_long_name in ( +- dictionary_with_super_really_long_name +- ) ++ for key_with_super_really_long_name in dictionary_with_super_really_long_name + } + { + key_with_super_really_long_name: key_with_super_really_long_name +- for key in dictionary ++ for key in (dictionary) + } +``` + +## Ruff Output + +```python +[ + a + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +[ + (foobar_very_long_key, foobar_very_long_value) + for foobar_very_long_key, foobar_very_long_value in foobar_very_long_dictionary.items() +] + +# Don't split the `in` if it's not too long +lcomp3 = [ + element.split("\n", 1)[0] + for element in collection.select_elements() + # right + if element is not None +] + +# Don't remove parens around ternaries +expected = [i for i in (a if b else c)] + +# Nested arrays +# First in will not be split because it would still be too long +[ + [ + x + for x in bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + for y in xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ] +] + +# Multiple comprehensions, only split the second `in` +graph_path_expressions_in_local_constraint_refinements = [ + graph_path_expression + for refined_constraint in self._local_constraint_refinements.values() + if refined_constraint is not None + for graph_path_expression in refined_constraint.condition_as_predicate.variables +] + +# Dictionary comprehensions +dict_with_really_long_names = { + really_really_long_key_name: an_even_longer_really_really_long_key_value + for really_really_long_key_name, an_even_longer_really_really_long_key_value in really_really_really_long_dict_name.items() +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in dictionary_with_super_really_long_name +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in dictionary_with_super_really_long_name +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key in (dictionary) +} +``` + +## Black Output + +```python +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] +[ + a + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +[ + (foobar_very_long_key, foobar_very_long_value) + for foobar_very_long_key, foobar_very_long_value in ( + foobar_very_long_dictionary.items() + ) +] + +# Don't split the `in` if it's not too long +lcomp3 = [ + element.split("\n", 1)[0] + for element in collection.select_elements() + # right + if element is not None +] + +# Don't remove parens around ternaries +expected = [i for i in (a if b else c)] + +# Nested arrays +# First in will not be split because it would still be too long +[ + [ + x + for x in bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + for y in ( + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ) + ] +] + +# Multiple comprehensions, only split the second `in` +graph_path_expressions_in_local_constraint_refinements = [ + graph_path_expression + for refined_constraint in self._local_constraint_refinements.values() + if refined_constraint is not None + for graph_path_expression in ( + refined_constraint.condition_as_predicate.variables + ) +] + +# Dictionary comprehensions +dict_with_really_long_names = { + really_really_long_key_name: an_even_longer_really_really_long_key_value + for really_really_long_key_name, an_even_longer_really_really_long_key_value in ( + really_really_really_long_dict_name.items() + ) +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in ( + dictionary_with_super_really_long_name + ) +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key_with_super_really_long_name in ( + dictionary_with_super_really_long_name + ) +} +{ + key_with_super_really_long_name: key_with_super_really_long_name + for key in dictionary +} +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap new file mode 100644 index 0000000000..1562391b41 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap @@ -0,0 +1,427 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py +--- +## Input + +```python +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except (ValueError): + pass + +try: + pass +except* (ValueError): + pass + +# parenthesis are removed +try: + pass +except (ValueError) as e: + pass + +try: + pass +except* (ValueError) as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# remains unchanged +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt): + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt): + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except (ValueError if True else TypeError): + pass + +try: + pass +except* (ValueError if True else TypeError): + pass + +# inner except: parenthesis are removed +# outer except: parenthsis are not removed +try: + try: + pass + except (TypeError, KeyboardInterrupt): + pass +except (ValueError,): + pass + +try: + try: + pass + except* (TypeError, KeyboardInterrupt): + pass +except* (ValueError,): + pass +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -74,12 +74,12 @@ + # parenthesis are removed + try: + pass +-except ValueError, TypeError, KeyboardInterrupt: ++except (ValueError, TypeError, KeyboardInterrupt): + pass + + try: + pass +-except* ValueError, TypeError, KeyboardInterrupt: ++except* (ValueError, TypeError, KeyboardInterrupt): + pass + + # parenthesis are not removed +@@ -109,7 +109,7 @@ + try: + try: + pass +- except TypeError, KeyboardInterrupt: ++ except (TypeError, KeyboardInterrupt): + pass + except (ValueError,): + pass +@@ -117,7 +117,7 @@ + try: + try: + pass +- except* TypeError, KeyboardInterrupt: ++ except* (TypeError, KeyboardInterrupt): + pass + except* (ValueError,): + pass +``` + +## Ruff Output + +```python +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError as e: + pass + +try: + pass +except* ValueError as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# remains unchanged +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt): + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt): + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except ValueError if True else TypeError: + pass + +try: + pass +except* ValueError if True else TypeError: + pass + +# inner except: parenthesis are removed +# outer except: parenthsis are not removed +try: + try: + pass + except (TypeError, KeyboardInterrupt): + pass +except (ValueError,): + pass + +try: + try: + pass + except* (TypeError, KeyboardInterrupt): + pass +except* (ValueError,): + pass +``` + +## Black Output + +```python +# SEE PEP 758 FOR MORE DETAILS +# remains unchanged +try: + pass +except: + pass + +# remains unchanged +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError: + pass + +try: + pass +except* ValueError: + pass + +# parenthesis are removed +try: + pass +except ValueError as e: + pass + +try: + pass +except* ValueError as e: + pass + +# remains unchanged +try: + pass +except (ValueError,): + pass + +try: + pass +except* (ValueError,): + pass + +# remains unchanged +try: + pass +except (ValueError,) as e: + pass + +try: + pass +except* (ValueError,) as e: + pass + +# remains unchanged +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are removed +try: + pass +except ValueError, TypeError, KeyboardInterrupt: + pass + +try: + pass +except* ValueError, TypeError, KeyboardInterrupt: + pass + +# parenthesis are not removed +try: + pass +except (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +try: + pass +except* (ValueError, TypeError, KeyboardInterrupt) as e: + pass + +# parenthesis are removed +try: + pass +except ValueError if True else TypeError: + pass + +try: + pass +except* ValueError if True else TypeError: + pass + +# inner except: parenthesis are removed +# outer except: parenthsis are not removed +try: + try: + pass + except TypeError, KeyboardInterrupt: + pass +except (ValueError,): + pass + +try: + try: + pass + except* TypeError, KeyboardInterrupt: + pass +except* (ValueError,): + pass +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_lone_list_item_parens.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_lone_list_item_parens.py.snap new file mode 100644 index 0000000000..4b3a527f74 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_lone_list_item_parens.py.snap @@ -0,0 +1,283 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_lone_list_item_parens.py +--- +## Input + +```python +items = [(123)] +items = [(True)] +items = [(((((True)))))] +items = [(((((True,)))))] +items = [((((()))))] +items = [(x for x in [1])] +items = {(123)} +items = {(True)} +items = {(((((True)))))} + +# Requires `hug_parens_with_braces_and_square_brackets` unstable style to remove parentheses +# around multiline values +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2"} + if some_var == "" + else {"key": "val"} + ) +] + +# Comments should not cause crashes +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment +``` + +## Black Differences + +```diff +--- Black ++++ Ruff +@@ -1,12 +1,12 @@ +-items = [123] +-items = [True] +-items = [True] +-items = [(True,)] +-items = [()] ++items = [(123)] ++items = [(True)] ++items = [(True)] ++items = [((True,))] ++items = [(())] + items = [(x for x in [1])] +-items = {123} +-items = {True} +-items = {True} ++items = {(123)} ++items = {(True)} ++items = {(True)} + + # Requires `hug_parens_with_braces_and_square_brackets` unstable style to remove parentheses + # around multiline values +@@ -17,7 +17,7 @@ + else {"key": "val"} + ) + ] +-items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] ++items = [({"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"})] + + # Comments should not cause crashes + items = [ +``` + +## Ruff Output + +```python +items = [(123)] +items = [(True)] +items = [(True)] +items = [((True,))] +items = [(())] +items = [(x for x in [1])] +items = {(123)} +items = {(True)} +items = {(True)} + +# Requires `hug_parens_with_braces_and_square_brackets` unstable style to remove parentheses +# around multiline values +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [({"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"})] + +# Comments should not cause crashes +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment +``` + +## Black Output + +```python +items = [123] +items = [True] +items = [True] +items = [(True,)] +items = [()] +items = [(x for x in [1])] +items = {123} +items = {True} +items = {True} + +# Requires `hug_parens_with_braces_and_square_brackets` unstable style to remove parentheses +# around multiline values +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [{"key1": "val1", "key2": "val2"} if some_var == "" else {"key": "val"}] + +# Comments should not cause crashes +items = [ + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] + +items = [ # comment + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] # comment + +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} # comment + if some_var == "long strings" + else {"key": "val"} + ) +] + +items = [ # comment + ( # comment + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) +] +items = [ + ( + {"key1": "val1", "key2": "val2", "key3": "val3"} + if some_var == "long strings" + else {"key": "val"} + ) # comment +] # comment +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__type_param_defaults.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__type_param_defaults.py.snap index 3910a7cf56..f4a4a2163a 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__type_param_defaults.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__type_param_defaults.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/type_param_defaults.py -snapshot_kind: text --- ## Input @@ -25,6 +24,8 @@ def trailing_comma1[T=int,](a: str): def trailing_comma2[T=int](a: str,): pass + +def weird_syntax[T=lambda: 42, **P=lambda: 43, *Ts=lambda: 44](): pass ``` ## Black Differences @@ -32,7 +33,7 @@ def trailing_comma2[T=int](a: str,): ```diff --- Black +++ Ruff -@@ -8,20 +8,20 @@ +@@ -8,9 +8,9 @@ type D[ *something_that_is_very_very_very_long = *something_that_is_very_very_very_long ] = float @@ -44,35 +45,18 @@ def trailing_comma2[T=int](a: str,): +) --def simple[ -- T = something_that_is_long --](short1: int, short2: str, short3: bytes) -> float: -+def simple[T = something_that_is_long]( -+ short1: int, short2: str, short3: bytes -+) -> float: + def simple[T = something_that_is_long]( +@@ -27,9 +27,7 @@ + + def trailing_comma1[ + T = int, +-]( +- a: str, +-): ++](a: str): pass --def longer[ -- something_that_is_long = something_that_is_long --](something_that_is_long: something_that_is_long) -> something_that_is_long: -+def longer[something_that_is_long = something_that_is_long]( -+ something_that_is_long: something_that_is_long, -+) -> something_that_is_long: - pass - - -@@ -31,7 +31,7 @@ - pass - - --def trailing_comma2[ -- T = int --](a: str,): -+def trailing_comma2[T = int]( -+ a: str, -+): - pass ``` ## Ruff Output @@ -115,6 +99,10 @@ def trailing_comma2[T = int]( a: str, ): pass + + +def weird_syntax[T = lambda: 42, **P = lambda: 43, *Ts = lambda: 44](): + pass ``` ## Black Output @@ -135,26 +123,32 @@ type something_that_is_long[ ] = something_that_is_long -def simple[ - T = something_that_is_long -](short1: int, short2: str, short3: bytes) -> float: +def simple[T = something_that_is_long]( + short1: int, short2: str, short3: bytes +) -> float: pass -def longer[ - something_that_is_long = something_that_is_long -](something_that_is_long: something_that_is_long) -> something_that_is_long: +def longer[something_that_is_long = something_that_is_long]( + something_that_is_long: something_that_is_long, +) -> something_that_is_long: pass def trailing_comma1[ T = int, -](a: str): +]( + a: str, +): pass -def trailing_comma2[ - T = int -](a: str,): +def trailing_comma2[T = int]( + a: str, +): + pass + + +def weird_syntax[T = lambda: 42, **P = lambda: 43, *Ts = lambda: 44](): pass ``` From 591e9bbccbc4b876cef2a23931581efaeb10a50f Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Tue, 14 Oct 2025 11:17:45 -0400 Subject: [PATCH 035/113] Remove parentheses around multiple exception types on Python 3.14+ (#20768) Summary -- This PR implements the black preview style from https://github.com/psf/black/pull/4720. As of Python 3.14, you're allowed to omit the parentheses around groups of exceptions, as long as there's no `as` binding: **3.13** ```pycon Python 3.13.4 (main, Jun 4 2025, 17:37:06) [Clang 20.1.4 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> try: ... ... except (Exception, BaseException): ... ... Ellipsis >>> try: ... ... except Exception, BaseException: ... ... File "", line 2 except Exception, BaseException: ... ^^^^^^^^^^^^^^^^^^^^^^^^ SyntaxError: multiple exception types must be parenthesized ``` **3.14** ```pycon Python 3.14.0rc2 (main, Sep 2 2025, 14:20:56) [Clang 20.1.4 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> try: ... ... except Exception, BaseException: ... ... Ellipsis >>> try: ... ... except (Exception, BaseException): ... ... Ellipsis >>> try: ... ... except Exception, BaseException as e: ... ... File "", line 2 except Exception, BaseException as e: ... ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ SyntaxError: multiple exception types must be parenthesized when using 'as' ``` I think this ended up being pretty straightforward, at least once Micha showed me where to start :) Test Plan -- New tests At first I thought we were deviating from black in how we handle comments within the exception type tuple, but I think this applies to how we format all tuples, not specifically with the new preview style. --- .../fixtures/ruff/statement/try.options.json | 8 + .../test/fixtures/ruff/statement/try.py | 37 ++ crates/ruff_python_formatter/src/cli.rs | 13 +- .../src/expression/expr_tuple.rs | 4 +- .../other/except_handler_except_handler.rs | 61 ++- crates/ruff_python_formatter/src/preview.rs | 9 + ...@cases__remove_except_types_parens.py.snap | 427 ------------------ .../snapshots/format@statement__try.py.snap | 388 +++++++++++++++- 8 files changed, 497 insertions(+), 450 deletions(-) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.options.json delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.options.json new file mode 100644 index 0000000000..f1fa9100c1 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.options.json @@ -0,0 +1,8 @@ +[ + { + "target_version": "3.13" + }, + { + "target_version": "3.14" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.py index a5cdf3ec9f..2e4a7634ef 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.py @@ -166,3 +166,40 @@ else: finally: pass + + +try: + pass +# These parens can be removed on 3.14+ but not earlier +except (BaseException, Exception, ValueError): + pass +# But black won't remove these parentheses +except (ZeroDivisionError,): + pass +except ( # We wrap these and preserve the parens + BaseException, Exception, ValueError): + pass +except ( + BaseException, + # Same with this comment + Exception, + ValueError +): + pass + +try: + pass +# They can also be omitted for `except*` +except* (BaseException, Exception, ValueError): + pass + +# But parentheses are still required in the presence of an `as` binding +try: + pass +except (BaseException, Exception, ValueError) as e: + pass + +try: + pass +except* (BaseException, Exception, ValueError) as e: + pass diff --git a/crates/ruff_python_formatter/src/cli.rs b/crates/ruff_python_formatter/src/cli.rs index 60f7cd413c..96da64e86e 100644 --- a/crates/ruff_python_formatter/src/cli.rs +++ b/crates/ruff_python_formatter/src/cli.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use clap::{Parser, ValueEnum, command}; use ruff_formatter::SourceCode; -use ruff_python_ast::PySourceType; +use ruff_python_ast::{PySourceType, PythonVersion}; use ruff_python_parser::{ParseOptions, parse}; use ruff_python_trivia::CommentRanges; use ruff_text_size::Ranged; @@ -42,13 +42,19 @@ pub struct Cli { pub print_comments: bool, #[clap(long, short = 'C')] pub skip_magic_trailing_comma: bool, + #[clap(long)] + pub target_version: PythonVersion, } pub fn format_and_debug_print(source: &str, cli: &Cli, source_path: &Path) -> Result { let source_type = PySourceType::from(source_path); // Parse the AST. - let parsed = parse(source, ParseOptions::from(source_type)).context("Syntax error in input")?; + let parsed = parse( + source, + ParseOptions::from(source_type).with_target_version(cli.target_version), + ) + .context("Syntax error in input")?; let options = PyFormatOptions::from_extension(source_path) .with_preview(if cli.preview { @@ -60,7 +66,8 @@ pub fn format_and_debug_print(source: &str, cli: &Cli, source_path: &Path) -> Re MagicTrailingComma::Ignore } else { MagicTrailingComma::Respect - }); + }) + .with_target_version(cli.target_version); let source_code = SourceCode::new(source); let comment_ranges = CommentRanges::from(parsed.tokens()); diff --git a/crates/ruff_python_formatter/src/expression/expr_tuple.rs b/crates/ruff_python_formatter/src/expression/expr_tuple.rs index 6d95048673..8eba63a2ac 100644 --- a/crates/ruff_python_formatter/src/expression/expr_tuple.rs +++ b/crates/ruff_python_formatter/src/expression/expr_tuple.rs @@ -39,8 +39,8 @@ pub enum TupleParentheses { /// /// ```python /// return len(self.nodeseeeeeeeee), sum( - // len(node.parents) for node in self.node_map.values() - // ) + /// len(node.parents) for node in self.node_map.values() + /// ) /// ``` OptionalParentheses, diff --git a/crates/ruff_python_formatter/src/other/except_handler_except_handler.rs b/crates/ruff_python_formatter/src/other/except_handler_except_handler.rs index 4f2f93a3e9..aefaaec182 100644 --- a/crates/ruff_python_formatter/src/other/except_handler_except_handler.rs +++ b/crates/ruff_python_formatter/src/other/except_handler_except_handler.rs @@ -1,10 +1,12 @@ use ruff_formatter::FormatRuleWithOptions; use ruff_formatter::write; -use ruff_python_ast::ExceptHandlerExceptHandler; +use ruff_python_ast::{ExceptHandlerExceptHandler, Expr, PythonVersion}; +use crate::expression::expr_tuple::TupleParentheses; use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::Parenthesize; use crate::prelude::*; +use crate::preview::is_remove_parens_around_except_types_enabled; use crate::statement::clause::{ClauseHeader, clause_body, clause_header}; use crate::statement::suite::SuiteKind; @@ -57,7 +59,7 @@ impl FormatNodeRule for FormatExceptHandlerExceptHan clause_header( ClauseHeader::ExceptHandler(item), dangling_comments, - &format_with(|f| { + &format_with(|f: &mut PyFormatter| { write!( f, [ @@ -69,21 +71,50 @@ impl FormatNodeRule for FormatExceptHandlerExceptHan ] )?; - if let Some(type_) = type_ { - write!( - f, - [ - space(), - maybe_parenthesize_expression( - type_, - item, - Parenthesize::IfBreaks + match type_.as_deref() { + // For tuples of exception types without an `as` name and on 3.14+, the + // parentheses are optional. + // + // ```py + // try: + // ... + // except BaseException, Exception: # Ok + // ... + // ``` + Some(Expr::Tuple(tuple)) + if f.options().target_version() >= PythonVersion::PY314 + && is_remove_parens_around_except_types_enabled( + f.context(), ) - ] - )?; - if let Some(name) = name { - write!(f, [space(), token("as"), space(), name.format()])?; + && name.is_none() => + { + write!( + f, + [ + space(), + tuple + .format() + .with_options(TupleParentheses::NeverPreserve) + ] + )?; } + Some(type_) => { + write!( + f, + [ + space(), + maybe_parenthesize_expression( + type_, + item, + Parenthesize::IfBreaks + ) + ] + )?; + if let Some(name) = name { + write!(f, [space(), token("as"), space(), name.format()])?; + } + } + _ => {} } Ok(()) diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index ee9b378cb8..b6479ab1b4 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -27,3 +27,12 @@ pub(crate) const fn is_blank_line_before_decorated_class_in_stub_enabled( ) -> bool { context.is_preview() } + +/// Returns `true` if the +/// [`remove_parens_around_except_types`](https://github.com/astral-sh/ruff/pull/20768) preview +/// style is enabled. +pub(crate) const fn is_remove_parens_around_except_types_enabled( + context: &PyFormatContext, +) -> bool { + context.is_preview() +} diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap deleted file mode 100644 index 1562391b41..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__remove_except_types_parens.py.snap +++ /dev/null @@ -1,427 +0,0 @@ ---- -source: crates/ruff_python_formatter/tests/fixtures.rs -input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/remove_except_types_parens.py ---- -## Input - -```python -# SEE PEP 758 FOR MORE DETAILS -# remains unchanged -try: - pass -except: - pass - -# remains unchanged -try: - pass -except ValueError: - pass - -try: - pass -except* ValueError: - pass - -# parenthesis are removed -try: - pass -except (ValueError): - pass - -try: - pass -except* (ValueError): - pass - -# parenthesis are removed -try: - pass -except (ValueError) as e: - pass - -try: - pass -except* (ValueError) as e: - pass - -# remains unchanged -try: - pass -except (ValueError,): - pass - -try: - pass -except* (ValueError,): - pass - -# remains unchanged -try: - pass -except (ValueError,) as e: - pass - -try: - pass -except* (ValueError,) as e: - pass - -# remains unchanged -try: - pass -except ValueError, TypeError, KeyboardInterrupt: - pass - -try: - pass -except* ValueError, TypeError, KeyboardInterrupt: - pass - -# parenthesis are removed -try: - pass -except (ValueError, TypeError, KeyboardInterrupt): - pass - -try: - pass -except* (ValueError, TypeError, KeyboardInterrupt): - pass - -# parenthesis are not removed -try: - pass -except (ValueError, TypeError, KeyboardInterrupt) as e: - pass - -try: - pass -except* (ValueError, TypeError, KeyboardInterrupt) as e: - pass - -# parenthesis are removed -try: - pass -except (ValueError if True else TypeError): - pass - -try: - pass -except* (ValueError if True else TypeError): - pass - -# inner except: parenthesis are removed -# outer except: parenthsis are not removed -try: - try: - pass - except (TypeError, KeyboardInterrupt): - pass -except (ValueError,): - pass - -try: - try: - pass - except* (TypeError, KeyboardInterrupt): - pass -except* (ValueError,): - pass -``` - -## Black Differences - -```diff ---- Black -+++ Ruff -@@ -74,12 +74,12 @@ - # parenthesis are removed - try: - pass --except ValueError, TypeError, KeyboardInterrupt: -+except (ValueError, TypeError, KeyboardInterrupt): - pass - - try: - pass --except* ValueError, TypeError, KeyboardInterrupt: -+except* (ValueError, TypeError, KeyboardInterrupt): - pass - - # parenthesis are not removed -@@ -109,7 +109,7 @@ - try: - try: - pass -- except TypeError, KeyboardInterrupt: -+ except (TypeError, KeyboardInterrupt): - pass - except (ValueError,): - pass -@@ -117,7 +117,7 @@ - try: - try: - pass -- except* TypeError, KeyboardInterrupt: -+ except* (TypeError, KeyboardInterrupt): - pass - except* (ValueError,): - pass -``` - -## Ruff Output - -```python -# SEE PEP 758 FOR MORE DETAILS -# remains unchanged -try: - pass -except: - pass - -# remains unchanged -try: - pass -except ValueError: - pass - -try: - pass -except* ValueError: - pass - -# parenthesis are removed -try: - pass -except ValueError: - pass - -try: - pass -except* ValueError: - pass - -# parenthesis are removed -try: - pass -except ValueError as e: - pass - -try: - pass -except* ValueError as e: - pass - -# remains unchanged -try: - pass -except (ValueError,): - pass - -try: - pass -except* (ValueError,): - pass - -# remains unchanged -try: - pass -except (ValueError,) as e: - pass - -try: - pass -except* (ValueError,) as e: - pass - -# remains unchanged -try: - pass -except ValueError, TypeError, KeyboardInterrupt: - pass - -try: - pass -except* ValueError, TypeError, KeyboardInterrupt: - pass - -# parenthesis are removed -try: - pass -except (ValueError, TypeError, KeyboardInterrupt): - pass - -try: - pass -except* (ValueError, TypeError, KeyboardInterrupt): - pass - -# parenthesis are not removed -try: - pass -except (ValueError, TypeError, KeyboardInterrupt) as e: - pass - -try: - pass -except* (ValueError, TypeError, KeyboardInterrupt) as e: - pass - -# parenthesis are removed -try: - pass -except ValueError if True else TypeError: - pass - -try: - pass -except* ValueError if True else TypeError: - pass - -# inner except: parenthesis are removed -# outer except: parenthsis are not removed -try: - try: - pass - except (TypeError, KeyboardInterrupt): - pass -except (ValueError,): - pass - -try: - try: - pass - except* (TypeError, KeyboardInterrupt): - pass -except* (ValueError,): - pass -``` - -## Black Output - -```python -# SEE PEP 758 FOR MORE DETAILS -# remains unchanged -try: - pass -except: - pass - -# remains unchanged -try: - pass -except ValueError: - pass - -try: - pass -except* ValueError: - pass - -# parenthesis are removed -try: - pass -except ValueError: - pass - -try: - pass -except* ValueError: - pass - -# parenthesis are removed -try: - pass -except ValueError as e: - pass - -try: - pass -except* ValueError as e: - pass - -# remains unchanged -try: - pass -except (ValueError,): - pass - -try: - pass -except* (ValueError,): - pass - -# remains unchanged -try: - pass -except (ValueError,) as e: - pass - -try: - pass -except* (ValueError,) as e: - pass - -# remains unchanged -try: - pass -except ValueError, TypeError, KeyboardInterrupt: - pass - -try: - pass -except* ValueError, TypeError, KeyboardInterrupt: - pass - -# parenthesis are removed -try: - pass -except ValueError, TypeError, KeyboardInterrupt: - pass - -try: - pass -except* ValueError, TypeError, KeyboardInterrupt: - pass - -# parenthesis are not removed -try: - pass -except (ValueError, TypeError, KeyboardInterrupt) as e: - pass - -try: - pass -except* (ValueError, TypeError, KeyboardInterrupt) as e: - pass - -# parenthesis are removed -try: - pass -except ValueError if True else TypeError: - pass - -try: - pass -except* ValueError if True else TypeError: - pass - -# inner except: parenthesis are removed -# outer except: parenthsis are not removed -try: - try: - pass - except TypeError, KeyboardInterrupt: - pass -except (ValueError,): - pass - -try: - try: - pass - except* TypeError, KeyboardInterrupt: - pass -except* (ValueError,): - pass -``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__try.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__try.py.snap index fc6fcf4406..0dd13e8948 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__try.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__try.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/try.py -snapshot_kind: text --- ## Input ```python @@ -173,9 +172,61 @@ else: finally: pass + + +try: + pass +# These parens can be removed on 3.14+ but not earlier +except (BaseException, Exception, ValueError): + pass +# But black won't remove these parentheses +except (ZeroDivisionError,): + pass +except ( # We wrap these and preserve the parens + BaseException, Exception, ValueError): + pass +except ( + BaseException, + # Same with this comment + Exception, + ValueError +): + pass + +try: + pass +# They can also be omitted for `except*` +except* (BaseException, Exception, ValueError): + pass + +# But parentheses are still required in the presence of an `as` binding +try: + pass +except (BaseException, Exception, ValueError) as e: + pass + +try: + pass +except* (BaseException, Exception, ValueError) as e: + pass +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Disabled +target_version = 3.13 +source_type = Python ``` -## Output ```python try: pass @@ -364,10 +415,50 @@ else: finally: pass + + +try: + pass +# These parens can be removed on 3.14+ but not earlier +except (BaseException, Exception, ValueError): + pass +# But black won't remove these parentheses +except (ZeroDivisionError,): + pass +except ( # We wrap these and preserve the parens + BaseException, + Exception, + ValueError, +): + pass +except ( + BaseException, + # Same with this comment + Exception, + ValueError, +): + pass + +try: + pass +# They can also be omitted for `except*` +except* (BaseException, Exception, ValueError): + pass + +# But parentheses are still required in the presence of an `as` binding +try: + pass +except (BaseException, Exception, ValueError) as e: + pass + +try: + pass +except* (BaseException, Exception, ValueError) as e: + pass ``` -## Preview changes +#### Preview changes ```diff --- Stable +++ Preview @@ -392,3 +483,294 @@ finally: def f(): ``` + + +### Output 2 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Disabled +target_version = 3.14 +source_type = Python +``` + +```python +try: + pass +except: + pass + +try: + pass +except KeyError: # should remove brackets and be a single line + pass + + +try: # try + pass + # end of body +# before except +except (Exception, ValueError) as exc: # except line + pass +# before except 2 +except KeyError as key: # except line 2 + pass + # in body 2 +# before else +else: + pass +# before finally +finally: + pass + + +# with line breaks +try: # try + pass + # end of body + +# before except +except (Exception, ValueError) as exc: # except line + pass + +# before except 2 +except KeyError as key: # except line 2 + pass + # in body 2 + +# before else +else: + pass + +# before finally +finally: + pass + + +# with line breaks +try: + pass + +except: + pass + + +try: + pass +except ( + Exception, + Exception, + Exception, + Exception, + Exception, + Exception, + Exception, +) as exc: # splits exception over multiple lines + pass + + +try: + pass +except: + a = 10 # trailing comment1 + b = 11 # trailing comment2 + + +# try/except*, mostly the same as try +try: # try + pass + # end of body +# before except +except* (Exception, ValueError) as exc: # except line + pass +# before except 2 +except* KeyError as key: # except line 2 + pass + # in body 2 +# before else +else: + pass +# before finally +finally: + pass + +# try and try star are statements with body +# Minimized from https://github.com/python/cpython/blob/99b00efd5edfd5b26bf9e2a35cbfc96277fdcbb1/Lib/getpass.py#L68-L91 +try: + try: + pass + finally: + print(1) # issue7208 +except A: + pass + +try: + f() # end-of-line last comment +except RuntimeError: + raise + +try: + + def f(): + pass + # a +except: + + def f(): + pass + # b +else: + + def f(): + pass + # c +finally: + + def f(): + pass + # d + + +try: + pass # a +except ZeroDivisionError: + pass # b +except: + pass # c +else: + pass # d +finally: + pass # e + +try: # 1 preceding: any, following: first in body, enclosing: try + print(1) # 2 preceding: last in body, following: fist in alt body, enclosing: try +except ( + ZeroDivisionError +): # 3 preceding: test, following: fist in alt body, enclosing: try + print(2) # 4 preceding: last in body, following: fist in alt body, enclosing: exc +except: # 5 preceding: last in body, following: fist in alt body, enclosing: try + print(2) # 6 preceding: last in body, following: fist in alt body, enclosing: exc +else: # 7 preceding: last in body, following: fist in alt body, enclosing: exc + print(3) # 8 preceding: last in body, following: fist in alt body, enclosing: try +finally: # 9 preceding: last in body, following: fist in alt body, enclosing: try + print(3) # 10 preceding: last in body, following: any, enclosing: try + +try: + pass +except ( + ZeroDivisionError + # comment +): + pass + + +try: + pass + +finally: + pass + + +try: + pass + +except ZeroDivisonError: + pass + +else: + pass + +finally: + pass + + +try: + pass +# These parens can be removed on 3.14+ but not earlier +except (BaseException, Exception, ValueError): + pass +# But black won't remove these parentheses +except (ZeroDivisionError,): + pass +except ( # We wrap these and preserve the parens + BaseException, + Exception, + ValueError, +): + pass +except ( + BaseException, + # Same with this comment + Exception, + ValueError, +): + pass + +try: + pass +# They can also be omitted for `except*` +except* (BaseException, Exception, ValueError): + pass + +# But parentheses are still required in the presence of an `as` binding +try: + pass +except (BaseException, Exception, ValueError) as e: + pass + +try: + pass +except* (BaseException, Exception, ValueError) as e: + pass +``` + + +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -117,16 +117,19 @@ + def f(): + pass + # a ++ + except: + + def f(): + pass + # b ++ + else: + + def f(): + pass + # c ++ + finally: + + def f(): +@@ -190,7 +193,7 @@ + try: + pass + # These parens can be removed on 3.14+ but not earlier +-except (BaseException, Exception, ValueError): ++except BaseException, Exception, ValueError: + pass + # But black won't remove these parentheses + except (ZeroDivisionError,): +@@ -212,7 +215,7 @@ + try: + pass + # They can also be omitted for `except*` +-except* (BaseException, Exception, ValueError): ++except* BaseException, Exception, ValueError: + pass + + # But parentheses are still required in the presence of an `as` binding +``` From f8e00e3cd9a2d9057dc6eee61c4f6af45a12fa20 Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 14 Oct 2025 20:02:20 +0200 Subject: [PATCH 036/113] [ty] Ignore slow seeds as a temporary measure (#20870) ## Summary Basically what @AlexWaygood suggested [here](https://github.com/astral-sh/ruff/pull/20802#issuecomment-3402218389) (thank you). ## Test Plan CI on this PR --- .github/workflows/ci.yaml | 2 +- python/py-fuzzer/fuzz.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 87252c9fd9..78e22a12d0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -142,7 +142,7 @@ jobs: env: MERGE_BASE: ${{ steps.merge_base.outputs.sha }} run: | - if git diff --quiet "${MERGE_BASE}...HEAD" -- 'python/py_fuzzer/**' \ + if git diff --quiet "${MERGE_BASE}...HEAD" -- 'python/py-fuzzer/**' \ ; then echo "changed=false" >> "$GITHUB_OUTPUT" else diff --git a/python/py-fuzzer/fuzz.py b/python/py-fuzzer/fuzz.py index 668317c893..b5a14abc1b 100644 --- a/python/py-fuzzer/fuzz.py +++ b/python/py-fuzzer/fuzz.py @@ -152,8 +152,8 @@ class FuzzResult: def fuzz_code(seed: Seed, args: ResolvedCliArgs) -> FuzzResult: """Return a `FuzzResult` instance describing the fuzzing result from this seed.""" - # TODO(carljm) debug slowness of this seed - skip_check = seed in {208} + # TODO debug slowness of these seeds + skip_check = seed in {32, 56, 208} code = generate_random_code(seed) bug_found = False From 43eddc566faffd50da7b1e9b71a2c63f02ab31d8 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Tue, 14 Oct 2025 19:31:34 +0100 Subject: [PATCH 037/113] [ty] Improve and extend tests for instance attributes redeclared in subclasses (#20866) Part of https://github.com/astral-sh/ty/issues/1345 --- .../resources/mdtest/attributes.md | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index bff72953e2..ef36bea8d0 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -828,6 +828,10 @@ class Base: redeclared_with_narrower_type: str | None redeclared_with_wider_type: str | None + redeclared_in_method_with_same_type: str | None + redeclared_in_method_with_narrower_type: str | None + redeclared_in_method_with_wider_type: str | None + overwritten_in_subclass_body: str overwritten_in_subclass_method: str @@ -873,6 +877,14 @@ class Intermediate(Base): undeclared = "intermediate" def set_attributes(self) -> None: + self.redeclared_in_method_with_same_type: str | None = None + + # TODO: This should be an error (violates Liskov) + self.redeclared_in_method_with_narrower_type: str = "foo" + + # TODO: This should be an error (violates Liskov) + self.redeclared_in_method_with_wider_type: object = object() + # TODO: This should be an `invalid-assignment` error self.overwritten_in_subclass_method = None @@ -889,11 +901,13 @@ reveal_type(Derived().attribute) # revealed: int | None reveal_type(Derived.redeclared_with_same_type) # revealed: str | None reveal_type(Derived().redeclared_with_same_type) # revealed: str | None -# TODO: It would probably be more consistent if these were `str | None` +# We infer the narrower type here, despite the Liskov violation, +# for compatibility with other type checkers (and to reflect the clear user intent) reveal_type(Derived.redeclared_with_narrower_type) # revealed: str reveal_type(Derived().redeclared_with_narrower_type) # revealed: str -# TODO: It would probably be more consistent if these were `str | None` +# We infer the wider type here, despite the Liskov violation, +# for compatibility with other type checkers (and to reflect the clear user intent) reveal_type(Derived.redeclared_with_wider_type) # revealed: str | int | None reveal_type(Derived().redeclared_with_wider_type) # revealed: str | int | None @@ -901,6 +915,19 @@ reveal_type(Derived().redeclared_with_wider_type) # revealed: str | int | None reveal_type(Derived.overwritten_in_subclass_body) # revealed: Unknown | None reveal_type(Derived().overwritten_in_subclass_body) # revealed: Unknown | None | str +reveal_type(Derived.redeclared_in_method_with_same_type) # revealed: str | None +reveal_type(Derived().redeclared_in_method_with_same_type) # revealed: str | None + +# TODO: both of these should be `str`, despite the Liskov violation, +# for compatibility with other type checkers (and to reflect the clear user intent) +reveal_type(Derived.redeclared_in_method_with_narrower_type) # revealed: str | None +reveal_type(Derived().redeclared_in_method_with_narrower_type) # revealed: str | None + +# TODO: both of these should be `object`, despite the Liskov violation, +# for compatibility with other type checkers (and to reflect the clear user intent) +reveal_type(Derived.redeclared_in_method_with_wider_type) # revealed: str | None +reveal_type(Derived().redeclared_in_method_with_wider_type) # revealed: object + reveal_type(Derived.overwritten_in_subclass_method) # revealed: str reveal_type(Derived().overwritten_in_subclass_method) # revealed: str From e1e3eb7209b80833c837e60179e158057b4bd50d Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 14 Oct 2025 23:38:31 +0200 Subject: [PATCH 038/113] fix(docs): Fix typo in `RUF015` description (#20873) ## Summary Fixed a typo. It should be "or", not "of". Both `.pop()` and `next()` on an empty collection will raise `IndexError`, not "`[0]` of the `pop()` function" ## Test Plan n/a --- .../rules/unnecessary_iterable_allocation_for_first_element.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs index ae09b7f544..803adda4df 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_iterable_allocation_for_first_element.rs @@ -48,7 +48,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// element. As such, any side effects that occur during iteration will be /// delayed. /// 2. Second, accessing members of a collection via square bracket notation -/// `[0]` of the `pop()` function will raise `IndexError` if the collection +/// `[0]` or the `pop()` function will raise `IndexError` if the collection /// is empty, while `next(iter(...))` will raise `StopIteration`. /// /// ## References From abf685b030d57575e7677a13ef86eb670b2cd79d Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 14 Oct 2025 20:14:01 -0500 Subject: [PATCH 039/113] [`flake8-bugbear`] Omit annotation in preview fix for `B006` (#20877) Closes #20864 --- .../rules/mutable_argument_default.rs | 8 +----- ...gbear__tests__preview__B006_B006_5.py.snap | 26 +++++++++---------- ...gbear__tests__preview__B006_B006_8.py.snap | 2 +- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs index 7e13ba5aee..a20680a03f 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/mutable_argument_default.rs @@ -188,16 +188,10 @@ fn move_initialization( content.push_str(stylist.line_ending().as_str()); content.push_str(stylist.indentation()); if is_b006_unsafe_fix_preserve_assignment_expr_enabled(checker.settings()) { - let annotation = if let Some(ann) = parameter.annotation() { - format!(": {}", locator.slice(ann)) - } else { - String::new() - }; let _ = write!( &mut content, - "{}{} = {}", + "{} = {}", parameter.parameter.name(), - annotation, locator.slice( parenthesized_range( default.into(), diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_5.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_5.py.snap index de544081f7..b1e6c26783 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_5.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_5.py.snap @@ -16,7 +16,7 @@ help: Replace with `None`; initialize within function 5 + def import_module_wrong(value: dict[str, str] = None): 6 | import os 7 + if value is None: -8 + value: dict[str, str] = {} +8 + value = {} 9 | 10 | 11 | def import_module_with_values_wrong(value: dict[str, str] = {}): @@ -38,7 +38,7 @@ help: Replace with `None`; initialize within function 10 | import os 11 | 12 + if value is None: -13 + value: dict[str, str] = {} +13 + value = {} 14 | return 2 15 | 16 | @@ -62,7 +62,7 @@ help: Replace with `None`; initialize within function 17 | import sys 18 | import itertools 19 + if value is None: -20 + value: dict[str, str] = {} +20 + value = {} 21 | 22 | 23 | def from_import_module_wrong(value: dict[str, str] = {}): @@ -83,7 +83,7 @@ help: Replace with `None`; initialize within function 21 + def from_import_module_wrong(value: dict[str, str] = None): 22 | from os import path 23 + if value is None: -24 + value: dict[str, str] = {} +24 + value = {} 25 | 26 | 27 | def from_imports_module_wrong(value: dict[str, str] = {}): @@ -106,7 +106,7 @@ help: Replace with `None`; initialize within function 26 | from os import path 27 | from sys import version_info 28 + if value is None: -29 + value: dict[str, str] = {} +29 + value = {} 30 | 31 | 32 | def import_and_from_imports_module_wrong(value: dict[str, str] = {}): @@ -129,7 +129,7 @@ help: Replace with `None`; initialize within function 31 | import os 32 | from sys import version_info 33 + if value is None: -34 + value: dict[str, str] = {} +34 + value = {} 35 | 36 | 37 | def import_docstring_module_wrong(value: dict[str, str] = {}): @@ -152,7 +152,7 @@ help: Replace with `None`; initialize within function 36 | """Docstring""" 37 | import os 38 + if value is None: -39 + value: dict[str, str] = {} +39 + value = {} 40 | 41 | 42 | def import_module_wrong(value: dict[str, str] = {}): @@ -175,7 +175,7 @@ help: Replace with `None`; initialize within function 41 | """Docstring""" 42 | import os; import sys 43 + if value is None: -44 + value: dict[str, str] = {} +44 + value = {} 45 | 46 | 47 | def import_module_wrong(value: dict[str, str] = {}): @@ -197,7 +197,7 @@ help: Replace with `None`; initialize within function 45 + def import_module_wrong(value: dict[str, str] = None): 46 | """Docstring""" 47 + if value is None: -48 + value: dict[str, str] = {} +48 + value = {} 49 | import os; import sys; x = 1 50 | 51 | @@ -220,7 +220,7 @@ help: Replace with `None`; initialize within function 51 | """Docstring""" 52 | import os; import sys 53 + if value is None: -54 + value: dict[str, str] = {} +54 + value = {} 55 | 56 | 57 | def import_module_wrong(value: dict[str, str] = {}): @@ -241,7 +241,7 @@ help: Replace with `None`; initialize within function 55 + def import_module_wrong(value: dict[str, str] = None): 56 | import os; import sys 57 + if value is None: -58 + value: dict[str, str] = {} +58 + value = {} 59 | 60 | 61 | def import_module_wrong(value: dict[str, str] = {}): @@ -261,7 +261,7 @@ help: Replace with `None`; initialize within function - def import_module_wrong(value: dict[str, str] = {}): 59 + def import_module_wrong(value: dict[str, str] = None): 60 + if value is None: -61 + value: dict[str, str] = {} +61 + value = {} 62 | import os; import sys; x = 1 63 | 64 | @@ -282,7 +282,7 @@ help: Replace with `None`; initialize within function 63 + def import_module_wrong(value: dict[str, str] = None): 64 | import os; import sys 65 + if value is None: -66 + value: dict[str, str] = {} +66 + value = {} 67 | 68 | 69 | def import_module_wrong(value: dict[str, str] = {}): import os diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_8.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_8.py.snap index a8a6f4fdea..f06b24cbab 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_8.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__preview__B006_B006_8.py.snap @@ -51,7 +51,7 @@ help: Replace with `None`; initialize within function 10 + def baz(a: list = None): 11 | """This one raises a different exception""" 12 + if a is None: -13 + a: list = [] +13 + a = [] 14 | raise IndexError() 15 | 16 | From 9e1aafd0ce5fb2899754c6939bb07bac0969de31 Mon Sep 17 00:00:00 2001 From: Dan Parizher <105245560+danparizher@users.noreply.github.com> Date: Wed, 15 Oct 2025 02:52:14 -0400 Subject: [PATCH 040/113] [`pyupgrade`] Extend `UP019` to detect `typing_extensions.Text` (`UP019`) (#20825) Co-authored-by: Micha Reiser --- .../test/fixtures/pyupgrade/UP019.py | 17 +++ crates/ruff_linter/src/preview.rs | 4 + crates/ruff_linter/src/rules/pyupgrade/mod.rs | 1 + .../pyupgrade/rules/typing_text_str_alias.rs | 49 ++++++-- ...er__rules__pyupgrade__tests__UP019.py.snap | 2 + ...__pyupgrade__tests__UP019.py__preview.snap | 119 ++++++++++++++++++ 6 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py__preview.snap diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP019.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP019.py index 64c616ede5..a89b6ee78a 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP019.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP019.py @@ -18,3 +18,20 @@ def print_third_word(word: Hello.Text) -> None: def print_fourth_word(word: Goodbye) -> None: print(word) + + +import typing_extensions +import typing_extensions as TypingExt +from typing_extensions import Text as TextAlias + + +def print_fifth_word(word: typing_extensions.Text) -> None: + print(word) + + +def print_sixth_word(word: TypingExt.Text) -> None: + print(word) + + +def print_seventh_word(word: TextAlias) -> None: + print(word) diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 37a577e8df..87895090e5 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -265,3 +265,7 @@ pub(crate) const fn is_fix_read_whole_file_enabled(settings: &LinterSettings) -> pub(crate) const fn is_fix_write_whole_file_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() } + +pub(crate) const fn is_typing_extensions_str_alias_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs index bf8e7495f9..d882cd5cca 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs @@ -126,6 +126,7 @@ mod tests { } #[test_case(Rule::SuperCallWithParameters, Path::new("UP008.py"))] + #[test_case(Rule::TypingTextStrAlias, Path::new("UP019.py"))] fn rules_preview(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}__preview", path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs index 3f11b63579..2310880eed 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/typing_text_str_alias.rs @@ -1,15 +1,19 @@ use ruff_python_ast::Expr; +use std::fmt::{Display, Formatter}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_semantic::Modules; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; +use crate::preview::is_typing_extensions_str_alias_enabled; use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does /// Checks for uses of `typing.Text`. /// +/// In preview mode, also checks for `typing_extensions.Text`. +/// /// ## Why is this bad? /// `typing.Text` is an alias for `str`, and only exists for Python 2 /// compatibility. As of Python 3.11, `typing.Text` is deprecated. Use `str` @@ -30,14 +34,16 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// ## References /// - [Python documentation: `typing.Text`](https://docs.python.org/3/library/typing.html#typing.Text) #[derive(ViolationMetadata)] -pub(crate) struct TypingTextStrAlias; +pub(crate) struct TypingTextStrAlias { + module: TypingModule, +} impl Violation for TypingTextStrAlias { const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; #[derive_message_formats] fn message(&self) -> String { - "`typing.Text` is deprecated, use `str`".to_string() + format!("`{}.Text` is deprecated, use `str`", self.module) } fn fix_title(&self) -> Option { @@ -47,16 +53,26 @@ impl Violation for TypingTextStrAlias { /// UP019 pub(crate) fn typing_text_str_alias(checker: &Checker, expr: &Expr) { - if !checker.semantic().seen_module(Modules::TYPING) { + if !checker + .semantic() + .seen_module(Modules::TYPING | Modules::TYPING_EXTENSIONS) + { return; } - if checker - .semantic() - .resolve_qualified_name(expr) - .is_some_and(|qualified_name| matches!(qualified_name.segments(), ["typing", "Text"])) - { - let mut diagnostic = checker.report_diagnostic(TypingTextStrAlias, expr.range()); + if let Some(qualified_name) = checker.semantic().resolve_qualified_name(expr) { + let segments = qualified_name.segments(); + let module = match segments { + ["typing", "Text"] => TypingModule::Typing, + ["typing_extensions", "Text"] + if is_typing_extensions_str_alias_enabled(checker.settings()) => + { + TypingModule::TypingExtensions + } + _ => return, + }; + + let mut diagnostic = checker.report_diagnostic(TypingTextStrAlias { module }, expr.range()); diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); diagnostic.try_set_fix(|| { let (import_edit, binding) = checker.importer().get_or_import_builtin_symbol( @@ -71,3 +87,18 @@ pub(crate) fn typing_text_str_alias(checker: &Checker, expr: &Expr) { }); } } + +#[derive(Copy, Clone, Debug)] +enum TypingModule { + Typing, + TypingExtensions, +} + +impl Display for TypingModule { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + TypingModule::Typing => f.write_str("typing"), + TypingModule::TypingExtensions => f.write_str("typing_extensions"), + } + } +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py.snap index d0af23c520..279648f87a 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py.snap @@ -66,3 +66,5 @@ help: Replace with `str` - def print_fourth_word(word: Goodbye) -> None: 19 + def print_fourth_word(word: str) -> None: 20 | print(word) +21 | +22 | diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py__preview.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py__preview.snap new file mode 100644 index 0000000000..89b257ccc2 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP019.py__preview.snap @@ -0,0 +1,119 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP019 [*] `typing.Text` is deprecated, use `str` + --> UP019.py:7:22 + | +7 | def print_word(word: Text) -> None: + | ^^^^ +8 | print(word) + | +help: Replace with `str` +4 | from typing import Text as Goodbye +5 | +6 | + - def print_word(word: Text) -> None: +7 + def print_word(word: str) -> None: +8 | print(word) +9 | +10 | + +UP019 [*] `typing.Text` is deprecated, use `str` + --> UP019.py:11:29 + | +11 | def print_second_word(word: typing.Text) -> None: + | ^^^^^^^^^^^ +12 | print(word) + | +help: Replace with `str` +8 | print(word) +9 | +10 | + - def print_second_word(word: typing.Text) -> None: +11 + def print_second_word(word: str) -> None: +12 | print(word) +13 | +14 | + +UP019 [*] `typing.Text` is deprecated, use `str` + --> UP019.py:15:28 + | +15 | def print_third_word(word: Hello.Text) -> None: + | ^^^^^^^^^^ +16 | print(word) + | +help: Replace with `str` +12 | print(word) +13 | +14 | + - def print_third_word(word: Hello.Text) -> None: +15 + def print_third_word(word: str) -> None: +16 | print(word) +17 | +18 | + +UP019 [*] `typing.Text` is deprecated, use `str` + --> UP019.py:19:29 + | +19 | def print_fourth_word(word: Goodbye) -> None: + | ^^^^^^^ +20 | print(word) + | +help: Replace with `str` +16 | print(word) +17 | +18 | + - def print_fourth_word(word: Goodbye) -> None: +19 + def print_fourth_word(word: str) -> None: +20 | print(word) +21 | +22 | + +UP019 [*] `typing_extensions.Text` is deprecated, use `str` + --> UP019.py:28:28 + | +28 | def print_fifth_word(word: typing_extensions.Text) -> None: + | ^^^^^^^^^^^^^^^^^^^^^^ +29 | print(word) + | +help: Replace with `str` +25 | from typing_extensions import Text as TextAlias +26 | +27 | + - def print_fifth_word(word: typing_extensions.Text) -> None: +28 + def print_fifth_word(word: str) -> None: +29 | print(word) +30 | +31 | + +UP019 [*] `typing_extensions.Text` is deprecated, use `str` + --> UP019.py:32:28 + | +32 | def print_sixth_word(word: TypingExt.Text) -> None: + | ^^^^^^^^^^^^^^ +33 | print(word) + | +help: Replace with `str` +29 | print(word) +30 | +31 | + - def print_sixth_word(word: TypingExt.Text) -> None: +32 + def print_sixth_word(word: str) -> None: +33 | print(word) +34 | +35 | + +UP019 [*] `typing_extensions.Text` is deprecated, use `str` + --> UP019.py:36:30 + | +36 | def print_seventh_word(word: TextAlias) -> None: + | ^^^^^^^^^ +37 | print(word) + | +help: Replace with `str` +33 | print(word) +34 | +35 | + - def print_seventh_word(word: TextAlias) -> None: +36 + def print_seventh_word(word: str) -> None: +37 | print(word) From a93618ed23fbfcdcedb6825df1ee0a45a7676a83 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 15 Oct 2025 08:59:59 +0200 Subject: [PATCH 041/113] Enable lto=fat (#20863) Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> --- Cargo.toml | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e7fea1cd0d..62305e93cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,12 +268,8 @@ large_stack_arrays = "allow" [profile.release] -# Note that we set these explicitly, and these values -# were chosen based on a trade-off between compile times -# and runtime performance[1]. -# -# [1]: https://github.com/astral-sh/ruff/pull/9031 -lto = "thin" +strip = true +lto = "fat" codegen-units = 16 # Some crates don't change as much but benefit more from @@ -283,6 +279,8 @@ codegen-units = 16 codegen-units = 1 [profile.release.package.ruff_python_ast] codegen-units = 1 +[profile.release.package.salsa] +codegen-units = 1 [profile.dev.package.insta] opt-level = 3 @@ -298,11 +296,30 @@ opt-level = 3 [profile.dev.package.ruff_python_parser] opt-level = 1 +# This profile is meant to mimic the `release` profile as closely as +# possible, but using settings that are more beneficial for iterative +# development. That is, the `release` profile is intended for actually +# building the release, where as `profiling` is meant for building ty/ruff +# for running benchmarks. +# +# The main differences here are to avoid stripping debug information +# and disabling fat lto. This does result in a mismatch between our release +# configuration and our benchmarking configuration, which is unfortunate. +# But compile times with `lto = fat` are completely untenable. +# +# This setup does risk that we are measuring something in benchmarks +# that we aren't shipping, but in order to make those two the same, we'd +# either need to make compile times way worse for development, or take +# a hit to binary size and a slight hit to runtime performance in our +# release builds. +# # Use the `--profile profiling` flag to show symbols in release mode. # e.g. `cargo build --profile profiling` [profile.profiling] inherits = "release" -debug = 1 +strip = false +debug = "full" +lto = false # The profile that 'cargo dist' will build with. [profile.dist] From 4fc7dd300c493fc16ea279928a2ad353e070c92e Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 15 Oct 2025 09:50:56 +0200 Subject: [PATCH 042/113] Improved error recovery for unclosed strings (including f- and t-strings) (#20848) --- .../ISC_syntax_error.py | 4 +- ...at__tests__ISC001_ISC_syntax_error.py.snap | 77 +++-- ...at__tests__ISC002_ISC_syntax_error.py.snap | 50 +-- ...10_invalid_characters_syntax_error.py.snap | 26 +- .../src/rules/pyupgrade/rules/f_strings.rs | 4 +- crates/ruff_python_ast/src/nodes.rs | 77 ++++- crates/ruff_python_parser/src/lexer.rs | 314 +++++++++++++++++- .../src/parser/expression.rs | 17 +- ...arser__parser__tests__unicode_aliases.snap | 1 + ...r__tests__fstring_newline_format_spec.snap | 42 ++- ...r__tests__tstring_newline_format_spec.snap | 42 ++- ...arser__string__tests__backspace_alias.snap | 1 + ...hon_parser__string__tests__bell_alias.snap | 1 + ..._string__tests__carriage_return_alias.snap | 1 + ...r_tabulation_with_justification_alias.snap | 1 + ...n_parser__string__tests__delete_alias.snap | 1 + ...ests__dont_panic_on_8_in_octal_escape.snap | 1 + ...er__string__tests__double_quoted_byte.snap | 1 + ...n_parser__string__tests__escape_alias.snap | 1 + ...g__tests__escape_char_in_byte_literal.snap | 1 + ...n_parser__string__tests__escape_octet.snap | 1 + ...arser__string__tests__form_feed_alias.snap | 1 + ...string__tests__fstring_constant_range.snap | 1 + ...ing__tests__fstring_escaped_character.snap | 1 + ...tring__tests__fstring_escaped_newline.snap | 1 + ...ing__tests__fstring_line_continuation.snap | 1 + ...__fstring_parse_self_documenting_base.snap | 1 + ...ring_parse_self_documenting_base_more.snap | 1 + ...fstring_parse_self_documenting_format.snap | 1 + ...ing__tests__fstring_unescaped_newline.snap | 1 + ...thon_parser__string__tests__hts_alias.snap | 1 + ...r__string__tests__parse_empty_fstring.snap | 1 + ...r__string__tests__parse_empty_tstring.snap | 1 + ...tring__tests__parse_f_string_concat_1.snap | 2 + ...tring__tests__parse_f_string_concat_2.snap | 2 + ...tring__tests__parse_f_string_concat_3.snap | 3 + ...tring__tests__parse_f_string_concat_4.snap | 4 + ..._parser__string__tests__parse_fstring.snap | 1 + ...__string__tests__parse_fstring_equals.snap | 1 + ...ring_nested_concatenation_string_spec.snap | 3 + ...ing__tests__parse_fstring_nested_spec.snap | 1 + ...sts__parse_fstring_nested_string_spec.snap | 2 + ...ring__tests__parse_fstring_not_equals.snap | 1 + ..._tests__parse_fstring_not_nested_spec.snap | 1 + ...ts__parse_fstring_self_doc_prec_space.snap | 1 + ...parse_fstring_self_doc_trailing_space.snap | 1 + ...ring__tests__parse_fstring_yield_expr.snap | 1 + ...r__string__tests__parse_string_concat.snap | 2 + ..._parse_string_triple_quotes_with_kind.snap | 1 + ..._parser__string__tests__parse_tstring.snap | 1 + ...__string__tests__parse_tstring_equals.snap | 1 + ...ring_nested_concatenation_string_spec.snap | 3 + ...ing__tests__parse_tstring_nested_spec.snap | 1 + ...sts__parse_tstring_nested_string_spec.snap | 2 + ...ring__tests__parse_tstring_not_equals.snap | 1 + ..._tests__parse_tstring_not_nested_spec.snap | 1 + ...ts__parse_tstring_self_doc_prec_space.snap | 1 + ...parse_tstring_self_doc_trailing_space.snap | 1 + ...ring__tests__parse_tstring_yield_expr.snap | 1 + ...ing__tests__parse_u_f_string_concat_1.snap | 2 + ...ing__tests__parse_u_f_string_concat_2.snap | 3 + ...tring__tests__parse_u_string_concat_1.snap | 2 + ...tring__tests__parse_u_string_concat_2.snap | 2 + ...er__string__tests__raw_byte_literal_1.snap | 1 + ...er__string__tests__raw_byte_literal_2.snap | 1 + ...on_parser__string__tests__raw_fstring.snap | 1 + ...on_parser__string__tests__raw_tstring.snap | 1 + ...er__string__tests__single_quoted_byte.snap | 1 + ..._tests__string_parser_escaped_mac_eol.snap | 1 + ...tests__string_parser_escaped_unix_eol.snap | 1 + ...ts__string_parser_escaped_windows_eol.snap | 1 + ...ing__tests__triple_quoted_raw_fstring.snap | 1 + ...ing__tests__triple_quoted_raw_tstring.snap | 1 + ...string__tests__tstring_constant_range.snap | 1 + ...ing__tests__tstring_escaped_character.snap | 1 + ...tring__tests__tstring_escaped_newline.snap | 1 + ...ing__tests__tstring_line_continuation.snap | 1 + ...__tstring_parse_self_documenting_base.snap | 1 + ...ring_parse_self_documenting_base_more.snap | 1 + ...tstring_parse_self_documenting_format.snap | 1 + ...ing__tests__tstring_unescaped_newline.snap | 1 + crates/ruff_python_parser/src/token.rs | 9 +- crates/ruff_python_parser/src/token_source.rs | 13 + ...tax@ann_assign_stmt_invalid_target.py.snap | 3 + ..._syntax@assign_stmt_invalid_target.py.snap | 4 + ...tax@aug_assign_stmt_invalid_target.py.snap | 2 + .../invalid_syntax@debug_shadow_with.py.snap | 1 + ..._syntax@duplicate_match_class_attr.py.snap | 6 + ...invalid_syntax@duplicate_match_key.py.snap | 17 + ...x@expressions__dict__comprehension.py.snap | 1 + ...x@expressions__list__comprehension.py.snap | 1 + ...ax@expressions__set__comprehension.py.snap | 1 + ...ing_conversion_follows_exclamation.py.snap | 3 + ...d_syntax@f_string_empty_expression.py.snap | 2 + ...g_invalid_conversion_flag_name_tok.py.snap | 1 + ..._invalid_conversion_flag_other_tok.py.snap | 2 + ...ntax@f_string_invalid_starred_expr.py.snap | 3 + ..._string_lambda_without_parentheses.py.snap | 1 + ...id_syntax@f_string_unclosed_lbrace.py.snap | 252 +++++--------- ...ing_unclosed_lbrace_in_format_spec.py.snap | 2 + ...lid_syntax@for_stmt_invalid_target.py.snap | 2 + ...y_concatenated_unterminated_string.py.snap | 52 +-- ...ated_unterminated_string_multiline.py.snap | 119 +++---- ...nvalid_syntax@invalid_byte_literal.py.snap | 3 + .../invalid_syntax@invalid_del_target.py.snap | 4 + ...ax@invalid_fstring_literal_element.py.snap | 2 + ...alid_syntax@invalid_string_literal.py.snap | 2 + ...mixed_bytes_and_non_bytes_literals.py.snap | 7 + ...ax@params_var_keyword_with_default.py.snap | 2 + ...valid_syntax@pep701_f_string_py311.py.snap | 23 ++ ...nvalid_syntax@re_lex_logical_token.py.snap | 18 +- ...x@re_lexing__fstring_format_spec_1.py.snap | 90 +++-- ...re_lexing__triple_quoted_fstring_1.py.snap | 3 +- ...re_lexing__triple_quoted_fstring_2.py.snap | 1 + ...re_lexing__triple_quoted_fstring_3.py.snap | 1 + ...ements__invalid_assignment_targets.py.snap | 5 + ...nvalid_augmented_assignment_target.py.snap | 5 + ...d_syntax@t_string_empty_expression.py.snap | 2 + ...g_invalid_conversion_flag_name_tok.py.snap | 1 + ..._invalid_conversion_flag_other_tok.py.snap | 2 + ...ntax@t_string_invalid_starred_expr.py.snap | 3 + ..._string_lambda_without_parentheses.py.snap | 1 + ...id_syntax@t_string_unclosed_lbrace.py.snap | 249 +++++--------- ...ing_unclosed_lbrace_in_format_spec.py.snap | 2 + ...alid_syntax@template_strings_py313.py.snap | 3 + ...erminated_fstring_newline_recovery.py.snap | 17 +- ...ecorator_expression_eval_hack_py38.py.snap | 1 + .../valid_syntax@expressions__call.py.snap | 1 + ...lid_syntax@expressions__dictionary.py.snap | 9 + ...ressions__dictionary_comprehension.py.snap | 1 + ...valid_syntax@expressions__f_string.py.snap | 72 ++++ ...alid_syntax@expressions__generator.py.snap | 3 + .../valid_syntax@expressions__string.py.snap | 14 + ...valid_syntax@expressions__t_string.py.snap | 59 ++++ ...tax@fstring_format_spec_terminator.py.snap | 2 + ...syntax@match_classify_as_keyword_1.py.snap | 2 + ...x@param_with_star_annotation_py310.py.snap | 2 + ...valid_syntax@pep701_f_string_py311.py.snap | 14 + ...valid_syntax@pep701_f_string_py312.py.snap | 16 + ...valid_syntax@pep750_t_string_py314.py.snap | 16 + ...thesized_item_context_manager_py38.py.snap | 2 + ...atement__ambiguous_lpar_with_items.py.snap | 4 + .../valid_syntax@statement__assert.py.snap | 1 + ...valid_syntax@statement__assignment.py.snap | 1 + .../valid_syntax@statement__class.py.snap | 1 + .../valid_syntax@statement__function.py.snap | 1 + .../valid_syntax@statement__match.py.snap | 16 + .../valid_syntax@statement__try.py.snap | 6 + .../valid_syntax@statement__type.py.snap | 1 + .../valid_syntax@statement__while.py.snap | 2 + ...alid_syntax@template_strings_py314.py.snap | 3 + 151 files changed, 1370 insertions(+), 566 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error.py b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error.py index 997c86968d..dae709face 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_implicit_str_concat/ISC_syntax_error.py @@ -1,10 +1,10 @@ -# The lexer doesn't emit a string token if it's unterminated +# The lexer emits a string token if it's unterminated "a" "b "a" "b" "c "a" """b c""" "d -# For f-strings, the `FStringRanges` won't contain the range for +# This is also true for # unterminated f-strings. f"a" f"b f"a" f"b" f"c diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error.py.snap index 0b61838958..ac74361ae5 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error.py.snap +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC001_ISC_syntax_error.py.snap @@ -1,22 +1,23 @@ --- source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs --- -invalid-syntax: missing closing quote in string literal - --> ISC_syntax_error.py:2:5 +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error.py:2:1 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b - | ^^ + | ^^^^^^ 3 | "a" "b" "c 4 | "a" """b | +help: Combine string literals -invalid-syntax: Expected a statement - --> ISC_syntax_error.py:2:7 +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error.py:2:5 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b - | ^ + | ^^ 3 | "a" "b" "c 4 | "a" """b | @@ -24,7 +25,7 @@ invalid-syntax: Expected a statement ISC001 Implicitly concatenated string literals on one line --> ISC_syntax_error.py:3:1 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b 3 | "a" "b" "c | ^^^^^^^ @@ -33,24 +34,25 @@ ISC001 Implicitly concatenated string literals on one line | help: Combine string literals -invalid-syntax: missing closing quote in string literal - --> ISC_syntax_error.py:3:9 +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error.py:3:5 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b 3 | "a" "b" "c - | ^^ + | ^^^^^^ 4 | "a" """b 5 | c""" "d | +help: Combine string literals -invalid-syntax: Expected a statement - --> ISC_syntax_error.py:3:11 +invalid-syntax: missing closing quote in string literal + --> ISC_syntax_error.py:3:9 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b 3 | "a" "b" "c - | ^ + | ^^ 4 | "a" """b 5 | c""" "d | @@ -64,7 +66,21 @@ ISC001 Implicitly concatenated string literals on one line 5 | | c""" "d | |____^ 6 | -7 | # For f-strings, the `FStringRanges` won't contain the range for +7 | # This is also true for + | +help: Combine string literals + +ISC001 Implicitly concatenated string literals on one line + --> ISC_syntax_error.py:4:5 + | +2 | "a" "b +3 | "a" "b" "c +4 | "a" """b + | _____^ +5 | | c""" "d + | |_______^ +6 | +7 | # This is also true for | help: Combine string literals @@ -76,24 +92,13 @@ invalid-syntax: missing closing quote in string literal 5 | c""" "d | ^^ 6 | -7 | # For f-strings, the `FStringRanges` won't contain the range for - | - -invalid-syntax: Expected a statement - --> ISC_syntax_error.py:5:8 - | -3 | "a" "b" "c -4 | "a" """b -5 | c""" "d - | ^ -6 | -7 | # For f-strings, the `FStringRanges` won't contain the range for +7 | # This is also true for | invalid-syntax: f-string: unterminated string --> ISC_syntax_error.py:9:8 | - 7 | # For f-strings, the `FStringRanges` won't contain the range for + 7 | # This is also true for 8 | # unterminated f-strings. 9 | f"a" f"b | ^ @@ -104,7 +109,7 @@ invalid-syntax: f-string: unterminated string invalid-syntax: Expected FStringEnd, found newline --> ISC_syntax_error.py:9:9 | - 7 | # For f-strings, the `FStringRanges` won't contain the range for + 7 | # This is also true for 8 | # unterminated f-strings. 9 | f"a" f"b | ^ @@ -183,14 +188,6 @@ invalid-syntax: f-string: unterminated triple-quoted string | |__^ | -invalid-syntax: unexpected EOF while parsing - --> ISC_syntax_error.py:30:1 - | -28 | "i" "j" -29 | ) - | ^ - | - invalid-syntax: f-string: unterminated string --> ISC_syntax_error.py:30:1 | diff --git a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error.py.snap b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error.py.snap index 1cbcf596b3..6c645ec5d1 100644 --- a/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error.py.snap +++ b/crates/ruff_linter/src/rules/flake8_implicit_str_concat/snapshots/ruff_linter__rules__flake8_implicit_str_concat__tests__ISC002_ISC_syntax_error.py.snap @@ -4,27 +4,17 @@ source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs invalid-syntax: missing closing quote in string literal --> ISC_syntax_error.py:2:5 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b | ^^ 3 | "a" "b" "c 4 | "a" """b | -invalid-syntax: Expected a statement - --> ISC_syntax_error.py:2:7 - | -1 | # The lexer doesn't emit a string token if it's unterminated -2 | "a" "b - | ^ -3 | "a" "b" "c -4 | "a" """b - | - invalid-syntax: missing closing quote in string literal --> ISC_syntax_error.py:3:9 | -1 | # The lexer doesn't emit a string token if it's unterminated +1 | # The lexer emits a string token if it's unterminated 2 | "a" "b 3 | "a" "b" "c | ^^ @@ -32,17 +22,6 @@ invalid-syntax: missing closing quote in string literal 5 | c""" "d | -invalid-syntax: Expected a statement - --> ISC_syntax_error.py:3:11 - | -1 | # The lexer doesn't emit a string token if it's unterminated -2 | "a" "b -3 | "a" "b" "c - | ^ -4 | "a" """b -5 | c""" "d - | - invalid-syntax: missing closing quote in string literal --> ISC_syntax_error.py:5:6 | @@ -51,24 +30,13 @@ invalid-syntax: missing closing quote in string literal 5 | c""" "d | ^^ 6 | -7 | # For f-strings, the `FStringRanges` won't contain the range for - | - -invalid-syntax: Expected a statement - --> ISC_syntax_error.py:5:8 - | -3 | "a" "b" "c -4 | "a" """b -5 | c""" "d - | ^ -6 | -7 | # For f-strings, the `FStringRanges` won't contain the range for +7 | # This is also true for | invalid-syntax: f-string: unterminated string --> ISC_syntax_error.py:9:8 | - 7 | # For f-strings, the `FStringRanges` won't contain the range for + 7 | # This is also true for 8 | # unterminated f-strings. 9 | f"a" f"b | ^ @@ -79,7 +47,7 @@ invalid-syntax: f-string: unterminated string invalid-syntax: Expected FStringEnd, found newline --> ISC_syntax_error.py:9:9 | - 7 | # For f-strings, the `FStringRanges` won't contain the range for + 7 | # This is also true for 8 | # unterminated f-strings. 9 | f"a" f"b | ^ @@ -133,14 +101,6 @@ invalid-syntax: f-string: unterminated triple-quoted string | |__^ | -invalid-syntax: unexpected EOF while parsing - --> ISC_syntax_error.py:30:1 - | -28 | "i" "j" -29 | ) - | ^ - | - invalid-syntax: f-string: unterminated string --> ISC_syntax_error.py:30:1 | diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters_syntax_error.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters_syntax_error.py.snap index 10839c42b0..4ae66b4a4a 100644 --- a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters_syntax_error.py.snap +++ b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2510_invalid_characters_syntax_error.py.snap @@ -23,16 +23,17 @@ invalid-syntax: missing closing quote in string literal 9 | # Unterminated f-string | -invalid-syntax: Expected a statement - --> invalid_characters_syntax_error.py:7:7 +PLE2510 Invalid unescaped character backspace, use "\b" instead + --> invalid_characters_syntax_error.py:7:6 | 5 | b = '␈' 6 | # Unterminated string 7 | b = '␈ - | ^ + | ^ 8 | b = '␈' 9 | # Unterminated f-string | +help: Replace with escape sequence PLE2510 Invalid unescaped character backspace, use "\b" instead --> invalid_characters_syntax_error.py:8:6 @@ -46,6 +47,18 @@ PLE2510 Invalid unescaped character backspace, use "\b" instead | help: Replace with escape sequence +PLE2510 Invalid unescaped character backspace, use "\b" instead + --> invalid_characters_syntax_error.py:10:7 + | + 8 | b = '␈' + 9 | # Unterminated f-string +10 | b = f'␈ + | ^ +11 | b = f'␈' +12 | # Implicitly concatenated + | +help: Replace with escape sequence + invalid-syntax: f-string: unterminated string --> invalid_characters_syntax_error.py:10:7 | @@ -109,11 +122,12 @@ invalid-syntax: missing closing quote in string literal | ^^ | -invalid-syntax: Expected a statement - --> invalid_characters_syntax_error.py:13:16 +PLE2510 Invalid unescaped character backspace, use "\b" instead + --> invalid_characters_syntax_error.py:13:15 | 11 | b = f'␈' 12 | # Implicitly concatenated 13 | b = '␈' f'␈' '␈ - | ^ + | ^ | +help: Replace with escape sequence diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index f34751ce93..938ff1aadb 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -6,7 +6,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::helpers::any_over_expr; use ruff_python_ast::str::{leading_quote, trailing_quote}; -use ruff_python_ast::{self as ast, Expr, Keyword}; +use ruff_python_ast::{self as ast, Expr, Keyword, StringFlags}; use ruff_python_literal::format::{ FieldName, FieldNamePart, FieldType, FormatPart, FormatString, FromTemplate, }; @@ -430,7 +430,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // dot is the start of an attribute access. break token.start(); } - TokenKind::String => { + TokenKind::String if !token.unwrap_string_flags().is_unclosed() => { match FStringConversion::try_convert(token.range(), &mut summary, checker.locator()) { // If the format string contains side effects that would need to be repeated, diff --git a/crates/ruff_python_ast/src/nodes.rs b/crates/ruff_python_ast/src/nodes.rs index b53582218f..55c055e5bb 100644 --- a/crates/ruff_python_ast/src/nodes.rs +++ b/crates/ruff_python_ast/src/nodes.rs @@ -735,6 +735,8 @@ pub trait StringFlags: Copy { fn prefix(self) -> AnyStringPrefix; + fn is_unclosed(self) -> bool; + /// Is the string triple-quoted, i.e., /// does it begin and end with three consecutive quote characters? fn is_triple_quoted(self) -> bool { @@ -779,6 +781,7 @@ pub trait StringFlags: Copy { fn as_any_string_flags(self) -> AnyStringFlags { AnyStringFlags::new(self.prefix(), self.quote_style(), self.triple_quotes()) + .with_unclosed(self.is_unclosed()) } fn display_contents(self, contents: &str) -> DisplayFlags<'_> { @@ -829,6 +832,10 @@ bitflags! { /// for why we track the casing of the `r` prefix, /// but not for any other prefix const R_PREFIX_UPPER = 1 << 3; + + /// The f-string is unclosed, meaning it is missing a closing quote. + /// For example: `f"{bar` + const UNCLOSED = 1 << 4; } } @@ -887,6 +894,12 @@ impl FStringFlags { self } + #[must_use] + pub fn with_unclosed(mut self, unclosed: bool) -> Self { + self.0.set(InterpolatedStringFlagsInner::UNCLOSED, unclosed); + self + } + #[must_use] pub fn with_prefix(mut self, prefix: FStringPrefix) -> Self { match prefix { @@ -984,6 +997,12 @@ impl TStringFlags { self } + #[must_use] + pub fn with_unclosed(mut self, unclosed: bool) -> Self { + self.0.set(InterpolatedStringFlagsInner::UNCLOSED, unclosed); + self + } + #[must_use] pub fn with_prefix(mut self, prefix: TStringPrefix) -> Self { match prefix { @@ -1051,6 +1070,10 @@ impl StringFlags for FStringFlags { fn prefix(self) -> AnyStringPrefix { AnyStringPrefix::Format(self.prefix()) } + + fn is_unclosed(self) -> bool { + self.0.intersects(InterpolatedStringFlagsInner::UNCLOSED) + } } impl fmt::Debug for FStringFlags { @@ -1059,6 +1082,7 @@ impl fmt::Debug for FStringFlags { .field("quote_style", &self.quote_style()) .field("prefix", &self.prefix()) .field("triple_quoted", &self.is_triple_quoted()) + .field("unclosed", &self.is_unclosed()) .finish() } } @@ -1090,6 +1114,10 @@ impl StringFlags for TStringFlags { fn prefix(self) -> AnyStringPrefix { AnyStringPrefix::Template(self.prefix()) } + + fn is_unclosed(self) -> bool { + self.0.intersects(InterpolatedStringFlagsInner::UNCLOSED) + } } impl fmt::Debug for TStringFlags { @@ -1098,6 +1126,7 @@ impl fmt::Debug for TStringFlags { .field("quote_style", &self.quote_style()) .field("prefix", &self.prefix()) .field("triple_quoted", &self.is_triple_quoted()) + .field("unclosed", &self.is_unclosed()) .finish() } } @@ -1427,6 +1456,9 @@ bitflags! { /// The string was deemed invalid by the parser. const INVALID = 1 << 5; + + /// The string literal misses the matching closing quote(s). + const UNCLOSED = 1 << 6; } } @@ -1479,6 +1511,12 @@ impl StringLiteralFlags { self } + #[must_use] + pub fn with_unclosed(mut self, unclosed: bool) -> Self { + self.0.set(StringLiteralFlagsInner::UNCLOSED, unclosed); + self + } + #[must_use] pub fn with_prefix(self, prefix: StringLiteralPrefix) -> Self { let StringLiteralFlags(flags) = self; @@ -1560,6 +1598,10 @@ impl StringFlags for StringLiteralFlags { fn prefix(self) -> AnyStringPrefix { AnyStringPrefix::Regular(self.prefix()) } + + fn is_unclosed(self) -> bool { + self.0.intersects(StringLiteralFlagsInner::UNCLOSED) + } } impl fmt::Debug for StringLiteralFlags { @@ -1568,6 +1610,7 @@ impl fmt::Debug for StringLiteralFlags { .field("quote_style", &self.quote_style()) .field("prefix", &self.prefix()) .field("triple_quoted", &self.is_triple_quoted()) + .field("unclosed", &self.is_unclosed()) .finish() } } @@ -1846,6 +1889,9 @@ bitflags! { /// The bytestring was deemed invalid by the parser. const INVALID = 1 << 4; + + /// The byte string misses the matching closing quote(s). + const UNCLOSED = 1 << 5; } } @@ -1897,6 +1943,12 @@ impl BytesLiteralFlags { self } + #[must_use] + pub fn with_unclosed(mut self, unclosed: bool) -> Self { + self.0.set(BytesLiteralFlagsInner::UNCLOSED, unclosed); + self + } + #[must_use] pub fn with_prefix(mut self, prefix: ByteStringPrefix) -> Self { match prefix { @@ -1959,6 +2011,10 @@ impl StringFlags for BytesLiteralFlags { fn prefix(self) -> AnyStringPrefix { AnyStringPrefix::Bytes(self.prefix()) } + + fn is_unclosed(self) -> bool { + self.0.intersects(BytesLiteralFlagsInner::UNCLOSED) + } } impl fmt::Debug for BytesLiteralFlags { @@ -1967,6 +2023,7 @@ impl fmt::Debug for BytesLiteralFlags { .field("quote_style", &self.quote_style()) .field("prefix", &self.prefix()) .field("triple_quoted", &self.is_triple_quoted()) + .field("unclosed", &self.is_unclosed()) .finish() } } @@ -2028,7 +2085,7 @@ bitflags! { /// prefix flags is by calling the `as_flags()` method on the /// `StringPrefix` enum. #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] - struct AnyStringFlagsInner: u8 { + struct AnyStringFlagsInner: u16 { /// The string uses double quotes (`"`). /// If this flag is not set, the string uses single quotes (`'`). const DOUBLE = 1 << 0; @@ -2071,6 +2128,9 @@ bitflags! { /// for why we track the casing of the `r` prefix, /// but not for any other prefix const R_PREFIX_UPPER = 1 << 7; + + /// String without matching closing quote(s). + const UNCLOSED = 1 << 8; } } @@ -2166,6 +2226,12 @@ impl AnyStringFlags { .set(AnyStringFlagsInner::TRIPLE_QUOTED, triple_quotes.is_yes()); self } + + #[must_use] + pub fn with_unclosed(mut self, unclosed: bool) -> Self { + self.0.set(AnyStringFlagsInner::UNCLOSED, unclosed); + self + } } impl StringFlags for AnyStringFlags { @@ -2234,6 +2300,10 @@ impl StringFlags for AnyStringFlags { } AnyStringPrefix::Regular(StringLiteralPrefix::Empty) } + + fn is_unclosed(self) -> bool { + self.0.intersects(AnyStringFlagsInner::UNCLOSED) + } } impl fmt::Debug for AnyStringFlags { @@ -2242,6 +2312,7 @@ impl fmt::Debug for AnyStringFlags { .field("prefix", &self.prefix()) .field("triple_quoted", &self.is_triple_quoted()) .field("quote_style", &self.quote_style()) + .field("unclosed", &self.is_unclosed()) .finish() } } @@ -2258,6 +2329,7 @@ impl From for StringLiteralFlags { .with_quote_style(value.quote_style()) .with_prefix(prefix) .with_triple_quotes(value.triple_quotes()) + .with_unclosed(value.is_unclosed()) } } @@ -2279,6 +2351,7 @@ impl From for BytesLiteralFlags { .with_quote_style(value.quote_style()) .with_prefix(bytestring_prefix) .with_triple_quotes(value.triple_quotes()) + .with_unclosed(value.is_unclosed()) } } @@ -2300,6 +2373,7 @@ impl From for FStringFlags { .with_quote_style(value.quote_style()) .with_prefix(prefix) .with_triple_quotes(value.triple_quotes()) + .with_unclosed(value.is_unclosed()) } } @@ -2321,6 +2395,7 @@ impl From for TStringFlags { .with_quote_style(value.quote_style()) .with_prefix(prefix) .with_triple_quotes(value.triple_quotes()) + .with_unclosed(value.is_unclosed()) } } diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index ca58e72bec..dc864d71b6 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -13,6 +13,7 @@ use unicode_ident::{is_xid_continue, is_xid_start}; use unicode_normalization::UnicodeNormalization; use ruff_python_ast::name::Name; +use ruff_python_ast::str_prefix::{AnyStringPrefix, StringLiteralPrefix}; use ruff_python_ast::{Int, IpyEscapeKind, StringFlags}; use ruff_python_trivia::is_python_whitespace; use ruff_text_size::{TextLen, TextRange, TextSize}; @@ -24,6 +25,7 @@ use crate::lexer::indentation::{Indentation, Indentations, IndentationsCheckpoin use crate::lexer::interpolated_string::{ InterpolatedStringContext, InterpolatedStrings, InterpolatedStringsCheckpoint, }; +use crate::string::InterpolatedStringKind; use crate::token::{TokenFlags, TokenKind, TokenValue}; mod cursor; @@ -782,6 +784,7 @@ impl<'src> Lexer<'src> { // SAFETY: Safe because the function is only called when `self.fstrings` is not empty. let interpolated_string = self.interpolated_strings.current().unwrap(); let string_kind = interpolated_string.kind(); + let interpolated_flags = interpolated_string.flags(); // Check if we're at the end of the f-string. if interpolated_string.is_triple_quoted() { @@ -819,15 +822,19 @@ impl<'src> Lexer<'src> { } else { InterpolatedStringErrorType::UnterminatedString }; + + self.nesting = interpolated_string.nesting(); self.interpolated_strings.pop(); - return Some(self.push_error(LexicalError::new( + self.current_flags |= TokenFlags::UNCLOSED_STRING; + self.push_error(LexicalError::new( LexicalErrorType::from_interpolated_string_error(error, string_kind), self.token_range(), - ))); + )); + + break; } '\n' | '\r' if !interpolated_string.is_triple_quoted() => { // https://github.com/astral-sh/ruff/issues/18632 - self.interpolated_strings.pop(); let error_type = if in_format_spec { InterpolatedStringErrorType::NewlineInFormatSpec @@ -835,10 +842,16 @@ impl<'src> Lexer<'src> { InterpolatedStringErrorType::UnterminatedString }; - return Some(self.push_error(LexicalError::new( + self.nesting = interpolated_string.nesting(); + self.interpolated_strings.pop(); + self.current_flags |= TokenFlags::UNCLOSED_STRING; + + self.push_error(LexicalError::new( LexicalErrorType::from_interpolated_string_error(error_type, string_kind), self.token_range(), - ))); + )); + + break; } '\\' => { self.cursor.bump(); // '\' @@ -913,7 +926,7 @@ impl<'src> Lexer<'src> { self.current_value = TokenValue::InterpolatedStringMiddle(value.into_boxed_str()); - self.current_flags = interpolated_string.flags(); + self.current_flags = interpolated_flags; Some(string_kind.middle_token()) } @@ -942,10 +955,12 @@ impl<'src> Lexer<'src> { let Some(index) = memchr::memchr(quote_byte, self.cursor.rest().as_bytes()) else { self.cursor.skip_to_end(); - return self.push_error(LexicalError::new( + self.current_flags |= TokenFlags::UNCLOSED_STRING; + self.push_error(LexicalError::new( LexicalErrorType::UnclosedStringError, self.token_range(), )); + break self.offset(); }; // Rare case: if there are an odd number of backslashes before the quote, then @@ -977,11 +992,14 @@ impl<'src> Lexer<'src> { memchr::memchr3(quote_byte, b'\r', b'\n', self.cursor.rest().as_bytes()) else { self.cursor.skip_to_end(); + self.current_flags |= TokenFlags::UNCLOSED_STRING; - return self.push_error(LexicalError::new( - LexicalErrorType::StringError, + self.push_error(LexicalError::new( + LexicalErrorType::UnclosedStringError, self.token_range(), )); + + break self.offset(); }; // Rare case: if there are an odd number of backslashes before the quote, then @@ -1009,10 +1027,12 @@ impl<'src> Lexer<'src> { match quote_or_newline { '\r' | '\n' => { - return self.push_error(LexicalError::new( + self.current_flags |= TokenFlags::UNCLOSED_STRING; + self.push_error(LexicalError::new( LexicalErrorType::UnclosedStringError, self.token_range(), )); + break self.offset(); } ch if ch == quote => { let value_end = self.offset(); @@ -1331,7 +1351,20 @@ impl<'src> Lexer<'src> { fn consume_end(&mut self) -> TokenKind { // We reached end of file. - // First of all, we need all nestings to be finished. + + // First, finish any unterminated interpolated-strings. + while let Some(interpolated_string) = self.interpolated_strings.pop() { + self.nesting = interpolated_string.nesting(); + self.push_error(LexicalError::new( + LexicalErrorType::from_interpolated_string_error( + InterpolatedStringErrorType::UnterminatedString, + interpolated_string.kind(), + ), + self.token_range(), + )); + } + + // Second, finish all nestings. // For Mode::ParenthesizedExpression we start with nesting level 1. // So we check if we end with that level. let init_nesting = u32::from(self.mode == Mode::ParenthesizedExpression); @@ -1459,6 +1492,107 @@ impl<'src> Lexer<'src> { true } + /// Re-lexes an unclosed string token in the context of an interpolated string element. + /// + /// ```py + /// f'{a' + /// ``` + /// + /// This method re-lexes the trailing `'` as the end of the f-string rather than the + /// start of a new string token for better error recovery. + pub(crate) fn re_lex_string_token_in_interpolation_element( + &mut self, + kind: InterpolatedStringKind, + ) { + let Some(interpolated_string) = self.interpolated_strings.current() else { + return; + }; + + let current_string_flags = self.current_flags().as_any_string_flags(); + + // Only unclosed strings, that have the same quote character + if !matches!(self.current_kind, TokenKind::String) + || !self.current_flags.is_unclosed() + || current_string_flags.prefix() != AnyStringPrefix::Regular(StringLiteralPrefix::Empty) + || current_string_flags.quote_style().as_char() != interpolated_string.quote_char() + || current_string_flags.is_triple_quoted() != interpolated_string.is_triple_quoted() + { + return; + } + + // Only if the string's first line only contains whitespace, + // or ends in a comment (not `f"{"abc`) + let first_line = &self.source + [(self.current_range.start() + current_string_flags.quote_len()).to_usize()..]; + + for c in first_line.chars() { + if matches!(c, '\n' | '\r' | '#') { + break; + } + + // `f'{'ab`, we want to parse `ab` as a normal string and not the closing element of the f-string + if !is_python_whitespace(c) { + return; + } + } + + if self.errors.last().is_some_and(|error| { + error.location() == self.current_range + && matches!(error.error(), LexicalErrorType::UnclosedStringError) + }) { + self.errors.pop(); + } + + self.current_range = + TextRange::at(self.current_range.start(), self.current_flags.quote_len()); + self.current_kind = kind.end_token(); + self.current_value = TokenValue::None; + self.current_flags = TokenFlags::empty(); + + self.nesting = interpolated_string.nesting(); + self.interpolated_strings.pop(); + + self.cursor = Cursor::new(self.source); + self.cursor.skip_bytes(self.current_range.end().to_usize()); + } + + /// Re-lex `r"` in a format specifier position. + /// + /// `r"` in a format specifier position is unlikely to be the start of a raw string. + /// Instead, it's the format specifier `!r` followed by the closing quote of the f-string, + /// when the `}` is missing. + /// + /// ```py + /// f"{test!r" + /// ``` + /// + /// This function re-lexes the `r"` as `r` (a name token). The next `next_token` call will + /// return a unclosed string token for `"`, which [`Self::re_lex_string_token_in_interpolation_element`] + /// can then re-lex as the end of the f-string. + pub(crate) fn re_lex_raw_string_in_format_spec(&mut self) { + // Re-lex `r"` as `NAME r` followed by an unclosed string + // `f"{test!r"` -> `f"{test!`, `r`, `"` + if matches!(self.current_kind, TokenKind::String) + && self.current_flags.is_unclosed() + && self.current_flags.prefix() + == AnyStringPrefix::Regular(StringLiteralPrefix::Raw { uppercase: false }) + { + if self.errors.last().is_some_and(|error| { + error.location() == self.current_range + && matches!(error.error(), LexicalErrorType::UnclosedStringError) + }) { + self.errors.pop(); + } + + self.current_range = TextRange::at(self.current_range.start(), 'r'.text_len()); + self.current_kind = TokenKind::Name; + self.current_value = TokenValue::Name(Name::new_static("r")); + self.current_flags = TokenFlags::empty(); + self.cursor = Cursor::new(self.source); + self.cursor.skip_bytes(self.current_range.end().to_usize()); + } + } + #[inline] fn token_range(&self) -> TextRange { let end = self.offset(); @@ -2739,6 +2873,164 @@ t"{(lambda x:{x})}" } } + #[test] + fn lex_fstring_unclosed() { + let source = r#"f"hello"#; + + assert_snapshot!(lex_invalid(source, Mode::Module), @r#" + ## Tokens + ``` + [ + ( + FStringStart, + 0..2, + TokenFlags( + DOUBLE_QUOTES | F_STRING, + ), + ), + ( + InterpolatedStringMiddle( + "hello", + ), + 2..7, + TokenFlags( + DOUBLE_QUOTES | F_STRING, + ), + ), + ( + Newline, + 7..7, + ), + ] + ``` + ## Errors + ``` + [ + LexicalError { + error: FStringError( + UnterminatedString, + ), + location: 2..7, + }, + ] + ``` + "#); + } + + #[test] + fn lex_fstring_missing_brace() { + let source = "f'{'"; + + assert_snapshot!(lex_invalid(source, Mode::Module), @r#" + ## Tokens + ``` + [ + ( + FStringStart, + 0..2, + TokenFlags( + F_STRING, + ), + ), + ( + Lbrace, + 2..3, + ), + ( + String( + "", + ), + 3..4, + TokenFlags( + UNCLOSED_STRING, + ), + ), + ( + Newline, + 4..4, + ), + ] + ``` + ## Errors + ``` + [ + LexicalError { + error: UnclosedStringError, + location: 3..4, + }, + LexicalError { + error: FStringError( + UnterminatedString, + ), + location: 4..4, + }, + ] + ``` + "#); + } + + #[test] + fn lex_fstring_missing_brace_after_format_spec() { + let source = r#"f"{foo!r""#; + + assert_snapshot!(lex_invalid(source, Mode::Module), @r#" + ## Tokens + ``` + [ + ( + FStringStart, + 0..2, + TokenFlags( + DOUBLE_QUOTES | F_STRING, + ), + ), + ( + Lbrace, + 2..3, + ), + ( + Name( + Name("foo"), + ), + 3..6, + ), + ( + Exclamation, + 6..7, + ), + ( + String( + "", + ), + 7..9, + TokenFlags( + DOUBLE_QUOTES | RAW_STRING_LOWERCASE | UNCLOSED_STRING, + ), + ), + ( + Newline, + 9..9, + ), + ] + ``` + ## Errors + ``` + [ + LexicalError { + error: UnclosedStringError, + location: 7..9, + }, + LexicalError { + error: FStringError( + UnterminatedString, + ), + location: 9..9, + }, + ] + ``` + "#); + } + #[test] fn test_tstring_error() { use InterpolatedStringErrorType::{ diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index d198441895..043de1772c 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -1526,7 +1526,7 @@ impl<'src> Parser<'src> { kind: InterpolatedStringKind, ) -> InterpolatedStringData { let start = self.node_start(); - let flags = self.tokens.current_flags().as_any_string_flags(); + let mut flags = self.tokens.current_flags().as_any_string_flags(); self.bump(kind.start_token()); let elements = self.parse_interpolated_string_elements( @@ -1535,7 +1535,9 @@ impl<'src> Parser<'src> { kind, ); - self.expect(kind.end_token()); + if !self.expect(kind.end_token()) { + flags = flags.with_unclosed(true); + } // test_ok pep701_f_string_py312 // # parse_options: {"target-version": "3.12"} @@ -1719,6 +1721,9 @@ impl<'src> Parser<'src> { let start = self.node_start(); self.bump(TokenKind::Lbrace); + self.tokens + .re_lex_string_token_in_interpolation_element(string_kind); + // test_err f_string_empty_expression // f"{}" // f"{ }" @@ -1740,6 +1745,7 @@ impl<'src> Parser<'src> { // t"{*}" // t"{*x and y}" // t"{*yield x}" + let value = self.parse_expression_list(ExpressionContext::yield_or_starred_bitwise_or()); if !value.is_parenthesized && value.expr.is_lambda_expr() { @@ -1773,6 +1779,10 @@ impl<'src> Parser<'src> { }; let conversion = if self.eat(TokenKind::Exclamation) { + // Ensure that the `r` is lexed as a `r` name token instead of a raw string + // in `f{abc!r"` (note the missing `}`). + self.tokens.re_lex_raw_string_in_format_spec(); + let conversion_flag_range = self.current_token_range(); if self.at(TokenKind::Name) { // test_err f_string_conversion_follows_exclamation @@ -1852,6 +1862,9 @@ impl<'src> Parser<'src> { None }; + self.tokens + .re_lex_string_token_in_interpolation_element(string_kind); + // We're using `eat` here instead of `expect` to use the f-string specific error type. if !self.eat(TokenKind::Rbrace) { // TODO(dhruvmanila): This requires some changes in the lexer. One of them diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__unicode_aliases.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__unicode_aliases.snap index 2e77ece943..08e3e1b4f3 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__unicode_aliases.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__unicode_aliases.snap @@ -31,6 +31,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__fstring_newline_format_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__fstring_newline_format_spec.snap index 868a705a31..4f6fb2e8cc 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__fstring_newline_format_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__fstring_newline_format_spec.snap @@ -44,11 +44,16 @@ expression: "lex_invalid(source, Mode::Module)" 12..13, ), ( - Unknown, + InterpolatedStringMiddle( + "d", + ), 13..14, + TokenFlags( + F_STRING, + ), ), ( - NonLogicalNewline, + Newline, 14..15, ), ( @@ -62,8 +67,13 @@ expression: "lex_invalid(source, Mode::Module)" 16..18, ), ( - Unknown, + String( + "", + ), 18..19, + TokenFlags( + UNCLOSED_STRING, + ), ), ( Newline, @@ -104,13 +114,22 @@ expression: "lex_invalid(source, Mode::Module)" 31..32, ), ( - Unknown, + InterpolatedStringMiddle( + "a", + ), 32..33, + TokenFlags( + F_STRING, + ), ), ( - NonLogicalNewline, + Newline, 33..34, ), + ( + Indent, + 34..42, + ), ( Name( Name("b"), @@ -118,9 +137,13 @@ expression: "lex_invalid(source, Mode::Module)" 42..43, ), ( - NonLogicalNewline, + Newline, 43..44, ), + ( + Dedent, + 44..44, + ), ( Rbrace, 44..45, @@ -132,8 +155,13 @@ expression: "lex_invalid(source, Mode::Module)" 45..47, ), ( - Unknown, + String( + "", + ), 47..48, + TokenFlags( + UNCLOSED_STRING, + ), ), ( Newline, diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__tstring_newline_format_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__tstring_newline_format_spec.snap index c4db37da4c..810ce6592e 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__tstring_newline_format_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__lexer__tests__tstring_newline_format_spec.snap @@ -44,11 +44,16 @@ expression: "lex_invalid(source, Mode::Module)" 12..13, ), ( - Unknown, + InterpolatedStringMiddle( + "d", + ), 13..14, + TokenFlags( + T_STRING, + ), ), ( - NonLogicalNewline, + Newline, 14..15, ), ( @@ -62,8 +67,13 @@ expression: "lex_invalid(source, Mode::Module)" 16..18, ), ( - Unknown, + String( + "", + ), 18..19, + TokenFlags( + UNCLOSED_STRING, + ), ), ( Newline, @@ -104,13 +114,22 @@ expression: "lex_invalid(source, Mode::Module)" 31..32, ), ( - Unknown, + InterpolatedStringMiddle( + "a", + ), 32..33, + TokenFlags( + T_STRING, + ), ), ( - NonLogicalNewline, + Newline, 33..34, ), + ( + Indent, + 34..42, + ), ( Name( Name("b"), @@ -118,9 +137,13 @@ expression: "lex_invalid(source, Mode::Module)" 42..43, ), ( - NonLogicalNewline, + Newline, 43..44, ), + ( + Dedent, + 44..44, + ), ( Rbrace, 44..45, @@ -132,8 +155,13 @@ expression: "lex_invalid(source, Mode::Module)" 45..47, ), ( - Unknown, + String( + "", + ), 47..48, + TokenFlags( + UNCLOSED_STRING, + ), ), ( Newline, diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__backspace_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__backspace_alias.snap index 9a734b8be4..c6c8e9cf85 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__backspace_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__backspace_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__bell_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__bell_alias.snap index 72fcda7d2a..5c6fd12129 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__bell_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__bell_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__carriage_return_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__carriage_return_alias.snap index 3f04c6f2d7..56bda12a56 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__carriage_return_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__carriage_return_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__character_tabulation_with_justification_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__character_tabulation_with_justification_alias.snap index 791588d794..783e8c736e 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__character_tabulation_with_justification_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__character_tabulation_with_justification_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__delete_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__delete_alias.snap index 2d53ec0f3d..68c017dfc7 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__delete_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__delete_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__dont_panic_on_8_in_octal_escape.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__dont_panic_on_8_in_octal_escape.snap index 546f94b9d9..5e04b08423 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__dont_panic_on_8_in_octal_escape.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__dont_panic_on_8_in_octal_escape.snap @@ -31,6 +31,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__double_quoted_byte.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__double_quoted_byte.snap index 687ce16e1d..0b61a51378 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__double_quoted_byte.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__double_quoted_byte.snap @@ -278,6 +278,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_alias.snap index 2cec840d0a..e271519126 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_char_in_byte_literal.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_char_in_byte_literal.snap index e0ad6cff1b..36ed21596e 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_char_in_byte_literal.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_char_in_byte_literal.snap @@ -32,6 +32,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_octet.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_octet.snap index 80aad9289c..f4f90563ab 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_octet.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__escape_octet.snap @@ -27,6 +27,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__form_feed_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__form_feed_alias.snap index 2067566074..a2121c46f8 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__form_feed_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__form_feed_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_constant_range.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_constant_range.snap index ed6a838d4e..a04a2f4118 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_constant_range.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_constant_range.snap @@ -78,6 +78,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_character.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_character.snap index 990c1d9dad..c356bd1c08 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_character.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_character.snap @@ -47,6 +47,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_newline.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_newline.snap index 7fbfd50e8e..998a2269ed 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_newline.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_escaped_newline.snap @@ -47,6 +47,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_line_continuation.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_line_continuation.snap index d950371db1..44540b29c3 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_line_continuation.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_line_continuation.snap @@ -49,6 +49,7 @@ expression: suite uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base.snap index 2955122167..e1a4f80cca 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base.snap @@ -45,6 +45,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base_more.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base_more.snap index dc882a8c57..a6617dd01c 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base_more.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_base_more.snap @@ -81,6 +81,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_format.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_format.snap index da86391550..861fc69c1c 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_format.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_parse_self_documenting_format.snap @@ -59,6 +59,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_unescaped_newline.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_unescaped_newline.snap index 42fcd75676..0e871901fc 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_unescaped_newline.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__fstring_unescaped_newline.snap @@ -47,6 +47,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__hts_alias.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__hts_alias.snap index cdf45c0397..392be7a93c 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__hts_alias.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__hts_alias.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_fstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_fstring.snap index f865593fb2..db0275af32 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_fstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_fstring.snap @@ -22,6 +22,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_tstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_tstring.snap index 98b1fa3461..bfb2d6218a 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_tstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_empty_tstring.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_1.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_1.snap index d539cb8deb..b5a212b25b 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_1.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_1.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -43,6 +44,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_2.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_2.snap index d539cb8deb..b5a212b25b 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_2.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_2.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -43,6 +44,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_3.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_3.snap index 1193adb2e4..c0997152c4 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_3.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_3.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -56,6 +57,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -72,6 +74,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_4.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_4.snap index 02419e8a00..e9634e20e3 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_4.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_f_string_concat_4.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -56,6 +57,7 @@ expression: suite quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -72,6 +74,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -84,6 +87,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring.snap index db72a345e0..7123b8431c 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring.snap @@ -64,6 +64,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap index 07654a0e22..7a9edf207f 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap @@ -61,6 +61,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_concatenation_string_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_concatenation_string_spec.snap index 1d80035515..7bfb2a5b22 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_concatenation_string_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_concatenation_string_spec.snap @@ -57,6 +57,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -67,6 +68,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -91,6 +93,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_spec.snap index 3ed5dbd04b..f938542df6 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_spec.snap @@ -64,6 +64,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_string_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_string_spec.snap index 68960eab25..66f2aecbec 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_string_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_nested_string_spec.snap @@ -55,6 +55,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -76,6 +77,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap index f3fa222a17..3ab964282e 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap @@ -61,6 +61,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_nested_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_nested_spec.snap index 2f9be02104..b9a2229626 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_nested_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_nested_spec.snap @@ -54,6 +54,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_prec_space.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_prec_space.snap index e53098fdec..43ce4660a4 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_prec_space.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_prec_space.snap @@ -45,6 +45,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_trailing_space.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_trailing_space.snap index 7de32a7c0c..766de86c5f 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_trailing_space.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_self_doc_trailing_space.snap @@ -45,6 +45,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_yield_expr.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_yield_expr.snap index 3154dbeff5..1f59b2a5ba 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_yield_expr.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_yield_expr.snap @@ -39,6 +39,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_concat.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_concat.snap index 20b0be9eab..8b7e266d6d 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_concat.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_concat.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -33,6 +34,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_triple_quotes_with_kind.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_triple_quotes_with_kind.snap index e37ba3eaf0..bbacfc3e4a 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_triple_quotes_with_kind.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_string_triple_quotes_with_kind.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Single, prefix: Unicode, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring.snap index 64cb321009..67b4c41365 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring.snap @@ -63,6 +63,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap index af2754bea4..c7baa8a513 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap @@ -60,6 +60,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_concatenation_string_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_concatenation_string_spec.snap index bfef44ca1a..93cfa48dd1 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_concatenation_string_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_concatenation_string_spec.snap @@ -56,6 +56,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -66,6 +67,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -90,6 +92,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_spec.snap index 2432a03fea..70f42c3139 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_spec.snap @@ -63,6 +63,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_string_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_string_spec.snap index 4d167f6391..38b4e902ba 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_string_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_nested_string_spec.snap @@ -54,6 +54,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -75,6 +76,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap index cda9ac6385..bf1180fe07 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap @@ -60,6 +60,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_nested_spec.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_nested_spec.snap index 607db4621e..32d1997a5a 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_nested_spec.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_nested_spec.snap @@ -53,6 +53,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_prec_space.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_prec_space.snap index 82a3532343..102f40910a 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_prec_space.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_prec_space.snap @@ -44,6 +44,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_trailing_space.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_trailing_space.snap index 5b44cbb07e..24b1b36cd8 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_trailing_space.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_self_doc_trailing_space.snap @@ -44,6 +44,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_yield_expr.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_yield_expr.snap index 745e1afdf4..b16d33cefb 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_yield_expr.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_yield_expr.snap @@ -38,6 +38,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_1.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_1.snap index bfadf93148..ac951c2811 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_1.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_1.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -43,6 +44,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_2.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_2.snap index 31dcb6c17d..21f69ed5ed 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_2.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_f_string_concat_2.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -43,6 +44,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -55,6 +57,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_1.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_1.snap index 8a8b31c488..6c75165710 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_1.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_1.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -33,6 +34,7 @@ expression: suite quote_style: Single, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ], diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_2.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_2.snap index 6dd96bc445..aca2fefc21 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_2.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_u_string_concat_2.snap @@ -23,6 +23,7 @@ expression: suite quote_style: Single, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -33,6 +34,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_1.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_1.snap index d90d77f669..68e5a30fee 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_1.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_1.snap @@ -28,6 +28,7 @@ expression: suite uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_2.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_2.snap index 9dc2e32ae2..76ddf8520d 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_2.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_byte_literal_2.snap @@ -26,6 +26,7 @@ expression: suite uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_fstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_fstring.snap index 2454031212..eb8b1a3e6d 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_fstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_fstring.snap @@ -42,6 +42,7 @@ expression: suite uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_tstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_tstring.snap index 41f1bac3ab..b97792d742 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_tstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__raw_tstring.snap @@ -41,6 +41,7 @@ expression: suite uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__single_quoted_byte.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__single_quoted_byte.snap index b156356ac7..b919928dc5 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__single_quoted_byte.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__single_quoted_byte.snap @@ -278,6 +278,7 @@ expression: suite quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_mac_eol.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_mac_eol.snap index c2521fd8ac..4352fb34d4 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_mac_eol.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_mac_eol.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_unix_eol.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_unix_eol.snap index c2521fd8ac..4352fb34d4 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_unix_eol.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_unix_eol.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_windows_eol.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_windows_eol.snap index b66b5c3738..58ff7730d8 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_windows_eol.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__string_parser_escaped_windows_eol.snap @@ -21,6 +21,7 @@ expression: suite quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_fstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_fstring.snap index 3ec5fbae97..8877e5956f 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_fstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_fstring.snap @@ -42,6 +42,7 @@ expression: suite uppercase_r: false, }, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_tstring.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_tstring.snap index bdf91f88e5..4cf02baf48 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_tstring.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__triple_quoted_raw_tstring.snap @@ -41,6 +41,7 @@ expression: suite uppercase_r: false, }, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_constant_range.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_constant_range.snap index 7460cec091..f8825a1412 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_constant_range.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_constant_range.snap @@ -77,6 +77,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_character.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_character.snap index 44d783177f..a10e0fc996 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_character.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_character.snap @@ -46,6 +46,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_newline.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_newline.snap index d17834c3b1..071d5af2c4 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_newline.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_escaped_newline.snap @@ -46,6 +46,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_line_continuation.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_line_continuation.snap index 7a142da726..ae7da0c069 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_line_continuation.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_line_continuation.snap @@ -48,6 +48,7 @@ expression: suite uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base.snap index 81b079ca5e..5169493e0a 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base.snap @@ -44,6 +44,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base_more.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base_more.snap index 4af3383006..e9b8a959fc 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base_more.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_base_more.snap @@ -80,6 +80,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_format.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_format.snap index d8970fbf3a..18ce2cfc63 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_format.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_parse_self_documenting_format.snap @@ -58,6 +58,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_unescaped_newline.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_unescaped_newline.snap index 19db8fe2e7..2c5a1b47c4 100644 --- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_unescaped_newline.snap +++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__tstring_unescaped_newline.snap @@ -46,6 +46,7 @@ expression: suite quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/src/token.rs b/crates/ruff_python_parser/src/token.rs index 240e015a3b..1d9461a722 100644 --- a/crates/ruff_python_parser/src/token.rs +++ b/crates/ruff_python_parser/src/token.rs @@ -729,7 +729,7 @@ impl fmt::Display for TokenKind { bitflags! { #[derive(Clone, Copy, Debug, PartialEq, Eq)] - pub(crate) struct TokenFlags: u8 { + pub(crate) struct TokenFlags: u16 { /// The token is a string with double quotes (`"`). const DOUBLE_QUOTES = 1 << 0; /// The token is a triple-quoted string i.e., it starts and ends with three consecutive @@ -748,9 +748,12 @@ bitflags! { const RAW_STRING_LOWERCASE = 1 << 6; /// The token is a raw string and the prefix character is in uppercase. const RAW_STRING_UPPERCASE = 1 << 7; + /// String without matching closing quote(s) + const UNCLOSED_STRING = 1 << 8; /// The token is a raw string i.e., prefixed with `r` or `R` const RAW_STRING = Self::RAW_STRING_LOWERCASE.bits() | Self::RAW_STRING_UPPERCASE.bits(); + } } @@ -808,6 +811,10 @@ impl StringFlags for TokenFlags { AnyStringPrefix::Regular(StringLiteralPrefix::Empty) } } + + fn is_unclosed(self) -> bool { + self.intersects(TokenFlags::UNCLOSED_STRING) + } } impl TokenFlags { diff --git a/crates/ruff_python_parser/src/token_source.rs b/crates/ruff_python_parser/src/token_source.rs index 26088c7a12..f24fb4771f 100644 --- a/crates/ruff_python_parser/src/token_source.rs +++ b/crates/ruff_python_parser/src/token_source.rs @@ -3,6 +3,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::Mode; use crate::error::LexicalError; use crate::lexer::{Lexer, LexerCheckpoint}; +use crate::string::InterpolatedStringKind; use crate::token::{Token, TokenFlags, TokenKind, TokenValue}; /// Token source for the parser that skips over any trivia tokens. @@ -88,6 +89,18 @@ impl<'src> TokenSource<'src> { } } + pub(crate) fn re_lex_string_token_in_interpolation_element( + &mut self, + kind: InterpolatedStringKind, + ) { + self.lexer + .re_lex_string_token_in_interpolation_element(kind); + } + + pub(crate) fn re_lex_raw_string_in_format_spec(&mut self) { + self.lexer.re_lex_raw_string_in_format_spec(); + } + /// Returns the next non-trivia token without consuming it. /// /// Use [`peek2`] to get the next two tokens. diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@ann_assign_stmt_invalid_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@ann_assign_stmt_invalid_target.py.snap index 74e36dcbca..1e58991d17 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@ann_assign_stmt_invalid_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@ann_assign_stmt_invalid_target.py.snap @@ -28,6 +28,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -57,6 +58,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -114,6 +116,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assign_stmt_invalid_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assign_stmt_invalid_target.py.snap index 1085de726c..7516e2d842 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assign_stmt_invalid_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assign_stmt_invalid_target.py.snap @@ -144,6 +144,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -164,6 +165,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -194,6 +196,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -214,6 +217,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@aug_assign_stmt_invalid_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@aug_assign_stmt_invalid_target.py.snap index 731d9a3996..0d1311ca25 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@aug_assign_stmt_invalid_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@aug_assign_stmt_invalid_target.py.snap @@ -53,6 +53,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -74,6 +75,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@debug_shadow_with.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@debug_shadow_with.py.snap index 0f9a6c4b23..12c0003499 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@debug_shadow_with.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@debug_shadow_with.py.snap @@ -49,6 +49,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_class_attr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_class_attr.py.snap index 4a2eefcbc8..63067a4b3c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_class_attr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_class_attr.py.snap @@ -229,6 +229,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -249,6 +250,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -396,6 +398,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -416,6 +419,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -601,6 +605,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -621,6 +626,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_key.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_key.py.snap index 34974c55b0..aec92ae959 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_key.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@duplicate_match_key.py.snap @@ -45,6 +45,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -65,6 +66,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -147,6 +149,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -169,6 +172,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -663,6 +667,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, ), @@ -683,6 +688,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, ), @@ -763,6 +769,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -783,6 +790,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -803,6 +811,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -907,6 +916,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -936,6 +946,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1051,6 +1062,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1071,6 +1083,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1203,6 +1216,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1223,6 +1237,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1407,6 +1422,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1427,6 +1443,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap index 6c72721b03..6b8ee79f05 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap @@ -105,6 +105,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap index 80f0ed10de..480b1586fb 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap @@ -140,6 +140,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap index 08cad0598c..1ccec2050b 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap @@ -140,6 +140,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_conversion_follows_exclamation.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_conversion_follows_exclamation.py.snap index 0deb7be86a..63c3670334 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_conversion_follows_exclamation.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_conversion_follows_exclamation.py.snap @@ -47,6 +47,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -92,6 +93,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -137,6 +139,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_empty_expression.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_empty_expression.py.snap index b336a9531b..f18251146b 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_empty_expression.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_empty_expression.py.snap @@ -47,6 +47,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -93,6 +94,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_name_tok.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_name_tok.py.snap index acb9c88e02..a5603a2dd3 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_name_tok.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_name_tok.py.snap @@ -47,6 +47,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_other_tok.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_other_tok.py.snap index 28624a678e..f69dee9e5c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_other_tok.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_conversion_flag_other_tok.py.snap @@ -47,6 +47,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -93,6 +94,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_starred_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_starred_expr.py.snap index a6920bde70..20091bbe02 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_starred_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_invalid_starred_expr.py.snap @@ -54,6 +54,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -124,6 +125,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -185,6 +187,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_lambda_without_parentheses.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_lambda_without_parentheses.py.snap index 2c4c6ee355..b7da154352 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_lambda_without_parentheses.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_lambda_without_parentheses.py.snap @@ -87,6 +87,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace.py.snap index 9f85f5551d..c8b75ce3f9 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace.py.snap @@ -47,6 +47,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -73,7 +74,7 @@ Module( elements: [ Interpolation( InterpolatedElement { - range: 7..14, + range: 7..13, node_index: NodeIndex(None), expression: Name( ExprName { @@ -84,7 +85,7 @@ Module( }, ), debug_text: None, - conversion: None, + conversion: Repr, format_spec: None, }, ), @@ -93,6 +94,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -144,6 +146,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -156,75 +159,91 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 24..37, + range: 24..28, value: FString( ExprFString { node_index: NodeIndex(None), - range: 24..37, + range: 24..28, value: FStringValue { - inner: Concatenated( - [ - FString( - FString { - range: 24..28, - node_index: NodeIndex(None), - elements: [ - Interpolation( - InterpolatedElement { - range: 26..27, - node_index: NodeIndex(None), - expression: Name( - ExprName { - node_index: NodeIndex(None), - range: 27..27, - id: Name(""), - ctx: Invalid, - }, - ), - debug_text: None, - conversion: None, - format_spec: None, - }, - ), - ], - flags: FStringFlags { - quote_style: Double, - prefix: Regular, - triple_quoted: false, - }, + inner: Single( + FString( + FString { + range: 24..28, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 26..27, + node_index: NodeIndex(None), + expression: Name( + ExprName { + node_index: NodeIndex(None), + range: 27..27, + id: Name(""), + ctx: Invalid, + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, }, - ), - FString( - FString { - range: 29..37, - node_index: NodeIndex(None), - elements: [ - Interpolation( - InterpolatedElement { - range: 33..34, - node_index: NodeIndex(None), - expression: Name( - ExprName { - node_index: NodeIndex(None), - range: 34..34, - id: Name(""), - ctx: Invalid, - }, - ), - debug_text: None, - conversion: None, - format_spec: None, - }, - ), - ], - flags: FStringFlags { - quote_style: Double, - prefix: Regular, - triple_quoted: true, - }, + }, + ), + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 29..37, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 29..37, + value: FStringValue { + inner: Single( + FString( + FString { + range: 29..37, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 33..34, + node_index: NodeIndex(None), + expression: Name( + ExprName { + node_index: NodeIndex(None), + range: 34..34, + id: Name(""), + ctx: Invalid, + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: true, + unclosed: false, }, - ), - ], + }, + ), ), }, }, @@ -239,23 +258,7 @@ Module( | 1 | f"{" - | ^ Syntax Error: missing closing quote in string literal -2 | f"{foo!r" -3 | f"{foo=" - | - - - | -1 | f"{" - | ^ Syntax Error: f-string: unterminated string -2 | f"{foo!r" -3 | f"{foo=" - | - - - | -1 | f"{" - | ^ Syntax Error: f-string: unterminated string + | ^ Syntax Error: Expected an expression 2 | f"{foo!r" 3 | f"{foo=" | @@ -264,25 +267,7 @@ Module( | 1 | f"{" 2 | f"{foo!r" - | ^^ Syntax Error: missing closing quote in string literal -3 | f"{foo=" -4 | f"{" - | - - - | -1 | f"{" -2 | f"{foo!r" - | ^ Syntax Error: f-string: unterminated string -3 | f"{foo=" -4 | f"{" - | - - - | -1 | f"{" -2 | f"{foo!r" - | ^ Syntax Error: f-string: unterminated string + | ^ Syntax Error: f-string: expecting '}' 3 | f"{foo=" 4 | f"{" | @@ -292,46 +277,7 @@ Module( 1 | f"{" 2 | f"{foo!r" 3 | f"{foo=" - | ^^ Syntax Error: f-string: expecting '}' -4 | f"{" -5 | f"""{""" - | - - - | -1 | f"{" -2 | f"{foo!r" - | ^ Syntax Error: Expected FStringEnd, found Unknown -3 | f"{foo=" -4 | f"{" - | - - - | -1 | f"{" -2 | f"{foo!r" -3 | f"{foo=" - | ^ Syntax Error: missing closing quote in string literal -4 | f"{" -5 | f"""{""" - | - - - | -1 | f"{" -2 | f"{foo!r" -3 | f"{foo=" - | ^ Syntax Error: f-string: unterminated string -4 | f"{" -5 | f"""{""" - | - - - | -1 | f"{" -2 | f"{foo!r" -3 | f"{foo=" - | ^ Syntax Error: f-string: unterminated string + | ^ Syntax Error: f-string: expecting '}' 4 | f"{" 5 | f"""{""" | @@ -341,36 +287,14 @@ Module( 2 | f"{foo!r" 3 | f"{foo=" 4 | f"{" - | ^ Syntax Error: missing closing quote in string literal + | ^ Syntax Error: Expected an expression 5 | f"""{""" | - | -3 | f"{foo=" -4 | f"{" -5 | f"""{""" - | ^^^^ Syntax Error: Expected FStringEnd, found FStringStart - | - - | 3 | f"{foo=" 4 | f"{" 5 | f"""{""" | ^^^ Syntax Error: Expected an expression | - - - | -4 | f"{" -5 | f"""{""" - | ^ Syntax Error: unexpected EOF while parsing - | - - - | -4 | f"{" -5 | f"""{""" - | ^ Syntax Error: f-string: unterminated string - | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace_in_format_spec.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace_in_format_spec.py.snap index 796a9745ea..cf843119c2 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace_in_format_spec.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@f_string_unclosed_lbrace_in_format_spec.py.snap @@ -60,6 +60,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -127,6 +128,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap index 2ee9ae9293..04caa94916 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap @@ -68,6 +68,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -388,6 +389,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string.py.snap index a883b9e411..7dd649dafd 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string.py.snap @@ -13,22 +13,39 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 0..7, + range: 0..14, value: StringLiteral( ExprStringLiteral { node_index: NodeIndex(None), - range: 0..7, + range: 0..14, value: StringLiteralValue { - inner: Single( - StringLiteral { - range: 0..7, - node_index: NodeIndex(None), - value: "hello", - flags: StringLiteralFlags { - quote_style: Single, - prefix: Empty, - triple_quoted: false, - }, + inner: Concatenated( + ConcatenatedStringLiteral { + strings: [ + StringLiteral { + range: 0..7, + node_index: NodeIndex(None), + value: "hello", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 8..14, + node_index: NodeIndex(None), + value: "world", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: true, + }, + }, + ], + value: "helloworld", }, ), }, @@ -87,6 +104,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -124,6 +142,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -179,15 +198,6 @@ Module( | - | -1 | 'hello' 'world - | ^ Syntax Error: Expected a statement -2 | 1 + 1 -3 | 'hello' f'world {x} -4 | 2 + 2 - | - - | 1 | 'hello' 'world 2 | 1 + 1 diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string_multiline.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string_multiline.py.snap index 58734d3b8f..6d6ae6f83c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string_multiline.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@implicitly_concatenated_unterminated_string_multiline.py.snap @@ -30,6 +30,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -67,6 +68,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -111,59 +113,62 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 38..51, - value: StringLiteral( - ExprStringLiteral { - node_index: NodeIndex(None), - range: 44..51, - value: StringLiteralValue { - inner: Single( - StringLiteral { - range: 44..51, - node_index: NodeIndex(None), - value: "first", - flags: StringLiteralFlags { - quote_style: Single, - prefix: Empty, - triple_quoted: false, - }, - }, - ), - }, - }, - ), - }, - ), - Expr( - StmtExpr { - node_index: NodeIndex(None), - range: 68..76, + range: 38..78, value: FString( ExprFString { node_index: NodeIndex(None), - range: 68..76, + range: 44..76, value: FStringValue { - inner: Single( - FString( - FString { - range: 68..76, - node_index: NodeIndex(None), - elements: [ - Literal( - InterpolatedStringLiteralElement { - range: 70..75, - node_index: NodeIndex(None), - value: "third", - }, - ), - ], - flags: FStringFlags { - quote_style: Single, - prefix: Regular, - triple_quoted: false, + inner: Concatenated( + [ + Literal( + StringLiteral { + range: 44..51, + node_index: NodeIndex(None), + value: "first", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, }, - }, - ), + ), + Literal( + StringLiteral { + range: 56..63, + node_index: NodeIndex(None), + value: "second", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: true, + }, + }, + ), + FString( + FString { + range: 68..76, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 70..75, + node_index: NodeIndex(None), + value: "third", + }, + ), + ], + flags: FStringFlags { + quote_style: Single, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], ), }, }, @@ -246,21 +251,3 @@ Module( 9 | f'third' 10 | ) | - - - | - 8 | 'second - 9 | f'third' -10 | ) - | ^ Syntax Error: Expected a statement -11 | 2 + 2 - | - - - | - 8 | 'second - 9 | f'third' -10 | ) - | ^ Syntax Error: Expected a statement -11 | 2 + 2 - | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_byte_literal.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_byte_literal.py.snap index 635e860d01..a330b1ce96 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_byte_literal.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_byte_literal.py.snap @@ -28,6 +28,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -56,6 +57,7 @@ Module( uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), @@ -82,6 +84,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_del_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_del_target.py.snap index f214888ea3..5834d7311a 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_del_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_del_target.py.snap @@ -68,6 +68,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -115,6 +116,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -135,6 +137,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -204,6 +207,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_fstring_literal_element.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_fstring_literal_element.py.snap index 88875b56f9..0d2f651c21 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_fstring_literal_element.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_fstring_literal_element.py.snap @@ -37,6 +37,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -73,6 +74,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_string_literal.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_string_literal.py.snap index 0dbe2ee507..72cdf3cf6a 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_string_literal.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_string_literal.py.snap @@ -28,6 +28,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -54,6 +55,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_bytes_and_non_bytes_literals.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_bytes_and_non_bytes_literals.py.snap index f79a6c0478..6ac75db9d5 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_bytes_and_non_bytes_literals.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@mixed_bytes_and_non_bytes_literals.py.snap @@ -30,6 +30,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -40,6 +41,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -79,6 +81,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -91,6 +94,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -121,6 +125,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -141,6 +146,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -153,6 +159,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap index 85f48fc80f..3a8644b3a8 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap @@ -85,6 +85,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -118,6 +119,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep701_f_string_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep701_f_string_py311.py.snap index 0361caff9b..0551f2e991 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep701_f_string_py311.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep701_f_string_py311.py.snap @@ -62,6 +62,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -81,6 +82,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -131,6 +133,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -172,6 +175,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -233,6 +237,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -252,6 +257,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -390,6 +396,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -407,6 +414,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -424,6 +432,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -441,6 +450,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -458,6 +468,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -475,6 +486,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -532,6 +544,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -555,6 +568,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -579,6 +593,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -639,6 +654,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -700,6 +716,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -717,6 +734,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -767,6 +785,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -804,6 +823,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -824,6 +844,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -844,6 +865,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -869,6 +891,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lex_logical_token.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lex_logical_token.py.snap index 5864cb2b05..45a5b27c78 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lex_logical_token.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lex_logical_token.py.snap @@ -581,7 +581,7 @@ Module( test: Call( ExprCall { node_index: NodeIndex(None), - range: 890..905, + range: 890..906, func: Name( ExprName { node_index: NodeIndex(None), @@ -591,7 +591,7 @@ Module( }, ), arguments: Arguments { - range: 894..905, + range: 894..906, node_index: NodeIndex(None), args: [ FString( @@ -634,6 +634,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -713,11 +714,20 @@ Module( FString { range: 944..951, node_index: NodeIndex(None), - elements: [], + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 946..951, + node_index: NodeIndex(None), + value: "hello", + }, + ), + ], flags: FStringFlags { quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -888,7 +898,7 @@ Module( | 49 | # F-strings uses normal list parsing, so test those as well 50 | if call(f"hello {x - | ^ Syntax Error: Expected FStringEnd, found Unknown + | ^ Syntax Error: Expected FStringEnd, found NonLogicalNewline 51 | def bar(): 52 | pass | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__fstring_format_spec_1.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__fstring_format_spec_1.py.snap index b14f005e9c..1e2ad65fcf 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__fstring_format_spec_1.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__fstring_format_spec_1.py.snap @@ -50,6 +50,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -80,6 +81,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -117,6 +119,33 @@ Module( ), }, ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 203..205, + value: StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 203..205, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 203..205, + node_index: NodeIndex(None), + value: "}", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: true, + }, + }, + ), + }, + }, + ), + }, + ), Expr( StmtExpr { node_index: NodeIndex(None), @@ -157,6 +186,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -169,7 +199,15 @@ Module( InterpolatedStringFormatSpec { range: 226..228, node_index: NodeIndex(None), - elements: [], + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 226..228, + node_index: NodeIndex(None), + value: "\\", + }, + ), + ], }, ), }, @@ -179,6 +217,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -206,6 +245,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -254,6 +294,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -284,6 +325,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -321,6 +363,33 @@ Module( ), }, ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 296..298, + value: StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 296..298, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 296..298, + node_index: NodeIndex(None), + value: "}", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + unclosed: true, + }, + }, + ), + }, + }, + ), + }, + ), ], }, ) @@ -363,16 +432,6 @@ Module( | - | -5 | f'middle {'string':\ -6 | 'format spec'} - | ^ Syntax Error: Expected a statement -7 | -8 | f'middle {'string':\\ -9 | 'format spec'} - | - - | 6 | 'format spec'} 7 | @@ -454,12 +513,5 @@ Module( | 11 | f'middle {'string':\\\ 12 | 'format spec'} - | ^^ Syntax Error: Got unexpected string - | - - - | -11 | f'middle {'string':\\\ -12 | 'format spec'} - | ^ Syntax Error: Expected a statement + | ^^ Syntax Error: missing closing quote in string literal | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_1.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_1.py.snap index 81e700a1d2..1a9af6dacc 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_1.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_1.py.snap @@ -54,6 +54,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: true, }, }, ), @@ -92,7 +93,7 @@ Module( 5 | f"""hello {x # comment | ___________________________^ 6 | | y = 1 - | |_____^ Syntax Error: Expected FStringEnd, found Unknown + | |_____^ Syntax Error: Expected FStringEnd, found FStringMiddle | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_2.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_2.py.snap index 599be8b78d..50bb114c7e 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_2.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_2.py.snap @@ -61,6 +61,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_3.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_3.py.snap index ce80ab0705..174ebceee4 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_3.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__triple_quoted_fstring_3.py.snap @@ -77,6 +77,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap index 6920a94c53..cdf7785101 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap @@ -382,6 +382,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -915,6 +916,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -996,6 +998,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1034,6 +1037,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1075,6 +1079,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap index 1ac04383c5..66bb515f10 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap @@ -289,6 +289,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -811,6 +812,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -891,6 +893,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -928,6 +931,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -968,6 +972,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_empty_expression.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_empty_expression.py.snap index e1c3d47bda..de21eac870 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_empty_expression.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_empty_expression.py.snap @@ -46,6 +46,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -90,6 +91,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_name_tok.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_name_tok.py.snap index 6b648bae65..b1b3890a11 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_name_tok.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_name_tok.py.snap @@ -46,6 +46,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_other_tok.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_other_tok.py.snap index 9c937acac3..ef9242f4f8 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_other_tok.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_conversion_flag_other_tok.py.snap @@ -46,6 +46,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -90,6 +91,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_starred_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_starred_expr.py.snap index 592c6eedce..d906c8c837 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_starred_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_invalid_starred_expr.py.snap @@ -53,6 +53,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -121,6 +122,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -180,6 +182,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_lambda_without_parentheses.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_lambda_without_parentheses.py.snap index d0d8abdfd3..0d23f0c0d2 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_lambda_without_parentheses.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_lambda_without_parentheses.py.snap @@ -86,6 +86,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace.py.snap index 4218d6845d..4ff0a7d78f 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace.py.snap @@ -46,6 +46,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -70,7 +71,7 @@ Module( elements: [ Interpolation( InterpolatedElement { - range: 51..58, + range: 51..57, node_index: NodeIndex(None), expression: Name( ExprName { @@ -81,7 +82,7 @@ Module( }, ), debug_text: None, - conversion: None, + conversion: Repr, format_spec: None, }, ), @@ -90,6 +91,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -139,6 +141,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -150,71 +153,87 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 68..81, + range: 68..72, value: TString( ExprTString { node_index: NodeIndex(None), - range: 68..81, + range: 68..72, value: TStringValue { - inner: Concatenated( - [ - TString { - range: 68..72, - node_index: NodeIndex(None), - elements: [ - Interpolation( - InterpolatedElement { - range: 70..71, - node_index: NodeIndex(None), - expression: Name( - ExprName { - node_index: NodeIndex(None), - range: 71..71, - id: Name(""), - ctx: Invalid, - }, - ), - debug_text: None, - conversion: None, - format_spec: None, - }, - ), - ], - flags: TStringFlags { - quote_style: Double, - prefix: Regular, - triple_quoted: false, - }, + inner: Single( + TString { + range: 68..72, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 70..71, + node_index: NodeIndex(None), + expression: Name( + ExprName { + node_index: NodeIndex(None), + range: 71..71, + id: Name(""), + ctx: Invalid, + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + }, + ), + ], + flags: TStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, }, - TString { - range: 73..81, - node_index: NodeIndex(None), - elements: [ - Interpolation( - InterpolatedElement { - range: 77..78, - node_index: NodeIndex(None), - expression: Name( - ExprName { - node_index: NodeIndex(None), - range: 78..78, - id: Name(""), - ctx: Invalid, - }, - ), - debug_text: None, - conversion: None, - format_spec: None, - }, - ), - ], - flags: TStringFlags { - quote_style: Double, - prefix: Regular, - triple_quoted: true, - }, + }, + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 73..81, + value: TString( + ExprTString { + node_index: NodeIndex(None), + range: 73..81, + value: TStringValue { + inner: Single( + TString { + range: 73..81, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 77..78, + node_index: NodeIndex(None), + expression: Name( + ExprName { + node_index: NodeIndex(None), + range: 78..78, + id: Name(""), + ctx: Invalid, + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + }, + ), + ], + flags: TStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: true, + unclosed: false, }, - ], + }, ), }, }, @@ -230,25 +249,7 @@ Module( | 1 | # parse_options: {"target-version": "3.14"} 2 | t"{" - | ^ Syntax Error: missing closing quote in string literal -3 | t"{foo!r" -4 | t"{foo=" - | - - - | -1 | # parse_options: {"target-version": "3.14"} -2 | t"{" - | ^ Syntax Error: t-string: unterminated string -3 | t"{foo!r" -4 | t"{foo=" - | - - - | -1 | # parse_options: {"target-version": "3.14"} -2 | t"{" - | ^ Syntax Error: t-string: unterminated string + | ^ Syntax Error: Expected an expression 3 | t"{foo!r" 4 | t"{foo=" | @@ -258,27 +259,7 @@ Module( 1 | # parse_options: {"target-version": "3.14"} 2 | t"{" 3 | t"{foo!r" - | ^^ Syntax Error: missing closing quote in string literal -4 | t"{foo=" -5 | t"{" - | - - - | -1 | # parse_options: {"target-version": "3.14"} -2 | t"{" -3 | t"{foo!r" - | ^ Syntax Error: t-string: unterminated string -4 | t"{foo=" -5 | t"{" - | - - - | -1 | # parse_options: {"target-version": "3.14"} -2 | t"{" -3 | t"{foo!r" - | ^ Syntax Error: t-string: unterminated string + | ^ Syntax Error: t-string: expecting '}' 4 | t"{foo=" 5 | t"{" | @@ -288,47 +269,7 @@ Module( 2 | t"{" 3 | t"{foo!r" 4 | t"{foo=" - | ^^ Syntax Error: t-string: expecting '}' -5 | t"{" -6 | t"""{""" - | - - - | -1 | # parse_options: {"target-version": "3.14"} -2 | t"{" -3 | t"{foo!r" - | ^ Syntax Error: Expected TStringEnd, found Unknown -4 | t"{foo=" -5 | t"{" - | - - - | -2 | t"{" -3 | t"{foo!r" -4 | t"{foo=" - | ^ Syntax Error: missing closing quote in string literal -5 | t"{" -6 | t"""{""" - | - - - | -2 | t"{" -3 | t"{foo!r" -4 | t"{foo=" - | ^ Syntax Error: t-string: unterminated string -5 | t"{" -6 | t"""{""" - | - - - | -2 | t"{" -3 | t"{foo!r" -4 | t"{foo=" - | ^ Syntax Error: t-string: unterminated string + | ^ Syntax Error: t-string: expecting '}' 5 | t"{" 6 | t"""{""" | @@ -338,36 +279,14 @@ Module( 3 | t"{foo!r" 4 | t"{foo=" 5 | t"{" - | ^ Syntax Error: missing closing quote in string literal + | ^ Syntax Error: Expected an expression 6 | t"""{""" | - | -4 | t"{foo=" -5 | t"{" -6 | t"""{""" - | ^^^^ Syntax Error: Expected TStringEnd, found TStringStart - | - - | 4 | t"{foo=" 5 | t"{" 6 | t"""{""" | ^^^ Syntax Error: Expected an expression | - - - | -5 | t"{" -6 | t"""{""" - | ^ Syntax Error: unexpected EOF while parsing - | - - - | -5 | t"{" -6 | t"""{""" - | ^ Syntax Error: t-string: unterminated string - | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace_in_format_spec.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace_in_format_spec.py.snap index 3a002658cc..bc20f6172c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace_in_format_spec.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@t_string_unclosed_lbrace_in_format_spec.py.snap @@ -59,6 +59,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -124,6 +125,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@template_strings_py313.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@template_strings_py313.py.snap index b631879119..1eed668827 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@template_strings_py313.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@template_strings_py313.py.snap @@ -46,6 +46,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -90,6 +91,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -124,6 +126,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unterminated_fstring_newline_recovery.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unterminated_fstring_newline_recovery.py.snap index 096671b850..0595f124f2 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unterminated_fstring_newline_recovery.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unterminated_fstring_newline_recovery.py.snap @@ -24,11 +24,20 @@ Module( FString { range: 0..7, node_index: NodeIndex(None), - elements: [], + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 2..7, + node_index: NodeIndex(None), + value: "hello", + }, + ), + ], flags: FStringFlags { quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -113,6 +122,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -203,6 +213,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -287,6 +298,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: true, }, }, ), @@ -374,9 +386,10 @@ Module( 1 | f"hello 2 | 1 + 1 3 | f"hello {x - | ^ Syntax Error: Expected FStringEnd, found Unknown + | ^ Syntax Error: Expected FStringEnd, found newline 4 | 2 + 2 5 | f"hello {x: +6 | 3 + 3 | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_eval_hack_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_eval_hack_py38.py.snap index d6d40d9536..fb3322b7a7 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_eval_hack_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_eval_hack_py38.py.snap @@ -49,6 +49,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__call.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__call.py.snap index 452c617b2e..5f931e1947 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__call.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__call.py.snap @@ -606,6 +606,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap index 8ff7bb7782..1eef98686d 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap @@ -136,6 +136,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -411,6 +412,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -474,6 +476,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -739,6 +742,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -760,6 +764,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -794,6 +799,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -815,6 +821,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -881,6 +888,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -902,6 +910,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap index cf7709bd98..5046336486 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap @@ -181,6 +181,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap index adc0020609..e696d1c34a 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap @@ -29,6 +29,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -57,6 +58,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -85,6 +87,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -113,6 +116,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -141,6 +145,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -183,6 +188,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -199,6 +205,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -245,6 +252,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -302,6 +310,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -375,6 +384,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -437,6 +447,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -465,6 +476,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -527,6 +539,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -555,6 +568,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -606,6 +620,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -671,6 +686,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -722,6 +738,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -793,6 +810,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -874,6 +892,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -905,6 +924,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -941,6 +961,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1000,6 +1021,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1012,6 +1034,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1070,6 +1093,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1111,6 +1135,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -1121,6 +1146,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -1227,6 +1253,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1263,6 +1290,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1323,6 +1351,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -1374,6 +1403,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1482,6 +1512,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1607,6 +1638,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1674,6 +1706,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1777,6 +1810,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1864,6 +1898,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1915,6 +1950,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1966,6 +2002,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2017,6 +2054,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2077,6 +2115,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2142,6 +2181,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2202,6 +2242,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2231,6 +2272,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2261,6 +2303,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2309,6 +2352,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2339,6 +2383,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2387,6 +2432,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2399,6 +2445,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2478,6 +2525,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2507,6 +2555,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2537,6 +2586,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2549,6 +2599,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2587,6 +2638,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2607,6 +2659,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2619,6 +2672,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2641,6 +2695,7 @@ Module( uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), @@ -2663,6 +2718,7 @@ Module( uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ), @@ -2693,6 +2749,7 @@ Module( quote_style: Double, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -2723,6 +2780,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2735,6 +2793,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2747,6 +2806,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2777,6 +2837,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2807,6 +2868,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2819,6 +2881,7 @@ Module( quote_style: Double, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -2831,6 +2894,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2861,6 +2925,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2891,6 +2956,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2903,6 +2969,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2915,6 +2982,7 @@ Module( quote_style: Double, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -2945,6 +3013,7 @@ Module( quote_style: Double, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -2989,6 +3058,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -3001,6 +3071,7 @@ Module( quote_style: Double, prefix: Unicode, triple_quoted: false, + unclosed: false, }, }, ), @@ -3013,6 +3084,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap index 6dcaa77c6b..da4fe251d0 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap @@ -616,6 +616,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -693,6 +694,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -748,6 +750,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__string.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__string.py.snap index e03b149d1a..0f376fd490 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__string.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__string.py.snap @@ -28,6 +28,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -54,6 +55,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -82,6 +84,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -92,6 +95,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -123,6 +127,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -133,6 +138,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -143,6 +149,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -172,6 +179,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, ), @@ -198,6 +206,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, ), @@ -226,6 +235,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, StringLiteral { @@ -236,6 +246,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: true, + unclosed: false, }, }, ], @@ -277,6 +288,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -310,6 +322,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, BytesLiteral { @@ -333,6 +346,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ], diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap index 2233e19dc5..0896a3ae47 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap @@ -28,6 +28,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -54,6 +55,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -80,6 +82,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -106,6 +109,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -132,6 +136,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -172,6 +177,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -188,6 +194,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -232,6 +239,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -287,6 +295,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -358,6 +367,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -418,6 +428,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -446,6 +457,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -506,6 +518,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -534,6 +547,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -583,6 +597,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -646,6 +661,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -695,6 +711,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -764,6 +781,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -842,6 +860,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -872,6 +891,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -914,6 +934,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -971,6 +992,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -989,6 +1011,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ], @@ -1046,6 +1069,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1087,6 +1111,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, StringLiteral { @@ -1097,6 +1122,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ], @@ -1202,6 +1228,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1236,6 +1263,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1294,6 +1322,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -1343,6 +1372,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1449,6 +1479,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1572,6 +1603,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1637,6 +1669,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1738,6 +1771,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1823,6 +1857,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1872,6 +1907,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1921,6 +1957,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1970,6 +2007,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2028,6 +2066,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2091,6 +2130,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2149,6 +2189,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2184,6 +2225,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2212,6 +2254,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ], @@ -2258,6 +2301,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2286,6 +2330,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ], @@ -2332,6 +2377,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2350,6 +2396,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ], @@ -2427,6 +2474,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2462,6 +2510,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2490,6 +2539,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2508,6 +2558,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ], @@ -2544,6 +2595,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2562,6 +2614,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2580,6 +2633,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2600,6 +2654,7 @@ Module( uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, TString { @@ -2620,6 +2675,7 @@ Module( uppercase_r: false, }, triple_quoted: false, + unclosed: false, }, }, ], @@ -2694,6 +2750,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2710,6 +2767,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2727,6 +2785,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@fstring_format_spec_terminator.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@fstring_format_spec_terminator.py.snap index 331e7ba86f..d3a6103ce0 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@fstring_format_spec_terminator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@fstring_format_spec_terminator.py.snap @@ -67,6 +67,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -141,6 +142,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_keyword_1.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_keyword_1.py.snap index 7c83386b60..e7e54f42bb 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_keyword_1.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_keyword_1.py.snap @@ -204,6 +204,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -285,6 +286,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@param_with_star_annotation_py310.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@param_with_star_annotation_py310.py.snap index d411155da7..3213ff7743 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@param_with_star_annotation_py310.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@param_with_star_annotation_py310.py.snap @@ -157,6 +157,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -255,6 +256,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap index 581693aa41..4b294d49ea 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap @@ -50,6 +50,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -66,6 +67,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -134,6 +136,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -162,6 +165,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -234,6 +238,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -250,6 +255,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -267,6 +273,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -284,6 +291,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -420,6 +428,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -444,6 +453,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -461,6 +471,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -485,6 +496,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -545,6 +557,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -581,6 +594,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py312.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py312.py.snap index 6fb97c216e..91ec761efa 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py312.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py312.py.snap @@ -62,6 +62,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -81,6 +82,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -131,6 +133,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -172,6 +175,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -233,6 +237,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -252,6 +257,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -390,6 +396,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -407,6 +414,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -424,6 +432,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -441,6 +450,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -458,6 +468,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -475,6 +486,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -532,6 +544,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -555,6 +568,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -579,6 +593,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -639,6 +654,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep750_t_string_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep750_t_string_py314.py.snap index 6c656364a8..0d5db2d14d 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep750_t_string_py314.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep750_t_string_py314.py.snap @@ -61,6 +61,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -80,6 +81,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -128,6 +130,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -169,6 +172,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -228,6 +232,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -247,6 +252,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -378,6 +384,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -394,6 +401,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -410,6 +418,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -426,6 +435,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -442,6 +452,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -458,6 +469,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -512,6 +524,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -535,6 +548,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), @@ -558,6 +572,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -616,6 +631,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap index f2ab2b6c7b..09e2f6d782 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@single_parenthesized_item_context_manager_py38.py.snap @@ -49,6 +49,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -127,6 +128,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap index 8e048cdfb7..5e948486b8 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap @@ -1131,6 +1131,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1214,6 +1215,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -2720,6 +2722,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2766,6 +2769,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap index b3cb2bc013..ae3131d0c1 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap @@ -237,6 +237,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assignment.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assignment.py.snap index 852bb10a83..7239c8b530 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assignment.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assignment.py.snap @@ -855,6 +855,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__class.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__class.py.snap index 981e0fe0de..29935b6485 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__class.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__class.py.snap @@ -423,6 +423,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__function.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__function.py.snap index e715f81366..0ecafb822d 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__function.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__function.py.snap @@ -3642,6 +3642,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap index f98896f399..e10babe037 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap @@ -593,6 +593,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -637,6 +638,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -980,6 +982,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1826,6 +1829,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2893,6 +2897,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -2954,6 +2959,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -3672,6 +3678,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -4333,6 +4340,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -4380,6 +4388,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -4486,6 +4495,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -4507,6 +4517,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -4540,6 +4551,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -5292,6 +5304,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -5337,6 +5350,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -5771,6 +5785,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -7745,6 +7760,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__try.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__try.py.snap index 962d435263..6aae2f4228 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__try.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__try.py.snap @@ -715,6 +715,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -835,6 +836,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -898,6 +900,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -1183,6 +1186,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1339,6 +1343,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -1394,6 +1399,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap index d12ea97256..608679f328 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap @@ -111,6 +111,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap index ca6b03c547..211ddf7e05 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap @@ -188,6 +188,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), @@ -237,6 +238,7 @@ Module( quote_style: Single, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@template_strings_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@template_strings_py314.py.snap index dda530ea46..e714e59451 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@template_strings_py314.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@template_strings_py314.py.snap @@ -46,6 +46,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -90,6 +91,7 @@ Module( quote_style: Single, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -124,6 +126,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: true, + unclosed: false, }, }, ), From 651f7963a7b5e0ce63e1edb2ea9c3a84e30f0034 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Wed, 15 Oct 2025 04:59:33 -0400 Subject: [PATCH 043/113] [ty] Add some completion ranking improvements (#20807) Co-authored-by: Micha Reiser Co-authored-by: Alex Waygood --- .github/workflows/ci.yaml | 2 +- crates/ty_completion_eval/README.md | 6 +- .../completion-evaluation-tasks.csv | 27 +- crates/ty_completion_eval/src/main.rs | 15 +- .../truth/fstring-completions/completion.toml | 2 + .../truth/fstring-completions/main.py | 3 + .../truth/fstring-completions/pyproject.toml | 5 + .../truth/fstring-completions/uv.lock | 8 + .../truth/none-completion/completion.toml | 2 + .../truth/none-completion/main.py | 1 + .../truth/none-completion/pyproject.toml | 5 + .../truth/none-completion/uv.lock | 8 + .../truth/tstring-completions/completion.toml | 2 + .../truth/tstring-completions/main.py | 3 + .../truth/tstring-completions/pyproject.toml | 5 + .../truth/tstring-completions/uv.lock | 8 + crates/ty_ide/src/completion.rs | 717 ++++++++++++++++-- crates/ty_ide/src/symbols.rs | 16 +- crates/ty_python_semantic/src/types.rs | 2 +- .../src/server/api/requests/completion.rs | 9 +- 20 files changed, 753 insertions(+), 93 deletions(-) create mode 100644 crates/ty_completion_eval/truth/fstring-completions/completion.toml create mode 100644 crates/ty_completion_eval/truth/fstring-completions/main.py create mode 100644 crates/ty_completion_eval/truth/fstring-completions/pyproject.toml create mode 100644 crates/ty_completion_eval/truth/fstring-completions/uv.lock create mode 100644 crates/ty_completion_eval/truth/none-completion/completion.toml create mode 100644 crates/ty_completion_eval/truth/none-completion/main.py create mode 100644 crates/ty_completion_eval/truth/none-completion/pyproject.toml create mode 100644 crates/ty_completion_eval/truth/none-completion/uv.lock create mode 100644 crates/ty_completion_eval/truth/tstring-completions/completion.toml create mode 100644 crates/ty_completion_eval/truth/tstring-completions/main.py create mode 100644 crates/ty_completion_eval/truth/tstring-completions/pyproject.toml create mode 100644 crates/ty_completion_eval/truth/tstring-completions/uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 78e22a12d0..d8606cd463 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -721,7 +721,7 @@ jobs: - name: "Install Rust toolchain" run: rustup show - name: "Run ty completion evaluation" - run: cargo run --release --package ty_completion_eval -- all --threshold 0.1 --tasks /tmp/completion-evaluation-tasks.csv + run: cargo run --release --package ty_completion_eval -- all --threshold 0.4 --tasks /tmp/completion-evaluation-tasks.csv - name: "Ensure there are no changes" run: diff ./crates/ty_completion_eval/completion-evaluation-tasks.csv /tmp/completion-evaluation-tasks.csv diff --git a/crates/ty_completion_eval/README.md b/crates/ty_completion_eval/README.md index 0927c354e4..748c6e9d0d 100644 --- a/crates/ty_completion_eval/README.md +++ b/crates/ty_completion_eval/README.md @@ -7,7 +7,7 @@ To run a full evaluation, run the `ty_completion_eval` crate with the `all` command from the root of this repository: ```console -cargo run --release --package ty_completion_eval -- all +cargo run --profile profiling --package ty_completion_eval -- all ``` The output should look like this: @@ -24,7 +24,7 @@ you can ask the evaluation to write CSV data that contains the rank of the expected answer in each completion request: ```console -cargo r -r -p ty_completion_eval -- all --tasks ./crates/ty_completion_eval/completion-evaluation-tasks.csv +cargo r --profile profiling -p ty_completion_eval -- all --tasks ./crates/ty_completion_eval/completion-evaluation-tasks.csv ``` To debug a _specific_ task and look at the actual results, use the `show-one` @@ -133,7 +133,7 @@ CI will also fail if the individual task results have changed. To make CI pass, you can just re-run the evaluation locally and commit the results: ```console -cargo r -r -p ty_completion_eval -- all --tasks ./crates/ty_completion_eval/completion-evaluation-tasks.csv +cargo r --profile profiling -p ty_completion_eval -- all --tasks ./crates/ty_completion_eval/completion-evaluation-tasks.csv ``` CI fails in this case because it would be best to scrutinize the differences here. diff --git a/crates/ty_completion_eval/completion-evaluation-tasks.csv b/crates/ty_completion_eval/completion-evaluation-tasks.csv index 03f14a8a66..00b612e217 100644 --- a/crates/ty_completion_eval/completion-evaluation-tasks.csv +++ b/crates/ty_completion_eval/completion-evaluation-tasks.csv @@ -1,17 +1,20 @@ name,file,index,rank +fstring-completions,main.py,0,1 higher-level-symbols-preferred,main.py,0, higher-level-symbols-preferred,main.py,1,1 -import-deprioritizes-dunder,main.py,0,195 -import-deprioritizes-sunder,main.py,0,195 -internal-typeshed-hidden,main.py,0,43 +import-deprioritizes-dunder,main.py,0,1 +import-deprioritizes-sunder,main.py,0,1 +internal-typeshed-hidden,main.py,0,4 +none-completion,main.py,0,11 numpy-array,main.py,0, -numpy-array,main.py,1,32 -object-attr-instance-methods,main.py,0,7 +numpy-array,main.py,1,1 +object-attr-instance-methods,main.py,0,1 object-attr-instance-methods,main.py,1,1 -raise-uses-base-exception,main.py,0,42 -scope-existing-over-new-import,main.py,0,495 -scope-prioritize-closer,main.py,0,152 -scope-simple-long-identifier,main.py,0,140 -ty-extensions-lower-stdlib,main.py,0,142 -type-var-typing-over-ast,main.py,0,65 -type-var-typing-over-ast,main.py,1,353 +raise-uses-base-exception,main.py,0,2 +scope-existing-over-new-import,main.py,0,474 +scope-prioritize-closer,main.py,0,2 +scope-simple-long-identifier,main.py,0,1 +tstring-completions,main.py,0,1 +ty-extensions-lower-stdlib,main.py,0,7 +type-var-typing-over-ast,main.py,0,3 +type-var-typing-over-ast,main.py,1,270 diff --git a/crates/ty_completion_eval/src/main.rs b/crates/ty_completion_eval/src/main.rs index 784a141de7..146041a278 100644 --- a/crates/ty_completion_eval/src/main.rs +++ b/crates/ty_completion_eval/src/main.rs @@ -15,6 +15,9 @@ use regex::bytes::Regex; use ruff_db::files::system_path_to_file; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; use ty_ide::Completion; +use ty_project::metadata::Options; +use ty_project::metadata::options::EnvironmentOptions; +use ty_project::metadata::value::RelativePathBuf; use ty_project::{ProjectDatabase, ProjectMetadata}; use ty_python_semantic::ModuleName; @@ -117,8 +120,8 @@ impl ShowOneCommand { && self .file_name .as_ref() - .is_some_and(|name| name == task.cursor_name()) - && self.index.is_some_and(|index| index == task.cursor.index) + .is_none_or(|name| name == task.cursor_name()) + && self.index.is_none_or(|index| index == task.cursor.index) } } @@ -278,6 +281,14 @@ impl Task { let system = OsSystem::new(project_path); let mut project_metadata = ProjectMetadata::discover(project_path, &system)?; + // Explicitly point ty to the .venv to avoid any set VIRTUAL_ENV variable to take precedence. + project_metadata.apply_options(Options { + environment: Some(EnvironmentOptions { + python: Some(RelativePathBuf::cli(".venv")), + ..EnvironmentOptions::default() + }), + ..Options::default() + }); project_metadata.apply_configuration_files(&system)?; let db = ProjectDatabase::new(project_metadata, system)?; Ok(Task { diff --git a/crates/ty_completion_eval/truth/fstring-completions/completion.toml b/crates/ty_completion_eval/truth/fstring-completions/completion.toml new file mode 100644 index 0000000000..1c3c4b8ea4 --- /dev/null +++ b/crates/ty_completion_eval/truth/fstring-completions/completion.toml @@ -0,0 +1,2 @@ +[settings] +auto-import = false diff --git a/crates/ty_completion_eval/truth/fstring-completions/main.py b/crates/ty_completion_eval/truth/fstring-completions/main.py new file mode 100644 index 0000000000..767245df1e --- /dev/null +++ b/crates/ty_completion_eval/truth/fstring-completions/main.py @@ -0,0 +1,3 @@ +zqzqzq_identifier = 1 + +print(f"{zqzqzq_}") diff --git a/crates/ty_completion_eval/truth/fstring-completions/pyproject.toml b/crates/ty_completion_eval/truth/fstring-completions/pyproject.toml new file mode 100644 index 0000000000..cd277d8097 --- /dev/null +++ b/crates/ty_completion_eval/truth/fstring-completions/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "test" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [] diff --git a/crates/ty_completion_eval/truth/fstring-completions/uv.lock b/crates/ty_completion_eval/truth/fstring-completions/uv.lock new file mode 100644 index 0000000000..a4937d10d3 --- /dev/null +++ b/crates/ty_completion_eval/truth/fstring-completions/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "test" +version = "0.1.0" +source = { virtual = "." } diff --git a/crates/ty_completion_eval/truth/none-completion/completion.toml b/crates/ty_completion_eval/truth/none-completion/completion.toml new file mode 100644 index 0000000000..1c3c4b8ea4 --- /dev/null +++ b/crates/ty_completion_eval/truth/none-completion/completion.toml @@ -0,0 +1,2 @@ +[settings] +auto-import = false diff --git a/crates/ty_completion_eval/truth/none-completion/main.py b/crates/ty_completion_eval/truth/none-completion/main.py new file mode 100644 index 0000000000..4f5674c1ee --- /dev/null +++ b/crates/ty_completion_eval/truth/none-completion/main.py @@ -0,0 +1 @@ +x = Non diff --git a/crates/ty_completion_eval/truth/none-completion/pyproject.toml b/crates/ty_completion_eval/truth/none-completion/pyproject.toml new file mode 100644 index 0000000000..cd277d8097 --- /dev/null +++ b/crates/ty_completion_eval/truth/none-completion/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "test" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [] diff --git a/crates/ty_completion_eval/truth/none-completion/uv.lock b/crates/ty_completion_eval/truth/none-completion/uv.lock new file mode 100644 index 0000000000..a4937d10d3 --- /dev/null +++ b/crates/ty_completion_eval/truth/none-completion/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "test" +version = "0.1.0" +source = { virtual = "." } diff --git a/crates/ty_completion_eval/truth/tstring-completions/completion.toml b/crates/ty_completion_eval/truth/tstring-completions/completion.toml new file mode 100644 index 0000000000..1c3c4b8ea4 --- /dev/null +++ b/crates/ty_completion_eval/truth/tstring-completions/completion.toml @@ -0,0 +1,2 @@ +[settings] +auto-import = false diff --git a/crates/ty_completion_eval/truth/tstring-completions/main.py b/crates/ty_completion_eval/truth/tstring-completions/main.py new file mode 100644 index 0000000000..01f65e4536 --- /dev/null +++ b/crates/ty_completion_eval/truth/tstring-completions/main.py @@ -0,0 +1,3 @@ +zqzqzq_identifier = 1 + +print(t"{zqzqzq_}") diff --git a/crates/ty_completion_eval/truth/tstring-completions/pyproject.toml b/crates/ty_completion_eval/truth/tstring-completions/pyproject.toml new file mode 100644 index 0000000000..cd277d8097 --- /dev/null +++ b/crates/ty_completion_eval/truth/tstring-completions/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "test" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [] diff --git a/crates/ty_completion_eval/truth/tstring-completions/uv.lock b/crates/ty_completion_eval/truth/tstring-completions/uv.lock new file mode 100644 index 0000000000..a4937d10d3 --- /dev/null +++ b/crates/ty_completion_eval/truth/tstring-completions/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "test" +version = "0.1.0" +source = { virtual = "." } diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 2cec00791e..e8405c863d 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -7,7 +7,7 @@ use ruff_diagnostics::Edit; use ruff_python_ast as ast; use ruff_python_ast::name::Name; use ruff_python_codegen::Stylist; -use ruff_python_parser::{Token, TokenAt, TokenKind}; +use ruff_python_parser::{Token, TokenAt, TokenKind, Tokens}; use ruff_text_size::{Ranged, TextRange, TextSize}; use ty_python_semantic::{ Completion as SemanticCompletion, ModuleName, NameKind, SemanticModel, @@ -18,6 +18,7 @@ use crate::docstring::Docstring; use crate::find_node::covering_node; use crate::goto::DefinitionsOrTargets; use crate::importer::{ImportRequest, Importer}; +use crate::symbols::QueryPattern; use crate::{Db, all_symbols}; #[derive(Clone, Debug)] @@ -206,6 +207,15 @@ pub fn completion<'db>( offset: TextSize, ) -> Vec> { let parsed = parsed_module(db, file).load(db); + if is_in_comment(&parsed, offset) || is_in_string(&parsed, offset) { + return vec![]; + } + + let typed = find_typed_text(db, file, &parsed, offset); + let typed_query = typed + .as_deref() + .map(QueryPattern::new) + .unwrap_or_else(QueryPattern::matches_all_symbols); let Some(target_token) = CompletionTargetTokens::find(&parsed, offset) else { return vec![]; @@ -235,12 +245,23 @@ pub fn completion<'db>( }; let mut completions: Vec> = semantic_completions .into_iter() + .filter(|c| typed_query.is_match_symbol_name(c.name.as_str())) .map(|c| Completion::from_semantic_completion(db, c)) .collect(); + if scoped.is_some() { + add_keyword_value_completions(db, &typed_query, &mut completions); + } if settings.auto_import { if let Some(scoped) = scoped { - add_unimported_completions(db, file, &parsed, scoped, &mut completions); + add_unimported_completions( + db, + file, + &parsed, + scoped, + typed.as_deref(), + &mut completions, + ); } } completions.sort_by(compare_suggestions); @@ -248,6 +269,37 @@ pub fn completion<'db>( completions } +/// Adds a subset of completions derived from keywords. +/// +/// Note that at present, these should only be added to "scoped" +/// completions. i.e., This will include `None`, `True`, `False`, etc. +fn add_keyword_value_completions<'db>( + db: &'db dyn Db, + query: &QueryPattern, + completions: &mut Vec>, +) { + let keywords = [ + ("None", Type::none(db)), + ("True", Type::BooleanLiteral(true)), + ("False", Type::BooleanLiteral(false)), + ]; + for (name, ty) in keywords { + if !query.is_match_symbol_name(name) { + continue; + } + completions.push(Completion { + name: ast::name::Name::new(name), + insert: None, + ty: Some(ty), + kind: None, + module_name: None, + import: None, + builtin: true, + documentation: None, + }); + } +} + /// Adds completions not in scope. /// /// `scoped` should be information about the identified scope @@ -260,9 +312,10 @@ fn add_unimported_completions<'db>( file: File, parsed: &ParsedModuleRef, scoped: ScopedTarget<'_>, + typed: Option<&str>, completions: &mut Vec>, ) { - let Some(typed) = scoped.typed else { + let Some(typed) = typed else { return; }; let source = source_text(db, file); @@ -356,7 +409,7 @@ impl<'t> CompletionTargetTokens<'t> { TokenAt::Single(tok) => tok.end(), TokenAt::Between(_, tok) => tok.start(), }; - let before = parsed.tokens().before(offset); + let before = tokens_start_before(parsed.tokens(), offset); Some( // Our strategy when it comes to `object.attribute` here is // to look for the `.` and then take the token immediately @@ -485,21 +538,13 @@ impl<'t> CompletionTargetTokens<'t> { } CompletionTargetTokens::Generic { token } => { let node = covering_node(parsed.syntax().into(), token.range()).node(); - let typed = match node { - ast::AnyNodeRef::ExprName(ast::ExprName { id, .. }) => { - let name = id.as_str(); - if name.is_empty() { None } else { Some(name) } - } - _ => None, - }; - Some(CompletionTargetAst::Scoped(ScopedTarget { node, typed })) + Some(CompletionTargetAst::Scoped(ScopedTarget { node })) } CompletionTargetTokens::Unknown => { let range = TextRange::empty(offset); let covering_node = covering_node(parsed.syntax().into(), range); Some(CompletionTargetAst::Scoped(ScopedTarget { node: covering_node.node(), - typed: None, })) } } @@ -561,11 +606,25 @@ struct ScopedTarget<'t> { /// The node with the smallest range that fully covers /// the token under the cursor. node: ast::AnyNodeRef<'t>, - /// The text that has been typed so far, if available. - /// - /// When not `None`, the typed text is guaranteed to be - /// non-empty. - typed: Option<&'t str>, +} + +/// Returns a slice of tokens that all start before or at the given +/// [`TextSize`] offset. +/// +/// If the given offset is between two tokens, the returned slice will end just +/// before the following token. In other words, if the offset is between the +/// end of previous token and start of next token, the returned slice will end +/// just before the next token. +/// +/// Unlike `Tokens::before`, this never panics. If `offset` is within a token's +/// range (including if it's at the very beginning), then that token will be +/// included in the slice returned. +fn tokens_start_before(tokens: &Tokens, offset: TextSize) -> &[Token] { + let idx = match tokens.binary_search_by(|token| token.start().cmp(&offset)) { + Ok(idx) => idx, + Err(idx) => idx, + }; + &tokens[..idx] } /// Returns a suffix of `tokens` corresponding to the `kinds` given. @@ -729,6 +788,57 @@ fn import_tokens(tokens: &[Token]) -> Option<(&Token, &Token)> { None } +/// Looks for the text typed immediately before the cursor offset +/// given. +/// +/// If there isn't any typed text or it could not otherwise be found, +/// then `None` is returned. +fn find_typed_text( + db: &dyn Db, + file: File, + parsed: &ParsedModuleRef, + offset: TextSize, +) -> Option { + let source = source_text(db, file); + let tokens = tokens_start_before(parsed.tokens(), offset); + let last = tokens.last()?; + if !matches!(last.kind(), TokenKind::Name) { + return None; + } + // This one's weird, but if the cursor is beyond + // what is in the closest `Name` token, then it's + // likely we can't infer anything about what has + // been typed. This likely means there is whitespace + // or something that isn't represented in the token + // stream. So just give up. + if last.end() < offset { + return None; + } + Some(source[last.range()].to_string()) +} + +/// Whether the given offset within the parsed module is within +/// a comment or not. +fn is_in_comment(parsed: &ParsedModuleRef, offset: TextSize) -> bool { + let tokens = tokens_start_before(parsed.tokens(), offset); + tokens.last().is_some_and(|t| t.kind().is_comment()) +} + +/// Returns true when the cursor at `offset` is positioned within +/// a string token (regular, f-string, t-string, etc). +/// +/// Note that this will return `false` when positioned within an +/// interpolation block in an f-string or a t-string. +fn is_in_string(parsed: &ParsedModuleRef, offset: TextSize) -> bool { + let tokens = tokens_start_before(parsed.tokens(), offset); + tokens.last().is_some_and(|t| { + matches!( + t.kind(), + TokenKind::String | TokenKind::FStringMiddle | TokenKind::TStringMiddle + ) + }) +} + /// Order completions lexicographically, with these exceptions: /// /// 1) A `_[^_]` prefix sorts last and @@ -1055,7 +1165,7 @@ g ", ); - assert_snapshot!(test.completions_without_builtins(), @"foo"); + assert_snapshot!(test.completions_without_builtins(), @""); } #[test] @@ -1493,10 +1603,8 @@ class Foo: ); assert_snapshot!(test.completions_without_builtins(), @r" - Foo bar frob - quux "); } @@ -1510,11 +1618,7 @@ class Foo: ", ); - assert_snapshot!(test.completions_without_builtins(), @r" - Foo - bar - quux - "); + assert_snapshot!(test.completions_without_builtins(), @"bar"); } #[test] @@ -1687,29 +1791,8 @@ quux.b assert_snapshot!(test.completions_without_builtins_with_types(), @r" bar :: Unknown | Literal[2] baz :: Unknown | Literal[3] - foo :: Unknown | Literal[1] - __annotations__ :: dict[str, Any] - __class__ :: type[Quux] - __delattr__ :: bound method Quux.__delattr__(name: str, /) -> None - __dict__ :: dict[str, Any] - __dir__ :: bound method Quux.__dir__() -> Iterable[str] - __doc__ :: str | None - __eq__ :: bound method Quux.__eq__(value: object, /) -> bool - __format__ :: bound method Quux.__format__(format_spec: str, /) -> str __getattribute__ :: bound method Quux.__getattribute__(name: str, /) -> Any - __getstate__ :: bound method Quux.__getstate__() -> object - __hash__ :: bound method Quux.__hash__() -> int - __init__ :: bound method Quux.__init__() -> Unknown __init_subclass__ :: bound method type[Quux].__init_subclass__() -> None - __module__ :: str - __ne__ :: bound method Quux.__ne__(value: object, /) -> bool - __new__ :: bound method Quux.__new__() -> Quux - __reduce__ :: bound method Quux.__reduce__() -> str | tuple[Any, ...] - __reduce_ex__ :: bound method Quux.__reduce_ex__(protocol: SupportsIndex, /) -> str | tuple[Any, ...] - __repr__ :: bound method Quux.__repr__() -> str - __setattr__ :: bound method Quux.__setattr__(name: str, value: Any, /) -> None - __sizeof__ :: bound method Quux.__sizeof__() -> int - __str__ :: bound method Quux.__str__() -> str __subclasshook__ :: bound method type[Quux].__subclasshook__(subclass: type, /) -> bool "); } @@ -2059,10 +2142,7 @@ bar(o ", ); - assert_snapshot!(test.completions_without_builtins(), @r" - bar - foo - "); + assert_snapshot!(test.completions_without_builtins(), @"foo"); } #[test] @@ -2097,8 +2177,6 @@ class C: ); assert_snapshot!(test.completions_without_builtins(), @r" - C - bar foo self "); @@ -2133,8 +2211,6 @@ class C: // that is only a method that can be called on // `self`. assert_snapshot!(test.completions_without_builtins(), @r" - C - bar foo self "); @@ -2179,7 +2255,7 @@ hidden_ assert_snapshot!( test.completions_without_builtins(), - @"", + @"", ); } @@ -2199,7 +2275,10 @@ if sys.platform == \"not-my-current-platform\": // TODO: ideally, `only_available_in_this_branch` should be available here, but we // currently make no effort to provide a good IDE experience within sections that // are unreachable - assert_snapshot!(test.completions_without_builtins(), @"sys"); + assert_snapshot!( + test.completions_without_builtins(), + @"", + ); } #[test] @@ -2785,17 +2864,7 @@ f = Foo() "#, ); - // TODO: This should not have any completions suggested for it. - // We do correctly avoid giving `object.attr` completions here, - // but we instead fall back to scope based completions. Since - // we're inside a string, we should avoid giving completions at - // all. - assert_snapshot!(test.completions_without_builtins(), @r" - Foo - bar - f - foo - "); + assert_snapshot!(test.completions_without_builtins(), @r""); } #[test] @@ -2818,6 +2887,26 @@ f"{f. test.assert_completions_include("method"); } + #[test] + fn string_dot_attr3() { + let test = cursor_test( + r#" +foo = 1 +bar = 2 + +class Foo: + def method(self): ... + +f = Foo() + +# T-string, this is an attribute access +t"{f. +"#, + ); + + test.assert_completions_include("method"); + } + #[test] fn no_panic_for_attribute_table_that_contains_subscript() { let test = cursor_test( @@ -3315,6 +3404,498 @@ from os. assert_eq!(completion.kind(&test.db), Some(CompletionKind::Struct)); } + #[test] + fn no_completions_in_comment() { + let test = cursor_test( + "\ +zqzqzq = 1 +# zqzq +", + ); + + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_double_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(\"zqzq\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(\"Foo.zqzq\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_incomplete_double_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(\"zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(\"Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_single_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print('zqzq') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print('Foo.zqzq') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_incomplete_single_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print('zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print('Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_double_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(\"\"\"zqzq\"\"\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(\"\"\"Foo.zqzq\"\"\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_incomplete_double_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(\"\"\"zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(\"\"\"Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_single_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print('''zqzq''') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print('''Foo.zqzq''') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_string_incomplete_single_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print('''zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print('''Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_double_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f\"zqzq\") + ", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f\"{Foo} and Foo.zqzq\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_incomplete_double_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f\"zqzq + ", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f\"{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_single_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f'zqzq') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f'{Foo} and Foo.zqzq') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_incomplete_single_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f'zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f'{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_double_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f\"\"\"zqzq\"\"\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f\"\"\"{Foo} and Foo.zqzq\"\"\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_incomplete_double_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f\"\"\"zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f\"\"\"{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_single_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f'''zqzq''') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f'''{Foo} and Foo.zqzq''') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_fstring_incomplete_single_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(f'''zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(f'''{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_double_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t\"zqzq\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t\"{Foo} and Foo.zqzq\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_incomplete_double_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t\"zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t\"{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_single_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t'zqzq') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t'{Foo} and Foo.zqzq') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_incomplete_single_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t'zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t'{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_double_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t\"\"\"zqzq\"\"\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t\"\"\"{Foo} and Foo.zqzq\"\"\") +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_incomplete_double_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t\"\"\"zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t\"\"\"{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_single_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t'''zqzq''') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t'''{Foo} and Foo.zqzq''') +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + + #[test] + fn no_completions_in_tstring_incomplete_single_triple_quote() { + let test = cursor_test( + "\ +zqzqzq = 1 +print(t'''zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + + let test = cursor_test( + "\ +class Foo: + zqzqzq = 1 +print(t'''{Foo} and Foo.zqzq +", + ); + assert_snapshot!(test.completions_without_builtins(), @""); + } + // NOTE: The methods below are getting somewhat ridiculous. // We should refactor this by converting to using a builder // to set different modes. ---AG diff --git a/crates/ty_ide/src/symbols.rs b/crates/ty_ide/src/symbols.rs index 39cc0c3aea..a5af219b98 100644 --- a/crates/ty_ide/src/symbols.rs +++ b/crates/ty_ide/src/symbols.rs @@ -44,17 +44,26 @@ impl QueryPattern { } } - fn is_match(&self, symbol: &SymbolInfo<'_>) -> bool { + /// Create a new query pattern that matches all symbols. + pub fn matches_all_symbols() -> QueryPattern { + QueryPattern { + re: None, + original: String::new(), + } + } + + fn is_match_symbol(&self, symbol: &SymbolInfo<'_>) -> bool { self.is_match_symbol_name(&symbol.name) } - fn is_match_symbol_name(&self, symbol_name: &str) -> bool { + pub fn is_match_symbol_name(&self, symbol_name: &str) -> bool { if let Some(ref re) = self.re { re.is_match(symbol_name) } else { // This is a degenerate case. The only way // we should get here is if the query string // was thousands (or more) characters long. + // ... or, if "typed" text could not be found. symbol_name.contains(&self.original) } } @@ -108,7 +117,8 @@ impl FlatSymbols { /// Returns a sequence of symbols that matches the given query. pub fn search(&self, query: &QueryPattern) -> impl Iterator)> { - self.iter().filter(|(_, symbol)| query.is_match(symbol)) + self.iter() + .filter(|(_, symbol)| query.is_match_symbol(symbol)) } /// Turns this flat sequence of symbols into a hierarchy of symbols. diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 7f9d182c69..777a2f2ee8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -6253,7 +6253,7 @@ impl<'db> Type<'db> { } /// The type `NoneType` / `None` - pub(crate) fn none(db: &'db dyn Db) -> Type<'db> { + pub fn none(db: &'db dyn Db) -> Type<'db> { KnownClass::NoneType.to_instance(db) } diff --git a/crates/ty_server/src/server/api/requests/completion.rs b/crates/ty_server/src/server/api/requests/completion.rs index 55b8b91074..35e1c3fd25 100644 --- a/crates/ty_server/src/server/api/requests/completion.rs +++ b/crates/ty_server/src/server/api/requests/completion.rs @@ -3,8 +3,8 @@ use std::time::Instant; use lsp_types::request::Completion; use lsp_types::{ - CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams, - CompletionResponse, Documentation, TextEdit, Url, + CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionList, + CompletionParams, CompletionResponse, Documentation, TextEdit, Url, }; use ruff_db::source::{line_index, source_text}; use ruff_source_file::OneIndexed; @@ -100,7 +100,10 @@ impl BackgroundDocumentRequestHandler for CompletionRequestHandler { }) .collect(); let len = items.len(); - let response = CompletionResponse::Array(items); + let response = CompletionResponse::List(CompletionList { + is_incomplete: true, + items, + }); tracing::debug!( "Completions request returned {len} suggestions in {elapsed:?}", elapsed = Instant::now().duration_since(start) From cafb96aa7ab8dd11622d4cde99538adc4ae19cd5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:13:32 +0200 Subject: [PATCH 044/113] [ty] Sync vendored typeshed stubs (#20876) Close and reopen this PR to trigger CI --------- Co-authored-by: typeshedbot <> Co-authored-by: David Peter --- crates/ty_ide/src/completion.rs | 16 +- crates/ty_ide/src/goto_type_definition.rs | 80 +++--- .../resources/mdtest/attributes.md | 2 +- .../resources/mdtest/binary/classes.md | 24 ++ .../resources/mdtest/call/methods.md | 6 +- ...it_diagno…_-_Basic_(f15db7dc447d0795).snap | 10 +- .../src/types/infer/builder.rs | 15 ++ .../vendor/typeshed/source_commit.txt | 2 +- .../vendor/typeshed/stdlib/_csv.pyi | 7 +- .../stdlib/_frozen_importlib_external.pyi | 10 +- .../typeshed/stdlib/_typeshed/__init__.pyi | 3 + .../vendor/typeshed/stdlib/ast.pyi | 51 ++-- .../vendor/typeshed/stdlib/builtins.pyi | 131 +++++----- .../typeshed/stdlib/ctypes/__init__.pyi | 4 +- .../vendor/typeshed/stdlib/html/parser.pyi | 13 +- .../vendor/typeshed/stdlib/importlib/abc.pyi | 6 +- .../stdlib/importlib/resources/__init__.pyi | 23 +- .../importlib/resources/_functional.pyi | 3 +- .../vendor/typeshed/stdlib/operator.pyi | 6 +- .../vendor/typeshed/stdlib/pdb.pyi | 12 + .../vendor/typeshed/stdlib/sysconfig.pyi | 21 +- .../typeshed/stdlib/tkinter/__init__.pyi | 233 ++++++++++++------ .../vendor/typeshed/stdlib/turtle.pyi | 24 +- .../vendor/typeshed/stdlib/types.pyi | 28 +-- .../vendor/typeshed/stdlib/typing.pyi | 13 +- .../typeshed/stdlib/typing_extensions.pyi | 2 +- .../typeshed/stdlib/xml/etree/ElementTree.pyi | 30 +-- 27 files changed, 487 insertions(+), 288 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index e8405c863d..2c57152248 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -1842,13 +1842,13 @@ C. __name__ :: str __ne__ :: def __ne__(self, value: object, /) -> bool __new__ :: def __new__(cls) -> Self@__new__ - __or__ :: bound method .__or__(value: Any, /) -> UnionType + __or__ :: bound method .__or__[Self](value: Any, /) -> UnionType | Self@__or__ __prepare__ :: bound method .__prepare__(name: str, bases: tuple[type, ...], /, **kwds: Any) -> MutableMapping[str, object] __qualname__ :: str __reduce__ :: def __reduce__(self) -> str | tuple[Any, ...] __reduce_ex__ :: def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...] __repr__ :: def __repr__(self) -> str - __ror__ :: bound method .__ror__(value: Any, /) -> UnionType + __ror__ :: bound method .__ror__[Self](value: Any, /) -> UnionType | Self@__ror__ __setattr__ :: def __setattr__(self, name: str, value: Any, /) -> None __sizeof__ :: def __sizeof__(self) -> int __str__ :: def __str__(self) -> str @@ -1910,13 +1910,13 @@ Meta. __mro__ :: tuple[, , ] __name__ :: str __ne__ :: def __ne__(self, value: object, /) -> bool - __or__ :: def __or__(self, value: Any, /) -> UnionType + __or__ :: def __or__[Self](self: Self@__or__, value: Any, /) -> UnionType | Self@__or__ __prepare__ :: bound method .__prepare__(name: str, bases: tuple[type, ...], /, **kwds: Any) -> MutableMapping[str, object] __qualname__ :: str __reduce__ :: def __reduce__(self) -> str | tuple[Any, ...] __reduce_ex__ :: def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...] __repr__ :: def __repr__(self) -> str - __ror__ :: def __ror__(self, value: Any, /) -> UnionType + __ror__ :: def __ror__[Self](self: Self@__ror__, value: Any, /) -> UnionType | Self@__ror__ __setattr__ :: def __setattr__(self, name: str, value: Any, /) -> None __sizeof__ :: def __sizeof__(self) -> int __str__ :: def __str__(self) -> str @@ -2019,13 +2019,13 @@ Quux. __name__ :: str __ne__ :: def __ne__(self, value: object, /) -> bool __new__ :: def __new__(cls) -> Self@__new__ - __or__ :: bound method .__or__(value: Any, /) -> UnionType + __or__ :: bound method .__or__[Self](value: Any, /) -> UnionType | Self@__or__ __prepare__ :: bound method .__prepare__(name: str, bases: tuple[type, ...], /, **kwds: Any) -> MutableMapping[str, object] __qualname__ :: str __reduce__ :: def __reduce__(self) -> str | tuple[Any, ...] __reduce_ex__ :: def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...] __repr__ :: def __repr__(self) -> str - __ror__ :: bound method .__ror__(value: Any, /) -> UnionType + __ror__ :: bound method .__ror__[Self](value: Any, /) -> UnionType | Self@__ror__ __setattr__ :: def __setattr__(self, name: str, value: Any, /) -> None __sizeof__ :: def __sizeof__(self) -> int __str__ :: def __str__(self) -> str @@ -2096,14 +2096,14 @@ Answer. __name__ :: str __ne__ :: def __ne__(self, value: object, /) -> bool __new__ :: def __new__(cls, value: object) -> Self@__new__ - __or__ :: bound method .__or__(value: Any, /) -> UnionType + __or__ :: bound method .__or__[Self](value: Any, /) -> UnionType | Self@__or__ __order__ :: str __prepare__ :: bound method .__prepare__(cls: str, bases: tuple[type, ...], **kwds: Any) -> _EnumDict __qualname__ :: str __reduce__ :: def __reduce__(self) -> str | tuple[Any, ...] __repr__ :: def __repr__(self) -> str __reversed__ :: bound method .__reversed__[_EnumMemberT]() -> Iterator[_EnumMemberT@__reversed__] - __ror__ :: bound method .__ror__(value: Any, /) -> UnionType + __ror__ :: bound method .__ror__[Self](value: Any, /) -> UnionType | Self@__ror__ __setattr__ :: def __setattr__(self, name: str, value: Any, /) -> None __sizeof__ :: def __sizeof__(self) -> int __str__ :: def __str__(self) -> str diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index c42f0d0d1a..f39e5dc4b6 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -199,13 +199,13 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:911:7 + --> stdlib/builtins.pyi:913:7 | - 910 | @disjoint_base - 911 | class str(Sequence[str]): + 912 | @disjoint_base + 913 | class str(Sequence[str]): | ^^^ - 912 | """str(object='') -> str - 913 | str(bytes_or_buffer[, encoding[, errors]]) -> str + 914 | """str(object='') -> str + 915 | str(bytes_or_buffer[, encoding[, errors]]) -> str | info: Source --> main.py:4:1 @@ -227,13 +227,13 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:911:7 + --> stdlib/builtins.pyi:913:7 | - 910 | @disjoint_base - 911 | class str(Sequence[str]): + 912 | @disjoint_base + 913 | class str(Sequence[str]): | ^^^ - 912 | """str(object='') -> str - 913 | str(bytes_or_buffer[, encoding[, errors]]) -> str + 914 | """str(object='') -> str + 915 | str(bytes_or_buffer[, encoding[, errors]]) -> str | info: Source --> main.py:2:10 @@ -324,13 +324,13 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:911:7 + --> stdlib/builtins.pyi:913:7 | - 910 | @disjoint_base - 911 | class str(Sequence[str]): + 912 | @disjoint_base + 913 | class str(Sequence[str]): | ^^^ - 912 | """str(object='') -> str - 913 | str(bytes_or_buffer[, encoding[, errors]]) -> str + 914 | """str(object='') -> str + 915 | str(bytes_or_buffer[, encoding[, errors]]) -> str | info: Source --> main.py:4:6 @@ -358,13 +358,13 @@ mod tests { // is an int. Navigating to `str` would match pyright's behavior. assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:344:7 + --> stdlib/builtins.pyi:346:7 | - 343 | @disjoint_base - 344 | class int: + 345 | @disjoint_base + 346 | class int: | ^^^ - 345 | """int([x]) -> integer - 346 | int(x, base=10) -> integer + 347 | """int([x]) -> integer + 348 | int(x, base=10) -> integer | info: Source --> main.py:4:6 @@ -391,13 +391,13 @@ f(**kwargs) assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:2916:7 + --> stdlib/builtins.pyi:2918:7 | - 2915 | @disjoint_base - 2916 | class dict(MutableMapping[_KT, _VT]): + 2917 | @disjoint_base + 2918 | class dict(MutableMapping[_KT, _VT]): | ^^^^ - 2917 | """dict() -> new empty dictionary - 2918 | dict(mapping) -> new dictionary initialized from a mapping object's + 2919 | """dict() -> new empty dictionary + 2920 | dict(mapping) -> new dictionary initialized from a mapping object's | info: Source --> main.py:6:5 @@ -421,13 +421,13 @@ f(**kwargs) assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:911:7 + --> stdlib/builtins.pyi:913:7 | - 910 | @disjoint_base - 911 | class str(Sequence[str]): + 912 | @disjoint_base + 913 | class str(Sequence[str]): | ^^^ - 912 | """str(object='') -> str - 913 | str(bytes_or_buffer[, encoding[, errors]]) -> str + 914 | """str(object='') -> str + 915 | str(bytes_or_buffer[, encoding[, errors]]) -> str | info: Source --> main.py:3:5 @@ -513,13 +513,13 @@ f(**kwargs) assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:911:7 + --> stdlib/builtins.pyi:913:7 | - 910 | @disjoint_base - 911 | class str(Sequence[str]): + 912 | @disjoint_base + 913 | class str(Sequence[str]): | ^^^ - 912 | """str(object='') -> str - 913 | str(bytes_or_buffer[, encoding[, errors]]) -> str + 914 | """str(object='') -> str + 915 | str(bytes_or_buffer[, encoding[, errors]]) -> str | info: Source --> main.py:4:15 @@ -560,13 +560,13 @@ f(**kwargs) | info[goto-type-definition]: Type definition - --> stdlib/builtins.pyi:911:7 + --> stdlib/builtins.pyi:913:7 | - 910 | @disjoint_base - 911 | class str(Sequence[str]): + 912 | @disjoint_base + 913 | class str(Sequence[str]): | ^^^ - 912 | """str(object='') -> str - 913 | str(bytes_or_buffer[, encoding[, errors]]) -> str + 914 | """str(object='') -> str + 915 | str(bytes_or_buffer[, encoding[, errors]]) -> str | info: Source --> main.py:3:5 diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index ef36bea8d0..8ae31e9265 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2189,7 +2189,7 @@ All attribute access on literal `bytes` types is currently delegated to `builtin ```py # revealed: bound method Literal[b"foo"].join(iterable_of_bytes: Iterable[@Todo(Support for `typing.TypeAlias`)], /) -> bytes reveal_type(b"foo".join) -# revealed: bound method Literal[b"foo"].endswith(suffix: @Todo(Support for `typing.TypeAlias`) | tuple[@Todo(Support for `typing.TypeAlias`), ...], start: SupportsIndex | None = EllipsisType, end: SupportsIndex | None = EllipsisType, /) -> bool +# revealed: bound method Literal[b"foo"].endswith(suffix: @Todo(Support for `typing.TypeAlias`) | tuple[@Todo(Support for `typing.TypeAlias`), ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> bool reveal_type(b"foo".endswith) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/binary/classes.md b/crates/ty_python_semantic/resources/mdtest/binary/classes.md index d0ded68b3d..7ae4c23e60 100644 --- a/crates/ty_python_semantic/resources/mdtest/binary/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/binary/classes.md @@ -30,3 +30,27 @@ class B: ... # error: "Operator `|` is unsupported between objects of type `` and ``" reveal_type(A | B) # revealed: Unknown ``` + +## Other binary operations resulting in `UnionType` + +```toml +[environment] +python-version = "3.12" +``` + +```py +class A: ... +class B: ... + +def _(sub_a: type[A], sub_b: type[B]): + reveal_type(A | sub_b) # revealed: UnionType + reveal_type(sub_a | B) # revealed: UnionType + reveal_type(sub_a | sub_b) # revealed: UnionType + +class C[T]: ... +class D[T]: ... + +reveal_type(C | D) # revealed: UnionType + +reveal_type(C[int] | D[str]) # revealed: UnionType +``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/methods.md b/crates/ty_python_semantic/resources/mdtest/call/methods.md index f11adb34e4..b6547e338d 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/methods.md +++ b/crates/ty_python_semantic/resources/mdtest/call/methods.md @@ -651,7 +651,7 @@ static_assert(is_assignable_to(TypeOf[property.__set__], Callable)) reveal_type(MyClass.my_property.__set__) static_assert(is_assignable_to(TypeOf[MyClass.my_property.__set__], Callable)) -# revealed: def startswith(self, prefix: str | tuple[str, ...], start: SupportsIndex | None = EllipsisType, end: SupportsIndex | None = EllipsisType, /) -> bool +# revealed: def startswith(self, prefix: str | tuple[str, ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> bool reveal_type(str.startswith) static_assert(is_assignable_to(TypeOf[str.startswith], Callable)) @@ -689,7 +689,7 @@ def _( # revealed: (obj: type) -> None reveal_type(e) - # revealed: (fget: ((Any, /) -> Any) | None = EllipsisType, fset: ((Any, Any, /) -> None) | None = EllipsisType, fdel: ((Any, /) -> None) | None = EllipsisType, doc: str | None = EllipsisType) -> property + # revealed: (fget: ((Any, /) -> Any) | None = None, fset: ((Any, Any, /) -> None) | None = None, fdel: ((Any, /) -> None) | None = None, doc: str | None = None) -> property reveal_type(f) # revealed: Overload[(self: property, instance: None, owner: type, /) -> Unknown, (self: property, instance: object, owner: type | None = None, /) -> Unknown] @@ -707,7 +707,7 @@ def _( # revealed: (instance: object, value: object, /) -> Unknown reveal_type(j) - # revealed: (self, prefix: str | tuple[str, ...], start: SupportsIndex | None = EllipsisType, end: SupportsIndex | None = EllipsisType, /) -> bool + # revealed: (self, prefix: str | tuple[str, ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> bool reveal_type(k) # revealed: (prefix: str | tuple[str, ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> bool diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_await.md_-_Invalid_await_diagno…_-_Basic_(f15db7dc447d0795).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_await.md_-_Invalid_await_diagno…_-_Basic_(f15db7dc447d0795).snap index 52bde42ce5..312f3209c5 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_await.md_-_Invalid_await_diagno…_-_Basic_(f15db7dc447d0795).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_await.md_-_Invalid_await_diagno…_-_Basic_(f15db7dc447d0795).snap @@ -26,13 +26,13 @@ error[invalid-await]: `Literal[1]` is not awaitable 2 | await 1 # error: [invalid-await] | ^ | - ::: stdlib/builtins.pyi:344:7 + ::: stdlib/builtins.pyi:346:7 | -343 | @disjoint_base -344 | class int: +345 | @disjoint_base +346 | class int: | --- type defined here -345 | """int([x]) -> integer -346 | int(x, base=10) -> integer +347 | """int([x]) -> integer +348 | int(x, base=10) -> integer | info: `__await__` is missing info: rule `invalid-await` is enabled by default diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index bae41b14b4..4b672f6e10 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -7957,6 +7957,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ))) } + // Special-case `X | Y` with `X` and `Y` instances of `type` to produce a `types.UnionType` instance, in order to + // overwrite the typeshed return type for `type.__or__`, which would result in `types.UnionType | X`. We currently + // do this to avoid false positives when a legacy type alias like `IntOrStr = int | str` is later used in a type + // expression, because `types.UnionType` will result in a `@Todo` type, while `types.UnionType | ` does + // not. + // + // TODO: Remove this special case once we add support for legacy type aliases. + ( + Type::ClassLiteral(..) | Type::SubclassOf(..) | Type::GenericAlias(..), + Type::ClassLiteral(..) | Type::SubclassOf(..) | Type::GenericAlias(..), + ast::Operator::BitOr, + ) if Program::get(self.db()).python_version(self.db()) >= PythonVersion::PY310 => { + Some(KnownClass::UnionType.to_instance(self.db())) + } + // We've handled all of the special cases that we support for literals, so we need to // fall back on looking for dunder methods on one of the operand types. ( diff --git a/crates/ty_vendored/vendor/typeshed/source_commit.txt b/crates/ty_vendored/vendor/typeshed/source_commit.txt index 713417f2a4..54a8607d25 100644 --- a/crates/ty_vendored/vendor/typeshed/source_commit.txt +++ b/crates/ty_vendored/vendor/typeshed/source_commit.txt @@ -1 +1 @@ -91055c730ffcda6311654cf32d663858ece69bad +d6f4a0f7102b1400a21742cf9b7ea93614e2b6ec diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_csv.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/_csv.pyi index 93322e781d..e3adaf6fb0 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_csv.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_csv.pyi @@ -136,7 +136,7 @@ else: def writerows(self, rows: Iterable[Iterable[Any]]) -> None: ... def writer( - csvfile: SupportsWrite[str], + fileobj: SupportsWrite[str], /, dialect: _DialectLike = "excel", *, @@ -164,7 +164,7 @@ def writer( """ def reader( - csvfile: Iterable[str], + iterable: Iterable[str], /, dialect: _DialectLike = "excel", *, @@ -194,7 +194,8 @@ def reader( def register_dialect( name: str, - dialect: type[Dialect | csv.Dialect] = ..., + /, + dialect: type[Dialect | csv.Dialect] | str = "excel", *, delimiter: str = ",", quotechar: str | None = '"', diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.pyi index bd0291a7fe..455af65389 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_frozen_importlib_external.pyi @@ -251,7 +251,7 @@ class SourceLoader(_LoaderBasics): """ def source_to_code( - self, data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: ReadableBuffer | StrPath + self, data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: bytes | StrPath ) -> types.CodeType: """Return the code object compiled from source. @@ -281,10 +281,10 @@ class FileLoader: def get_data(self, path: str) -> bytes: """Return the data from path as raw bytes.""" - def get_filename(self, name: str | None = None) -> str: + def get_filename(self, fullname: str | None = None) -> str: """Return the path to the source file as found by the finder.""" - def load_module(self, name: str | None = None) -> types.ModuleType: + def load_module(self, fullname: str | None = None) -> types.ModuleType: """Load a module from a file. This method is deprecated. Use exec_module() instead. @@ -311,7 +311,7 @@ class SourceFileLoader(importlib.abc.FileLoader, FileLoader, importlib.abc.Sourc def source_to_code( # type: ignore[override] # incompatible with InspectLoader.source_to_code self, data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, - path: ReadableBuffer | StrPath, + path: bytes | StrPath, *, _optimize: int = -1, ) -> types.CodeType: @@ -335,7 +335,7 @@ class ExtensionFileLoader(FileLoader, _LoaderBasics, importlib.abc.ExecutionLoad """ def __init__(self, name: str, path: str) -> None: ... - def get_filename(self, name: str | None = None) -> str: + def get_filename(self, fullname: str | None = None) -> str: """Return the path to the source file as found by the finder.""" def get_source(self, fullname: str) -> None: diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.pyi index 25054b601a..b786923880 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/__init__.pyi @@ -142,6 +142,9 @@ class SupportsIter(Protocol[_T_co]): class SupportsAiter(Protocol[_T_co]): def __aiter__(self) -> _T_co: ... +class SupportsLen(Protocol): + def __len__(self) -> int: ... + class SupportsLenAndGetItem(Protocol[_T_co]): def __len__(self) -> int: ... def __getitem__(self, k: int, /) -> _T_co: ... diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/ast.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/ast.pyi index 772f165a98..d9ca52c7d6 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/ast.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/ast.pyi @@ -2179,14 +2179,14 @@ _T = _TypeVar("_T", bound=AST) if sys.version_info >= (3, 13): @overload def parse( - source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any] = "", - mode: Literal["exec"] = "exec", + source: _T, + filename: str | bytes | os.PathLike[Any] = "", + mode: Literal["exec", "eval", "func_type", "single"] = "exec", *, type_comments: bool = False, feature_version: None | int | tuple[int, int] = None, optimize: Literal[-1, 0, 1, 2] = -1, - ) -> Module: + ) -> _T: """ Parse the source into an AST node. Equivalent to compile(source, filename, mode, PyCF_ONLY_AST). @@ -2196,7 +2196,17 @@ if sys.version_info >= (3, 13): @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any], + filename: str | bytes | os.PathLike[Any] = "", + mode: Literal["exec"] = "exec", + *, + type_comments: bool = False, + feature_version: None | int | tuple[int, int] = None, + optimize: Literal[-1, 0, 1, 2] = -1, + ) -> Module: ... + @overload + def parse( + source: str | ReadableBuffer, + filename: str | bytes | os.PathLike[Any], mode: Literal["eval"], *, type_comments: bool = False, @@ -2206,7 +2216,7 @@ if sys.version_info >= (3, 13): @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any], + filename: str | bytes | os.PathLike[Any], mode: Literal["func_type"], *, type_comments: bool = False, @@ -2216,7 +2226,7 @@ if sys.version_info >= (3, 13): @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any], + filename: str | bytes | os.PathLike[Any], mode: Literal["single"], *, type_comments: bool = False, @@ -2253,7 +2263,7 @@ if sys.version_info >= (3, 13): @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any] = "", + filename: str | bytes | os.PathLike[Any] = "", mode: str = "exec", *, type_comments: bool = False, @@ -2264,13 +2274,13 @@ if sys.version_info >= (3, 13): else: @overload def parse( - source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any] = "", - mode: Literal["exec"] = "exec", + source: _T, + filename: str | bytes | os.PathLike[Any] = "", + mode: Literal["exec", "eval", "func_type", "single"] = "exec", *, type_comments: bool = False, feature_version: None | int | tuple[int, int] = None, - ) -> Module: + ) -> _T: """ Parse the source into an AST node. Equivalent to compile(source, filename, mode, PyCF_ONLY_AST). @@ -2280,7 +2290,16 @@ else: @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any], + filename: str | bytes | os.PathLike[Any] = "", + mode: Literal["exec"] = "exec", + *, + type_comments: bool = False, + feature_version: None | int | tuple[int, int] = None, + ) -> Module: ... + @overload + def parse( + source: str | ReadableBuffer, + filename: str | bytes | os.PathLike[Any], mode: Literal["eval"], *, type_comments: bool = False, @@ -2289,7 +2308,7 @@ else: @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any], + filename: str | bytes | os.PathLike[Any], mode: Literal["func_type"], *, type_comments: bool = False, @@ -2298,7 +2317,7 @@ else: @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any], + filename: str | bytes | os.PathLike[Any], mode: Literal["single"], *, type_comments: bool = False, @@ -2331,7 +2350,7 @@ else: @overload def parse( source: str | ReadableBuffer, - filename: str | ReadableBuffer | os.PathLike[Any] = "", + filename: str | bytes | os.PathLike[Any] = "", mode: str = "exec", *, type_comments: bool = False, diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi index 5f1d339a59..bcacb3857b 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi @@ -301,10 +301,12 @@ class type: def __prepare__(metacls, name: str, bases: tuple[type, ...], /, **kwds: Any) -> MutableMapping[str, object]: """Create the namespace for the class statement""" if sys.version_info >= (3, 10): - def __or__(self, value: Any, /) -> types.UnionType: + # `int | str` produces an instance of `UnionType`, but `int | int` produces an instance of `type`, + # and `abc.ABC | abc.ABC` produces an instance of `abc.ABCMeta`. + def __or__(self: _typeshed.Self, value: Any, /) -> types.UnionType | _typeshed.Self: """Return self|value.""" - def __ror__(self, value: Any, /) -> types.UnionType: + def __ror__(self: _typeshed.Self, value: Any, /) -> types.UnionType | _typeshed.Self: """Return value|self.""" if sys.version_info >= (3, 12): __type_params__: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] @@ -359,7 +361,7 @@ class int: """ @overload - def __new__(cls, x: ConvertibleToInt = ..., /) -> Self: ... + def __new__(cls, x: ConvertibleToInt = 0, /) -> Self: ... @overload def __new__(cls, x: str | bytes | bytearray, /, base: SupportsIndex) -> Self: ... def as_integer_ratio(self) -> tuple[int, Literal[1]]: @@ -657,7 +659,7 @@ class int: class float: """Convert a string or number to a floating-point number, if possible.""" - def __new__(cls, x: ConvertibleToFloat = ..., /) -> Self: ... + def __new__(cls, x: ConvertibleToFloat = 0, /) -> Self: ... def as_integer_ratio(self) -> tuple[int, int]: """Return a pair of integers, whose ratio is exactly equal to the original float. @@ -828,8 +830,8 @@ class complex: @overload def __new__( cls, - real: complex | SupportsComplex | SupportsFloat | SupportsIndex = ..., - imag: complex | SupportsFloat | SupportsIndex = ..., + real: complex | SupportsComplex | SupportsFloat | SupportsIndex = 0, + imag: complex | SupportsFloat | SupportsIndex = 0, ) -> Self: ... @overload def __new__(cls, real: str | SupportsComplex | SupportsFloat | SupportsIndex | complex) -> Self: ... @@ -922,9 +924,9 @@ class str(Sequence[str]): """ @overload - def __new__(cls, object: object = ...) -> Self: ... + def __new__(cls, object: object = "") -> Self: ... @overload - def __new__(cls, object: ReadableBuffer, encoding: str = ..., errors: str = ...) -> Self: ... + def __new__(cls, object: ReadableBuffer, encoding: str = "utf-8", errors: str = "strict") -> Self: ... @overload def capitalize(self: LiteralString) -> LiteralString: """Return a capitalized version of the string. @@ -950,7 +952,7 @@ class str(Sequence[str]): @overload def center(self, width: SupportsIndex, fillchar: str = " ", /) -> str: ... # type: ignore[misc] - def count(self, sub: str, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., /) -> int: + def count(self, sub: str, start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> int: """Return the number of non-overlapping occurrences of substring sub in string S[start:end]. Optional arguments start and end are interpreted as in slice notation. @@ -970,7 +972,7 @@ class str(Sequence[str]): """ def endswith( - self, suffix: str | tuple[str, ...], start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, suffix: str | tuple[str, ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> bool: """Return True if the string ends with the specified suffix, False otherwise. @@ -991,7 +993,7 @@ class str(Sequence[str]): @overload def expandtabs(self, tabsize: SupportsIndex = 8) -> str: ... # type: ignore[misc] - def find(self, sub: str, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., /) -> int: + def find(self, sub: str, start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> int: """Return the lowest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation. @@ -1011,7 +1013,7 @@ class str(Sequence[str]): The substitutions are identified by braces ('{' and '}'). """ - def index(self, sub: str, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., /) -> int: + def index(self, sub: str, start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> int: """Return the lowest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation. @@ -1203,14 +1205,14 @@ class str(Sequence[str]): @overload def removesuffix(self, suffix: str, /) -> str: ... # type: ignore[misc] - def rfind(self, sub: str, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., /) -> int: + def rfind(self, sub: str, start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> int: """Return the highest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation. Return -1 on failure. """ - def rindex(self, sub: str, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., /) -> int: + def rindex(self, sub: str, start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> int: """Return the highest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation. @@ -1302,7 +1304,7 @@ class str(Sequence[str]): @overload def splitlines(self, keepends: bool = False) -> list[str]: ... # type: ignore[misc] def startswith( - self, prefix: str | tuple[str, ...], start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, prefix: str | tuple[str, ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> bool: """Return True if the string starts with the specified prefix, False otherwise. @@ -1458,7 +1460,7 @@ class bytes(Sequence[int]): @overload def __new__(cls, o: Iterable[SupportsIndex] | SupportsIndex | SupportsBytes | ReadableBuffer, /) -> Self: ... @overload - def __new__(cls, string: str, /, encoding: str, errors: str = ...) -> Self: ... + def __new__(cls, string: str, /, encoding: str, errors: str = "strict") -> Self: ... @overload def __new__(cls) -> Self: ... def capitalize(self) -> bytes: @@ -1475,7 +1477,7 @@ class bytes(Sequence[int]): """ def count( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the number of non-overlapping occurrences of subsection 'sub' in bytes B[start:end]. @@ -1501,8 +1503,8 @@ class bytes(Sequence[int]): def endswith( self, suffix: ReadableBuffer | tuple[ReadableBuffer, ...], - start: SupportsIndex | None = ..., - end: SupportsIndex | None = ..., + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, /, ) -> bool: """Return True if the bytes ends with the specified suffix, False otherwise. @@ -1522,7 +1524,7 @@ class bytes(Sequence[int]): """ def find( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the lowest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start,end]. @@ -1534,7 +1536,7 @@ class bytes(Sequence[int]): Return -1 on failure. """ - def hex(self, sep: str | bytes = ..., bytes_per_sep: SupportsIndex = ...) -> str: + def hex(self, sep: str | bytes = ..., bytes_per_sep: SupportsIndex = 1) -> str: """Create a string of hexadecimal numbers from a bytes object. sep @@ -1556,7 +1558,7 @@ class bytes(Sequence[int]): """ def index( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the lowest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start,end]. @@ -1692,7 +1694,7 @@ class bytes(Sequence[int]): """ def rfind( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the highest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start,end]. @@ -1705,7 +1707,7 @@ class bytes(Sequence[int]): """ def rindex( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the highest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start,end]. @@ -1776,8 +1778,8 @@ class bytes(Sequence[int]): def startswith( self, prefix: ReadableBuffer | tuple[ReadableBuffer, ...], - start: SupportsIndex | None = ..., - end: SupportsIndex | None = ..., + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, /, ) -> bool: """Return True if the bytes starts with the specified prefix, False otherwise. @@ -1913,7 +1915,7 @@ class bytearray(MutableSequence[int]): @overload def __init__(self, ints: Iterable[SupportsIndex] | SupportsIndex | ReadableBuffer, /) -> None: ... @overload - def __init__(self, string: str, /, encoding: str, errors: str = ...) -> None: ... + def __init__(self, string: str, /, encoding: str, errors: str = "strict") -> None: ... def append(self, item: SupportsIndex, /) -> None: """Append a single item to the end of the bytearray. @@ -1935,7 +1937,7 @@ class bytearray(MutableSequence[int]): """ def count( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the number of non-overlapping occurrences of subsection 'sub' in bytes B[start:end]. @@ -1964,8 +1966,8 @@ class bytearray(MutableSequence[int]): def endswith( self, suffix: ReadableBuffer | tuple[ReadableBuffer, ...], - start: SupportsIndex | None = ..., - end: SupportsIndex | None = ..., + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, /, ) -> bool: """Return True if the bytearray ends with the specified suffix, False otherwise. @@ -1992,7 +1994,7 @@ class bytearray(MutableSequence[int]): """ def find( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the lowest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start:end]. @@ -2004,7 +2006,7 @@ class bytearray(MutableSequence[int]): Return -1 on failure. """ - def hex(self, sep: str | bytes = ..., bytes_per_sep: SupportsIndex = ...) -> str: + def hex(self, sep: str | bytes = ..., bytes_per_sep: SupportsIndex = 1) -> str: """Create a string of hexadecimal numbers from a bytearray object. sep @@ -2026,7 +2028,7 @@ class bytearray(MutableSequence[int]): """ def index( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the lowest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start:end]. @@ -2187,7 +2189,7 @@ class bytearray(MutableSequence[int]): """ def rfind( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the highest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start:end]. @@ -2200,7 +2202,7 @@ class bytearray(MutableSequence[int]): """ def rindex( - self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = ..., end: SupportsIndex | None = ..., / + self, sub: ReadableBuffer | SupportsIndex, start: SupportsIndex | None = None, end: SupportsIndex | None = None, / ) -> int: """Return the highest index in B where subsection 'sub' is found, such that 'sub' is contained within B[start:end]. @@ -2272,8 +2274,8 @@ class bytearray(MutableSequence[int]): def startswith( self, prefix: ReadableBuffer | tuple[ReadableBuffer, ...], - start: SupportsIndex | None = ..., - end: SupportsIndex | None = ..., + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, /, ) -> bool: """Return True if the bytearray starts with the specified prefix, False otherwise. @@ -2549,7 +2551,7 @@ class memoryview(Sequence[_I]): def release(self) -> None: """Release the underlying buffer exposed by the memoryview object.""" - def hex(self, sep: str | bytes = ..., bytes_per_sep: SupportsIndex = ...) -> str: + def hex(self, sep: str | bytes = ..., bytes_per_sep: SupportsIndex = 1) -> str: """Return the data in the buffer as a str of hexadecimal numbers. sep @@ -2590,7 +2592,7 @@ class bool(int): The class bool is a subclass of the class int, and cannot be subclassed. """ - def __new__(cls, o: object = ..., /) -> Self: ... + def __new__(cls, o: object = False, /) -> Self: ... # The following overloads could be represented more elegantly with a TypeVar("_B", bool, int), # however mypy has a bug regarding TypeVar constraints (https://github.com/python/mypy/issues/11880). @overload @@ -2697,7 +2699,7 @@ class tuple(Sequence[_T_co]): If the argument is a tuple, the return value is the same object. """ - def __new__(cls, iterable: Iterable[_T_co] = ..., /) -> Self: ... + def __new__(cls, iterable: Iterable[_T_co] = (), /) -> Self: ... def __len__(self) -> int: """Return len(self).""" @@ -3254,7 +3256,7 @@ class range(Sequence[int]): @overload def __new__(cls, stop: SupportsIndex, /) -> Self: ... @overload - def __new__(cls, start: SupportsIndex, stop: SupportsIndex, step: SupportsIndex = ..., /) -> Self: ... + def __new__(cls, start: SupportsIndex, stop: SupportsIndex, step: SupportsIndex = 1, /) -> Self: ... def count(self, value: int, /) -> int: """rangeobject.count(value) -> integer -- return number of occurrences of value""" @@ -3328,10 +3330,10 @@ class property: def __init__( self, - fget: Callable[[Any], Any] | None = ..., - fset: Callable[[Any, Any], None] | None = ..., - fdel: Callable[[Any], None] | None = ..., - doc: str | None = ..., + fget: Callable[[Any], Any] | None = None, + fset: Callable[[Any, Any], None] | None = None, + fdel: Callable[[Any], None] | None = None, + doc: str | None = None, ) -> None: ... def getter(self, fget: Callable[[Any], Any], /) -> property: """Descriptor to obtain a copy of the property with a different getter.""" @@ -3437,7 +3439,7 @@ if sys.version_info >= (3, 10): @overload def compile( source: str | ReadableBuffer | _ast.Module | _ast.Expression | _ast.Interactive, - filename: str | ReadableBuffer | PathLike[Any], + filename: str | bytes | PathLike[Any], mode: str, flags: Literal[0], dont_inherit: bool = False, @@ -3462,7 +3464,7 @@ def compile( @overload def compile( source: str | ReadableBuffer | _ast.Module | _ast.Expression | _ast.Interactive, - filename: str | ReadableBuffer | PathLike[Any], + filename: str | bytes | PathLike[Any], mode: str, *, dont_inherit: bool = False, @@ -3472,7 +3474,7 @@ def compile( @overload def compile( source: str | ReadableBuffer | _ast.Module | _ast.Expression | _ast.Interactive, - filename: str | ReadableBuffer | PathLike[Any], + filename: str | bytes | PathLike[Any], mode: str, flags: Literal[1024], dont_inherit: bool = False, @@ -3483,7 +3485,7 @@ def compile( @overload def compile( source: str | ReadableBuffer | _ast.Module | _ast.Expression | _ast.Interactive, - filename: str | ReadableBuffer | PathLike[Any], + filename: str | bytes | PathLike[Any], mode: str, flags: int, dont_inherit: bool = False, @@ -4378,18 +4380,25 @@ class zip(Generic[_T_co]): if sys.version_info >= (3, 10): @overload - def __new__(cls, *, strict: bool = ...) -> zip[Any]: ... + def __new__(cls, *, strict: bool = False) -> zip[Any]: ... @overload - def __new__(cls, iter1: Iterable[_T1], /, *, strict: bool = ...) -> zip[tuple[_T1]]: ... + def __new__(cls, iter1: Iterable[_T1], /, *, strict: bool = False) -> zip[tuple[_T1]]: ... @overload - def __new__(cls, iter1: Iterable[_T1], iter2: Iterable[_T2], /, *, strict: bool = ...) -> zip[tuple[_T1, _T2]]: ... + def __new__(cls, iter1: Iterable[_T1], iter2: Iterable[_T2], /, *, strict: bool = False) -> zip[tuple[_T1, _T2]]: ... @overload def __new__( - cls, iter1: Iterable[_T1], iter2: Iterable[_T2], iter3: Iterable[_T3], /, *, strict: bool = ... + cls, iter1: Iterable[_T1], iter2: Iterable[_T2], iter3: Iterable[_T3], /, *, strict: bool = False ) -> zip[tuple[_T1, _T2, _T3]]: ... @overload def __new__( - cls, iter1: Iterable[_T1], iter2: Iterable[_T2], iter3: Iterable[_T3], iter4: Iterable[_T4], /, *, strict: bool = ... + cls, + iter1: Iterable[_T1], + iter2: Iterable[_T2], + iter3: Iterable[_T3], + iter4: Iterable[_T4], + /, + *, + strict: bool = False, ) -> zip[tuple[_T1, _T2, _T3, _T4]]: ... @overload def __new__( @@ -4401,7 +4410,7 @@ class zip(Generic[_T_co]): iter5: Iterable[_T5], /, *, - strict: bool = ..., + strict: bool = False, ) -> zip[tuple[_T1, _T2, _T3, _T4, _T5]]: ... @overload def __new__( @@ -4414,7 +4423,7 @@ class zip(Generic[_T_co]): iter6: Iterable[Any], /, *iterables: Iterable[Any], - strict: bool = ..., + strict: bool = False, ) -> zip[tuple[Any, ...]]: ... else: @overload @@ -4571,8 +4580,8 @@ if sys.version_info >= (3, 10): class AttributeError(Exception): """Attribute not found.""" - def __init__(self, *args: object, name: str | None = ..., obj: object = ...) -> None: ... - name: str + def __init__(self, *args: object, name: str | None = None, obj: object = None) -> None: ... + name: str | None obj: object else: @@ -4589,7 +4598,7 @@ class EOFError(Exception): class ImportError(Exception): """Import can't find module, or can't find name in module.""" - def __init__(self, *args: object, name: str | None = ..., path: str | None = ...) -> None: ... + def __init__(self, *args: object, name: str | None = None, path: str | None = None) -> None: ... name: str | None path: str | None msg: str # undocumented @@ -4607,8 +4616,8 @@ if sys.version_info >= (3, 10): class NameError(Exception): """Name not found globally.""" - def __init__(self, *args: object, name: str | None = ...) -> None: ... - name: str + def __init__(self, *args: object, name: str | None = None) -> None: ... + name: str | None else: class NameError(Exception): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.pyi index 9d488e29da..03c62e5dd7 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/ctypes/__init__.pyi @@ -25,7 +25,7 @@ from _ctypes import ( set_errno as set_errno, sizeof as sizeof, ) -from _typeshed import StrPath +from _typeshed import StrPath, SupportsBool, SupportsLen from ctypes._endian import BigEndianStructure as BigEndianStructure, LittleEndianStructure as LittleEndianStructure from types import GenericAlias from typing import Any, ClassVar, Final, Generic, Literal, TypeVar, overload, type_check_only @@ -301,7 +301,7 @@ class py_object(_CanCastTo, _SimpleCData[_T]): class c_bool(_SimpleCData[bool]): _type_: ClassVar[Literal["?"]] - def __init__(self, value: bool = ...) -> None: ... + def __init__(self, value: SupportsBool | SupportsLen | None = ...) -> None: ... class c_byte(_SimpleCData[int]): _type_: ClassVar[Literal["b"]] diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/html/parser.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/html/parser.pyi index cc71c2cb41..085b51186d 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/html/parser.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/html/parser.pyi @@ -1,6 +1,5 @@ """A parser for HTML and XHTML.""" -import sys from _markupbase import ParserBase from re import Pattern from typing import Final @@ -30,9 +29,8 @@ class HTMLParser(ParserBase): """ CDATA_CONTENT_ELEMENTS: Final[tuple[str, ...]] - if sys.version_info >= (3, 13): - # Added in 3.13.6 - RCDATA_CONTENT_ELEMENTS: Final[tuple[str, ...]] + # Added in Python 3.9.23, 3.10.18, 3.11.13, 3.12.11, 3.13.6 + RCDATA_CONTENT_ELEMENTS: Final[tuple[str, ...]] def __init__(self, *, convert_charrefs: bool = True) -> None: """Initialize and reset this instance. @@ -71,11 +69,8 @@ class HTMLParser(ParserBase): def parse_html_declaration(self, i: int) -> int: ... # undocumented def parse_pi(self, i: int) -> int: ... # undocumented def parse_starttag(self, i: int) -> int: ... # undocumented - if sys.version_info >= (3, 13): - # `escapable` parameter added in 3.13.6 - def set_cdata_mode(self, elem: str, *, escapable: bool = False) -> None: ... # undocumented - else: - def set_cdata_mode(self, elem: str) -> None: ... # undocumented + # `escapable` parameter added in Python 3.9.23, 3.10.18, 3.11.13, 3.12.11, 3.13.6 + def set_cdata_mode(self, elem: str, *, escapable: bool = False) -> None: ... # undocumented rawdata: str # undocumented cdata_elem: str | None # undocumented convert_charrefs: bool # undocumented diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.pyi index 9bbc9e5a91..ae2f291d55 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/abc.pyi @@ -136,7 +136,7 @@ class InspectLoader(Loader): @staticmethod def source_to_code( - data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: ReadableBuffer | StrPath = "" + data: ReadableBuffer | str | _ast.Module | _ast.Expression | _ast.Interactive, path: bytes | StrPath = "" ) -> types.CodeType: """Compile 'data' into a code object. @@ -342,10 +342,10 @@ class FileLoader(_bootstrap_external.FileLoader, ResourceLoader, ExecutionLoader def get_data(self, path: str) -> bytes: """Return the data from path as raw bytes.""" - def get_filename(self, name: str | None = None) -> str: + def get_filename(self, fullname: str | None = None) -> str: """Return the path to the source file as found by the finder.""" - def load_module(self, name: str | None = None) -> types.ModuleType: + def load_module(self, fullname: str | None = None) -> types.ModuleType: """Load a module from a file. This method is deprecated. Use exec_module() instead. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/__init__.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/__init__.pyi index fb0ac53ac1..bc87745ca6 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/__init__.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/__init__.pyi @@ -14,7 +14,7 @@ from contextlib import AbstractContextManager from pathlib import Path from types import ModuleType from typing import Any, BinaryIO, Literal, TextIO -from typing_extensions import TypeAlias +from typing_extensions import TypeAlias, deprecated if sys.version_info >= (3, 11): from importlib.resources.abc import Traversable @@ -98,14 +98,23 @@ else: Directories are *not* resources. """ + if sys.version_info >= (3, 11): + @deprecated("Deprecated since Python 3.11. Use `files(anchor).iterdir()`.") + def contents(package: Package) -> Iterator[str]: + """Return an iterable of entries in `package`. - def contents(package: Package) -> Iterator[str]: - """Return an iterable of entries in `package`. + Note that not all entries are resources. Specifically, directories are + not considered resources. Use `is_resource()` on each entry returned here + to check if it is a resource or not. + """ + else: + def contents(package: Package) -> Iterator[str]: + """Return an iterable of entries in 'package'. - Note that not all entries are resources. Specifically, directories are - not considered resources. Use `is_resource()` on each entry returned here - to check if it is a resource or not. - """ + Note that not all entries are resources. Specifically, directories are + not considered resources. Use `is_resource()` on each entry returned here + to check if it is a resource or not. + """ if sys.version_info >= (3, 11): from importlib.resources._common import as_file as as_file diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_functional.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_functional.pyi index e25c9a8e3a..9bea507086 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_functional.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/importlib/resources/_functional.pyi @@ -11,7 +11,7 @@ if sys.version_info >= (3, 13): from io import TextIOWrapper from pathlib import Path from typing import BinaryIO, Literal, overload - from typing_extensions import Unpack + from typing_extensions import Unpack, deprecated def open_binary(anchor: Anchor, *path_names: StrPath) -> BinaryIO: """Open for binary reading the *resource* within *package*.""" @@ -44,6 +44,7 @@ if sys.version_info >= (3, 13): Otherwise returns ``False``. """ + @deprecated("Deprecated since Python 3.11. Use `files(anchor).iterdir()`.") def contents(anchor: Anchor, *path_names: StrPath) -> Iterator[str]: """Return an iterable over the named resources within the package. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/operator.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/operator.pyi index 790288021b..000e90a72e 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/operator.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/operator.pyi @@ -227,8 +227,10 @@ class itemgetter(Generic[_T_co]): # "tuple[int, int]" is incompatible with protocol "SupportsIndex" # preventing [_T_co, ...] instead of [Any, ...] # - # A suspected mypy issue prevents using [..., _T] instead of [..., Any] here. - # https://github.com/python/mypy/issues/14032 + # If we can't infer a literal key from __new__ (ie: `itemgetter[Literal[0]]` for `itemgetter(0)`), + # then we can't annotate __call__'s return type or it'll break on tuples + # + # These issues are best demonstrated by the `itertools.check_itertools_recipes.unique_justseen` test. def __call__(self, obj: SupportsGetItem[Any, Any]) -> Any: """Call self as a function.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/pdb.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/pdb.pyi index 60436b2c81..6fc9e2a4fc 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/pdb.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/pdb.pyi @@ -343,6 +343,7 @@ from cmd import Cmd from collections.abc import Callable, Iterable, Mapping, Sequence from inspect import _SourceObjectType from linecache import _ModuleGlobals +from rlcompleter import Completer from types import CodeType, FrameType, TracebackType from typing import IO, Any, ClassVar, Final, Literal, TypeVar from typing_extensions import ParamSpec, Self, TypeAlias @@ -943,6 +944,17 @@ class Pdb(Bdb, Cmd): def completenames(self, text: str, line: str, begidx: int, endidx: int) -> list[str]: ... # type: ignore[override] if sys.version_info >= (3, 12): def set_convenience_variable(self, frame: FrameType, name: str, value: Any) -> None: ... + if sys.version_info >= (3, 13) and sys.version_info < (3, 14): + # Added in 3.13.8. + @property + def rlcompleter(self) -> type[Completer]: + """Return the `Completer` class from `rlcompleter`, while avoiding the + side effects of changing the completer from `import rlcompleter`. + + This is a compromise between GH-138860 and GH-139289. If GH-139289 is + fixed, then we don't need this and we can just `import rlcompleter` in + `Pdb.__init__`. + """ def _select_frame(self, number: int) -> None: ... def _getval_except(self, arg: str, frame: FrameType | None = None) -> object: ... diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/sysconfig.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/sysconfig.pyi index 747771c9e9..8cdd3b1b2f 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/sysconfig.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/sysconfig.pyi @@ -77,17 +77,22 @@ def get_platform() -> str: isn't particularly important. Examples of returned values: - linux-i586 - linux-alpha (?) - solaris-2.6-sun4u - Windows will return one of: - win-amd64 (64-bit Windows on AMD64 (aka x86_64, Intel64, EM64T, etc) - win-arm64 (64-bit Windows on ARM64 (aka AArch64) - win32 (all others - specifically, sys.platform is returned) - For other non-POSIX platforms, currently just returns 'sys.platform'. + Windows: + - win-amd64 (64-bit Windows on AMD64, aka x86_64, Intel64, and EM64T) + - win-arm64 (64-bit Windows on ARM64, aka AArch64) + - win32 (all others - specifically, sys.platform is returned) + + POSIX based OS: + + - linux-x86_64 + - macosx-15.5-arm64 + - macosx-26.0-universal2 (macOS on Apple Silicon or Intel) + - android-24-arm64_v8a + + For other non-POSIX platforms, currently just returns :data:`sys.platform`. """ if sys.version_info >= (3, 11): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.pyi index 80fe7a9545..1f31c1fbb4 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/tkinter/__init__.pyi @@ -5210,7 +5210,6 @@ class Scrollbar(Widget): lower ends as value between 0 and 1). """ -_TextIndex: TypeAlias = _tkinter.Tcl_Obj | str | float | Misc _WhatToCount: TypeAlias = Literal[ "chars", "displaychars", "displayindices", "displaylines", "indices", "lines", "xpixels", "ypixels" ] @@ -5358,18 +5357,29 @@ class Text(Widget, XView, YView): @overload def configure(self, cnf: str) -> tuple[str, str, str, Any, Any]: ... config = configure - def bbox(self, index: _TextIndex) -> tuple[int, int, int, int] | None: # type: ignore[override] + def bbox(self, index: str | float | _tkinter.Tcl_Obj | Widget) -> tuple[int, int, int, int] | None: # type: ignore[override] """Return a tuple of (x,y,width,height) which gives the bounding box of the visible part of the character at the given index. """ - def compare(self, index1: _TextIndex, op: Literal["<", "<=", "==", ">=", ">", "!="], index2: _TextIndex) -> bool: + def compare( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + op: Literal["<", "<=", "==", ">=", ">", "!="], + index2: str | float | _tkinter.Tcl_Obj | Widget, + ) -> bool: """Return whether between index INDEX1 and index INDEX2 the relation OP is satisfied. OP is one of <, <=, ==, >=, >, or !=. """ if sys.version_info >= (3, 13): @overload - def count(self, index1: _TextIndex, index2: _TextIndex, *, return_ints: Literal[True]) -> int: + def count( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + *, + return_ints: Literal[True], + ) -> int: """Counts the number of relevant things between the two indices. If INDEX1 is after INDEX2, the result will be a negative number @@ -5389,13 +5399,19 @@ class Text(Widget, XView, YView): @overload def count( - self, index1: _TextIndex, index2: _TextIndex, arg: _WhatToCount | Literal["update"], /, *, return_ints: Literal[True] + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + arg: _WhatToCount | Literal["update"], + /, + *, + return_ints: Literal[True], ) -> int: ... @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: Literal["update"], arg2: _WhatToCount, /, @@ -5405,8 +5421,8 @@ class Text(Widget, XView, YView): @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: _WhatToCount, arg2: Literal["update"], /, @@ -5415,13 +5431,20 @@ class Text(Widget, XView, YView): ) -> int: ... @overload def count( - self, index1: _TextIndex, index2: _TextIndex, arg1: _WhatToCount, arg2: _WhatToCount, /, *, return_ints: Literal[True] + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + arg1: _WhatToCount, + arg2: _WhatToCount, + /, + *, + return_ints: Literal[True], ) -> tuple[int, int]: ... @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: _WhatToCount | Literal["update"], arg2: _WhatToCount | Literal["update"], arg3: _WhatToCount | Literal["update"], @@ -5430,12 +5453,18 @@ class Text(Widget, XView, YView): return_ints: Literal[True], ) -> tuple[int, ...]: ... @overload - def count(self, index1: _TextIndex, index2: _TextIndex, *, return_ints: Literal[False] = False) -> tuple[int] | None: ... + def count( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + *, + return_ints: Literal[False] = False, + ) -> tuple[int] | None: ... @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg: _WhatToCount | Literal["update"], /, *, @@ -5444,8 +5473,8 @@ class Text(Widget, XView, YView): @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: Literal["update"], arg2: _WhatToCount, /, @@ -5455,8 +5484,8 @@ class Text(Widget, XView, YView): @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: _WhatToCount, arg2: Literal["update"], /, @@ -5466,8 +5495,8 @@ class Text(Widget, XView, YView): @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: _WhatToCount, arg2: _WhatToCount, /, @@ -5477,8 +5506,8 @@ class Text(Widget, XView, YView): @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: _WhatToCount | Literal["update"], arg2: _WhatToCount | Literal["update"], arg3: _WhatToCount | Literal["update"], @@ -5488,7 +5517,9 @@ class Text(Widget, XView, YView): ) -> tuple[int, ...]: ... else: @overload - def count(self, index1: _TextIndex, index2: _TextIndex) -> tuple[int] | None: + def count( + self, index1: str | float | _tkinter.Tcl_Obj | Widget, index2: str | float | _tkinter.Tcl_Obj | Widget + ) -> tuple[int] | None: """Counts the number of relevant things between the two indices. If index1 is after index2, the result will be a negative number (and this holds for each of the possible options). @@ -5504,19 +5535,44 @@ class Text(Widget, XView, YView): @overload def count( - self, index1: _TextIndex, index2: _TextIndex, arg: _WhatToCount | Literal["update"], / + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + arg: _WhatToCount | Literal["update"], + /, ) -> tuple[int] | None: ... @overload - def count(self, index1: _TextIndex, index2: _TextIndex, arg1: Literal["update"], arg2: _WhatToCount, /) -> int | None: ... - @overload - def count(self, index1: _TextIndex, index2: _TextIndex, arg1: _WhatToCount, arg2: Literal["update"], /) -> int | None: ... - @overload - def count(self, index1: _TextIndex, index2: _TextIndex, arg1: _WhatToCount, arg2: _WhatToCount, /) -> tuple[int, int]: ... - @overload def count( self, - index1: _TextIndex, - index2: _TextIndex, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + arg1: Literal["update"], + arg2: _WhatToCount, + /, + ) -> int | None: ... + @overload + def count( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + arg1: _WhatToCount, + arg2: Literal["update"], + /, + ) -> int | None: ... + @overload + def count( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + arg1: _WhatToCount, + arg2: _WhatToCount, + /, + ) -> tuple[int, int]: ... + @overload + def count( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, arg1: _WhatToCount | Literal["update"], arg2: _WhatToCount | Literal["update"], arg3: _WhatToCount | Literal["update"], @@ -5532,10 +5588,12 @@ class Text(Widget, XView, YView): @overload def debug(self, boolean: bool) -> None: ... - def delete(self, index1: _TextIndex, index2: _TextIndex | None = None) -> None: + def delete( + self, index1: str | float | _tkinter.Tcl_Obj | Widget, index2: str | float | _tkinter.Tcl_Obj | Widget | None = None + ) -> None: """Delete the characters between INDEX1 and INDEX2 (not included).""" - def dlineinfo(self, index: _TextIndex) -> tuple[int, int, int, int, int] | None: + def dlineinfo(self, index: str | float | _tkinter.Tcl_Obj | Widget) -> tuple[int, int, int, int, int] | None: """Return tuple (x,y,width,height,baseline) giving the bounding box and baseline position of the visible part of the line containing the character at INDEX. @@ -5544,8 +5602,8 @@ class Text(Widget, XView, YView): @overload def dump( self, - index1: _TextIndex, - index2: _TextIndex | None = None, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget | None = None, command: None = None, *, all: bool = ..., @@ -5571,8 +5629,8 @@ class Text(Widget, XView, YView): @overload def dump( self, - index1: _TextIndex, - index2: _TextIndex | None, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget | None, command: Callable[[str, str, str], object] | str, *, all: bool = ..., @@ -5585,8 +5643,8 @@ class Text(Widget, XView, YView): @overload def dump( self, - index1: _TextIndex, - index2: _TextIndex | None = None, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget | None = None, *, command: Callable[[str, str, str], object] | str, all: bool = ..., @@ -5651,27 +5709,31 @@ class Text(Widget, XView, YView): when the undo option is false """ - def get(self, index1: _TextIndex, index2: _TextIndex | None = None) -> str: + def get( + self, index1: str | float | _tkinter.Tcl_Obj | Widget, index2: str | float | _tkinter.Tcl_Obj | Widget | None = None + ) -> str: """Return the text from INDEX1 to INDEX2 (not included).""" @overload - def image_cget(self, index: _TextIndex, option: Literal["image", "name"]) -> str: + def image_cget(self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["image", "name"]) -> str: """Return the value of OPTION of an embedded image at INDEX.""" @overload - def image_cget(self, index: _TextIndex, option: Literal["padx", "pady"]) -> int: ... + def image_cget(self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["padx", "pady"]) -> int: ... @overload - def image_cget(self, index: _TextIndex, option: Literal["align"]) -> Literal["baseline", "bottom", "center", "top"]: ... + def image_cget( + self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["align"] + ) -> Literal["baseline", "bottom", "center", "top"]: ... @overload - def image_cget(self, index: _TextIndex, option: str) -> Any: ... + def image_cget(self, index: str | float | _tkinter.Tcl_Obj | Widget, option: str) -> Any: ... @overload - def image_configure(self, index: _TextIndex, cnf: str) -> tuple[str, str, str, str, str | int]: + def image_configure(self, index: str | float | _tkinter.Tcl_Obj | Widget, cnf: str) -> tuple[str, str, str, str, str | int]: """Configure an embedded image at INDEX.""" @overload def image_configure( self, - index: _TextIndex, + index: str | float | _tkinter.Tcl_Obj | Widget, cnf: dict[str, Any] | None = None, *, align: Literal["baseline", "bottom", "center", "top"] = ..., @@ -5682,7 +5744,7 @@ class Text(Widget, XView, YView): ) -> dict[str, tuple[str, str, str, str, str | int]] | None: ... def image_create( self, - index: _TextIndex, + index: str | float | _tkinter.Tcl_Obj | Widget, cnf: dict[str, Any] | None = {}, *, align: Literal["baseline", "bottom", "center", "top"] = ..., @@ -5696,10 +5758,12 @@ class Text(Widget, XView, YView): def image_names(self) -> tuple[str, ...]: """Return all names of embedded images in this widget.""" - def index(self, index: _TextIndex) -> str: + def index(self, index: str | float | _tkinter.Tcl_Obj | Widget) -> str: """Return the index in the form line.char for INDEX.""" - def insert(self, index: _TextIndex, chars: str, *args: str | list[str] | tuple[str, ...]) -> None: + def insert( + self, index: str | float | _tkinter.Tcl_Obj | Widget, chars: str, *args: str | list[str] | tuple[str, ...] + ) -> None: """Insert CHARS before the characters at INDEX. An additional tag can be given in ARGS. Additional CHARS and tags can follow in ARGS. """ @@ -5715,16 +5779,16 @@ class Text(Widget, XView, YView): def mark_names(self) -> tuple[str, ...]: """Return all mark names.""" - def mark_set(self, markName: str, index: _TextIndex) -> None: + def mark_set(self, markName: str, index: str | float | _tkinter.Tcl_Obj | Widget) -> None: """Set mark MARKNAME before the character at INDEX.""" def mark_unset(self, *markNames: str) -> None: """Delete all marks in MARKNAMES.""" - def mark_next(self, index: _TextIndex) -> str | None: + def mark_next(self, index: str | float | _tkinter.Tcl_Obj | Widget) -> str | None: """Return the name of the next mark after INDEX.""" - def mark_previous(self, index: _TextIndex) -> str | None: + def mark_previous(self, index: str | float | _tkinter.Tcl_Obj | Widget) -> str | None: """Return the name of the previous mark before INDEX.""" # **kw of peer_create is same as the kwargs of Text.__init__ def peer_create(self, newPathName: str | Text, cnf: dict[str, Any] = {}, **kw) -> None: @@ -5739,7 +5803,13 @@ class Text(Widget, XView, YView): the widget itself). """ - def replace(self, index1: _TextIndex, index2: _TextIndex, chars: str, *args: str | list[str] | tuple[str, ...]) -> None: + def replace( + self, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget, + chars: str, + *args: str | list[str] | tuple[str, ...], + ) -> None: """Replaces the range of characters between index1 and index2 with the given characters and tags specified by args. @@ -5759,8 +5829,8 @@ class Text(Widget, XView, YView): def search( self, pattern: str, - index: _TextIndex, - stopindex: _TextIndex | None = None, + index: str | float | _tkinter.Tcl_Obj | Widget, + stopindex: str | float | _tkinter.Tcl_Obj | Widget | None = None, forwards: bool | None = None, backwards: bool | None = None, exact: bool | None = None, @@ -5774,10 +5844,12 @@ class Text(Widget, XView, YView): empty string. """ - def see(self, index: _TextIndex) -> None: + def see(self, index: str | float | _tkinter.Tcl_Obj | Widget) -> None: """Scroll such that the character at INDEX is visible.""" - def tag_add(self, tagName: str, index1: _TextIndex, *args: _TextIndex) -> None: + def tag_add( + self, tagName: str, index1: str | float | _tkinter.Tcl_Obj | Widget, *args: str | float | _tkinter.Tcl_Obj | Widget + ) -> None: """Add tag TAGNAME to all characters between INDEX1 and index2 in ARGS. Additional pairs of indices may follow in ARGS. """ @@ -5855,16 +5927,26 @@ class Text(Widget, XView, YView): than the priority of BELOWTHIS. """ - def tag_names(self, index: _TextIndex | None = None) -> tuple[str, ...]: + def tag_names(self, index: str | float | _tkinter.Tcl_Obj | Widget | None = None) -> tuple[str, ...]: """Return a list of all tag names.""" - def tag_nextrange(self, tagName: str, index1: _TextIndex, index2: _TextIndex | None = None) -> tuple[str, str] | tuple[()]: + def tag_nextrange( + self, + tagName: str, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget | None = None, + ) -> tuple[str, str] | tuple[()]: """Return a list of start and end index for the first sequence of characters between INDEX1 and INDEX2 which all have tag TAGNAME. The text is searched forward from INDEX1. """ - def tag_prevrange(self, tagName: str, index1: _TextIndex, index2: _TextIndex | None = None) -> tuple[str, str] | tuple[()]: + def tag_prevrange( + self, + tagName: str, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget | None = None, + ) -> tuple[str, str] | tuple[()]: """Return a list of start and end index for the first sequence of characters between INDEX1 and INDEX2 which all have tag TAGNAME. The text is searched backwards from INDEX1. @@ -5878,29 +5960,38 @@ class Text(Widget, XView, YView): def tag_ranges(self, tagName: str) -> tuple[_tkinter.Tcl_Obj, ...]: """Return a list of ranges of text which have tag TAGNAME.""" # tag_remove and tag_delete are different - def tag_remove(self, tagName: str, index1: _TextIndex, index2: _TextIndex | None = None) -> None: + def tag_remove( + self, + tagName: str, + index1: str | float | _tkinter.Tcl_Obj | Widget, + index2: str | float | _tkinter.Tcl_Obj | Widget | None = None, + ) -> None: """Remove tag TAGNAME from all characters between INDEX1 and INDEX2.""" @overload - def window_cget(self, index: _TextIndex, option: Literal["padx", "pady"]) -> int: + def window_cget(self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["padx", "pady"]) -> int: """Return the value of OPTION of an embedded window at INDEX.""" @overload - def window_cget(self, index: _TextIndex, option: Literal["stretch"]) -> bool: ... # actually returns Literal[0, 1] + def window_cget( + self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["stretch"] + ) -> bool: ... # actually returns Literal[0, 1] @overload - def window_cget(self, index: _TextIndex, option: Literal["align"]) -> Literal["baseline", "bottom", "center", "top"]: ... + def window_cget( + self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["align"] + ) -> Literal["baseline", "bottom", "center", "top"]: ... @overload # window is set to a widget, but read as the string name. - def window_cget(self, index: _TextIndex, option: Literal["create", "window"]) -> str: ... + def window_cget(self, index: str | float | _tkinter.Tcl_Obj | Widget, option: Literal["create", "window"]) -> str: ... @overload - def window_cget(self, index: _TextIndex, option: str) -> Any: ... + def window_cget(self, index: str | float | _tkinter.Tcl_Obj | Widget, option: str) -> Any: ... @overload - def window_configure(self, index: _TextIndex, cnf: str) -> tuple[str, str, str, str, str | int]: + def window_configure(self, index: str | float | _tkinter.Tcl_Obj | Widget, cnf: str) -> tuple[str, str, str, str, str | int]: """Configure an embedded window at INDEX.""" @overload def window_configure( self, - index: _TextIndex, + index: str | float | _tkinter.Tcl_Obj | Widget, cnf: dict[str, Any] | None = None, *, align: Literal["baseline", "bottom", "center", "top"] = ..., @@ -5913,7 +6004,7 @@ class Text(Widget, XView, YView): window_config = window_configure def window_create( self, - index: _TextIndex, + index: str | float | _tkinter.Tcl_Obj | Widget, cnf: dict[str, Any] | None = {}, *, align: Literal["baseline", "bottom", "center", "top"] = ..., diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/turtle.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/turtle.pyi index 87b56b7e49..b0e7c1bf29 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/turtle.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/turtle.pyi @@ -399,7 +399,9 @@ class Shape: an image or a list constructed using the addcomponent method. """ - def __init__(self, type_: str, data: _PolygonCoords | PhotoImage | None = None) -> None: ... + def __init__( + self, type_: Literal["polygon", "image", "compound"], data: _PolygonCoords | PhotoImage | None = None + ) -> None: ... def addcomponent(self, poly: _PolygonCoords, fill: _Color, outline: _Color | None = None) -> None: """Add component to a shape of type compound. @@ -425,7 +427,9 @@ class TurtleScreen(TurtleScreenBase): which is Tkinter in this case. """ - def __init__(self, cv: Canvas, mode: str = "standard", colormode: float = 1.0, delay: int = 10) -> None: ... + def __init__( + self, cv: Canvas, mode: Literal["standard", "logo", "world"] = "standard", colormode: float = 1.0, delay: int = 10 + ) -> None: ... def clear(self) -> None: """Delete all drawings and all turtles from the TurtleScreen. @@ -465,7 +469,7 @@ class TurtleScreen(TurtleScreenBase): """ @overload - def mode(self, mode: str) -> None: ... + def mode(self, mode: Literal["standard", "logo", "world"]) -> None: ... def setworldcoordinates(self, llx: float, lly: float, urx: float, ury: float) -> None: """Set up a user defined coordinate-system. @@ -832,7 +836,7 @@ class TNavigator: DEFAULT_MODE: str DEFAULT_ANGLEOFFSET: int DEFAULT_ANGLEORIENT: int - def __init__(self, mode: str = "standard") -> None: ... + def __init__(self, mode: Literal["standard", "logo", "world"] = "standard") -> None: ... def reset(self) -> None: """reset turtle to its initial values @@ -1214,7 +1218,7 @@ class TPen: Implements drawing properties. """ - def __init__(self, resizemode: str = "noresize") -> None: ... + def __init__(self, resizemode: Literal["auto", "user", "noresize"] = "noresize") -> None: ... @overload def resizemode(self, rmode: None = None) -> str: """Set resizemode to one of the values: "auto", "user", "noresize". @@ -1240,7 +1244,7 @@ class TPen: """ @overload - def resizemode(self, rmode: str) -> None: ... + def resizemode(self, rmode: Literal["auto", "user", "noresize"]) -> None: ... @overload def pensize(self, width: None = None) -> int: """Set or return the line thickness. @@ -1543,7 +1547,7 @@ class TPen: fillcolor: _Color = ..., pensize: int = ..., speed: int = ..., - resizemode: str = ..., + resizemode: Literal["auto", "user", "noresize"] = ..., stretchfactor: tuple[float, float] = ..., outline: int = ..., tilt: float = ..., @@ -2324,7 +2328,7 @@ def mode(mode: None = None) -> str: """ @overload -def mode(mode: str) -> None: ... +def mode(mode: Literal["standard", "logo", "world"]) -> None: ... def setworldcoordinates(llx: float, lly: float, urx: float, ury: float) -> None: """Set up a user defined coordinate-system. @@ -3166,7 +3170,7 @@ def resizemode(rmode: None = None) -> str: """ @overload -def resizemode(rmode: str) -> None: ... +def resizemode(rmode: Literal["auto", "user", "noresize"]) -> None: ... @overload def pensize(width: None = None) -> int: """Set or return the line thickness. @@ -3463,7 +3467,7 @@ def pen( fillcolor: _Color = ..., pensize: int = ..., speed: int = ..., - resizemode: str = ..., + resizemode: Literal["auto", "user", "noresize"] = ..., stretchfactor: tuple[float, float] = ..., outline: int = ..., tilt: float = ..., diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/types.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/types.pyi index b5769ad86e..9c428718da 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/types.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/types.pyi @@ -69,7 +69,7 @@ if sys.version_info >= (3, 13): _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") -_KT = TypeVar("_KT") +_KT_co = TypeVar("_KT_co", covariant=True) _VT_co = TypeVar("_VT_co", covariant=True) # Make sure this class definition stays roughly in line with `builtins.function` @@ -337,51 +337,51 @@ class CodeType: __replace__ = replace @final -class MappingProxyType(Mapping[_KT, _VT_co]): +class MappingProxyType(Mapping[_KT_co, _VT_co]): # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] """Read-only proxy of a mapping.""" __hash__: ClassVar[None] # type: ignore[assignment] - def __new__(cls, mapping: SupportsKeysAndGetItem[_KT, _VT_co]) -> Self: ... - def __getitem__(self, key: _KT, /) -> _VT_co: + def __new__(cls, mapping: SupportsKeysAndGetItem[_KT_co, _VT_co]) -> Self: ... + def __getitem__(self, key: _KT_co, /) -> _VT_co: # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] """Return self[key].""" - def __iter__(self) -> Iterator[_KT]: + def __iter__(self) -> Iterator[_KT_co]: """Implement iter(self).""" def __len__(self) -> int: """Return len(self).""" def __eq__(self, value: object, /) -> bool: ... - def copy(self) -> dict[_KT, _VT_co]: + def copy(self) -> dict[_KT_co, _VT_co]: """D.copy() -> a shallow copy of D""" - def keys(self) -> KeysView[_KT]: + def keys(self) -> KeysView[_KT_co]: """D.keys() -> a set-like object providing a view on D's keys""" def values(self) -> ValuesView[_VT_co]: """D.values() -> an object providing a view on D's values""" - def items(self) -> ItemsView[_KT, _VT_co]: + def items(self) -> ItemsView[_KT_co, _VT_co]: """D.items() -> a set-like object providing a view on D's items""" @overload - def get(self, key: _KT, /) -> _VT_co | None: + def get(self, key: _KT_co, /) -> _VT_co | None: # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter """Return the value for key if key is in the mapping, else default.""" @overload - def get(self, key: _KT, default: _VT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter + def get(self, key: _KT_co, default: _VT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter @overload - def get(self, key: _KT, default: _T2, /) -> _VT_co | _T2: ... + def get(self, key: _KT_co, default: _T2, /) -> _VT_co | _T2: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # Covariant type as parameter def __class_getitem__(cls, item: Any, /) -> GenericAlias: """See PEP 585""" - def __reversed__(self) -> Iterator[_KT]: + def __reversed__(self) -> Iterator[_KT_co]: """D.__reversed__() -> reverse iterator""" - def __or__(self, value: Mapping[_T1, _T2], /) -> dict[_KT | _T1, _VT_co | _T2]: + def __or__(self, value: Mapping[_T1, _T2], /) -> dict[_KT_co | _T1, _VT_co | _T2]: """Return self|value.""" - def __ror__(self, value: Mapping[_T1, _T2], /) -> dict[_KT | _T1, _VT_co | _T2]: + def __ror__(self, value: Mapping[_T1, _T2], /) -> dict[_KT_co | _T1, _VT_co | _T2]: """Return value|self.""" if sys.version_info >= (3, 12): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/typing.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/typing.pyi index 2555ba3934..b5533714ac 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/typing.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/typing.pyi @@ -1963,6 +1963,15 @@ def _type_repr(obj: object) -> str: """ if sys.version_info >= (3, 12): + _TypeParameter: typing_extensions.TypeAlias = ( + TypeVar + | typing_extensions.TypeVar + | ParamSpec + | typing_extensions.ParamSpec + | TypeVarTuple + | typing_extensions.TypeVarTuple + ) + def override(method: _F, /) -> _F: """Indicate that a method is intended to override a method in a base class. @@ -2015,11 +2024,11 @@ if sys.version_info >= (3, 12): See PEP 695 for more information. """ - def __new__(cls, name: str, value: Any, *, type_params: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] = ()) -> Self: ... + def __new__(cls, name: str, value: Any, *, type_params: tuple[_TypeParameter, ...] = ()) -> Self: ... @property def __value__(self) -> Any: ... # AnnotationForm @property - def __type_params__(self) -> tuple[TypeVar | ParamSpec | TypeVarTuple, ...]: ... + def __type_params__(self) -> tuple[_TypeParameter, ...]: ... @property def __parameters__(self) -> tuple[Any, ...]: ... # AnnotationForm @property diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/typing_extensions.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/typing_extensions.pyi index bc5347a4b3..1e81194ead 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/typing_extensions.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/typing_extensions.pyi @@ -303,7 +303,7 @@ class _TypedDict(Mapping[str, object], metaclass=abc.ABCMeta): __readonly_keys__: ClassVar[frozenset[str]] __mutable_keys__: ClassVar[frozenset[str]] # PEP 728 - __closed__: ClassVar[bool] + __closed__: ClassVar[bool | None] __extra_items__: ClassVar[AnnotationForm] def copy(self) -> Self: ... # Using Never so that only calls using mypy plugin hook that specialize the signature diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.pyi index b56f886214..99c3f287b6 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/xml/etree/ElementTree.pyi @@ -143,7 +143,7 @@ class Element(Generic[_Tag]): def __init__(self, tag: _Tag, attrib: dict[str, str] = {}, **extra: str) -> None: ... def append(self, subelement: Element[Any], /) -> None: ... def clear(self) -> None: ... - def extend(self, elements: Iterable[Element], /) -> None: ... + def extend(self, elements: Iterable[Element[Any]], /) -> None: ... def find(self, path: str, namespaces: dict[str, str] | None = None) -> Element | None: ... def findall(self, path: str, namespaces: dict[str, str] | None = None) -> list[Element]: ... @overload @@ -154,7 +154,7 @@ class Element(Generic[_Tag]): def get(self, key: str, default: None = None) -> str | None: ... @overload def get(self, key: str, default: _T) -> str | _T: ... - def insert(self, index: int, subelement: Element, /) -> None: ... + def insert(self, index: int, subelement: Element[Any], /) -> None: ... def items(self) -> ItemsView[str, str]: ... def iter(self, tag: str | None = None) -> Generator[Element, None, None]: ... @overload @@ -165,7 +165,7 @@ class Element(Generic[_Tag]): def keys(self) -> dict_keys[str, str]: ... # makeelement returns the type of self in Python impl, but not in C impl def makeelement(self, tag: _OtherTag, attrib: dict[str, str], /) -> Element[_OtherTag]: ... - def remove(self, subelement: Element, /) -> None: ... + def remove(self, subelement: Element[Any], /) -> None: ... def set(self, key: str, value: str, /) -> None: ... def __copy__(self) -> Element[_Tag]: ... # returns the type of self in Python impl, but not in C impl def __deepcopy__(self, memo: Any, /) -> Element: ... # Only exists in C impl @@ -183,18 +183,18 @@ class Element(Generic[_Tag]): # Doesn't actually exist at runtime, but instance of the class are indeed iterable due to __getitem__. def __iter__(self) -> Iterator[Element]: ... @overload - def __setitem__(self, key: SupportsIndex, value: Element, /) -> None: + def __setitem__(self, key: SupportsIndex, value: Element[Any], /) -> None: """Set self[key] to value.""" @overload - def __setitem__(self, key: slice, value: Iterable[Element], /) -> None: ... + def __setitem__(self, key: slice, value: Iterable[Element[Any]], /) -> None: ... # Doesn't really exist in earlier versions, where __len__ is called implicitly instead @deprecated("Testing an element's truth value is deprecated.") def __bool__(self) -> bool: """True if self else False""" -def SubElement(parent: Element, tag: str, attrib: dict[str, str] = ..., **extra: str) -> Element: ... +def SubElement(parent: Element[Any], tag: str, attrib: dict[str, str] = ..., **extra: str) -> Element: ... def Comment(text: str | None = None) -> Element[_ElementCallable]: """Comment element factory. @@ -256,7 +256,7 @@ class ElementTree(Generic[_Root]): """ - def __init__(self, element: Element | None = None, file: _FileRead | None = None) -> None: ... + def __init__(self, element: Element[Any] | None = None, file: _FileRead | None = None) -> None: ... def getroot(self) -> _Root: """Return root element of this tree.""" @@ -389,7 +389,7 @@ def register_namespace(prefix: str, uri: str) -> None: @overload def tostring( - element: Element, + element: Element[Any], encoding: None = None, method: Literal["xml", "html", "text", "c14n"] | None = None, *, @@ -413,7 +413,7 @@ def tostring( @overload def tostring( - element: Element, + element: Element[Any], encoding: Literal["unicode"], method: Literal["xml", "html", "text", "c14n"] | None = None, *, @@ -423,7 +423,7 @@ def tostring( ) -> str: ... @overload def tostring( - element: Element, + element: Element[Any], encoding: str, method: Literal["xml", "html", "text", "c14n"] | None = None, *, @@ -433,7 +433,7 @@ def tostring( ) -> Any: ... @overload def tostringlist( - element: Element, + element: Element[Any], encoding: None = None, method: Literal["xml", "html", "text", "c14n"] | None = None, *, @@ -443,7 +443,7 @@ def tostringlist( ) -> list[bytes]: ... @overload def tostringlist( - element: Element, + element: Element[Any], encoding: Literal["unicode"], method: Literal["xml", "html", "text", "c14n"] | None = None, *, @@ -453,7 +453,7 @@ def tostringlist( ) -> list[str]: ... @overload def tostringlist( - element: Element, + element: Element[Any], encoding: str, method: Literal["xml", "html", "text", "c14n"] | None = None, *, @@ -461,7 +461,7 @@ def tostringlist( default_namespace: str | None = None, short_empty_elements: bool = True, ) -> list[Any]: ... -def dump(elem: Element | ElementTree[Any]) -> None: +def dump(elem: Element[Any] | ElementTree[Any]) -> None: """Write element tree or element structure to sys.stdout. This function should be used for debugging only. @@ -472,7 +472,7 @@ def dump(elem: Element | ElementTree[Any]) -> None: """ -def indent(tree: Element | ElementTree[Any], space: str = " ", level: int = 0) -> None: +def indent(tree: Element[Any] | ElementTree[Any], space: str = " ", level: int = 0) -> None: """Indent an XML document by inserting newlines and indentation space after elements. From cb4d4493d7275951e03a7df7846b4d9d22952b27 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 15 Oct 2025 11:36:05 +0200 Subject: [PATCH 045/113] Remove `strip` from release profile (#20885) --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 62305e93cd..40afa8c5a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,7 +268,6 @@ large_stack_arrays = "allow" [profile.release] -strip = true lto = "fat" codegen-units = 16 From 270ba71ad5465521e7a4823a9abaf86629909edd Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 15 Oct 2025 12:38:17 +0200 Subject: [PATCH 046/113] [ty] CI: Faster ecosystem analysis (#20886) ## Summary I considered making a dedicated cargo profile for these, but the `profiling` profile basically made all the modifications to `release` that I would have also made. ## Test Plan CI on this PR --- .github/workflows/ty-ecosystem-analyzer.yaml | 4 ++-- .github/workflows/ty-ecosystem-report.yaml | 4 ++-- scripts/mypy_primer.sh | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ty-ecosystem-analyzer.yaml b/.github/workflows/ty-ecosystem-analyzer.yaml index ae847d1684..11000041ad 100644 --- a/.github/workflows/ty-ecosystem-analyzer.yaml +++ b/.github/workflows/ty-ecosystem-analyzer.yaml @@ -64,12 +64,12 @@ jobs: cd .. - uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@279f8a15b0e7f77213bf9096dbc2335a19ef89c5" + uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@908758da02a73ef3f3308e1dbb2248510029bbe4" ecosystem-analyzer \ --repository ruff \ diff \ - --profile=release \ + --profile=profiling \ --projects-old ruff/projects_old.txt \ --projects-new ruff/projects_new.txt \ --old old_commit \ diff --git a/.github/workflows/ty-ecosystem-report.yaml b/.github/workflows/ty-ecosystem-report.yaml index cf54bb4e1a..e3348b749b 100644 --- a/.github/workflows/ty-ecosystem-report.yaml +++ b/.github/workflows/ty-ecosystem-report.yaml @@ -49,13 +49,13 @@ jobs: cd .. - uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@279f8a15b0e7f77213bf9096dbc2335a19ef89c5" + uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@908758da02a73ef3f3308e1dbb2248510029bbe4" ecosystem-analyzer \ --verbose \ --repository ruff \ analyze \ - --profile=release \ + --profile=profiling \ --projects ruff/crates/ty_python_semantic/resources/primer/good.txt \ --output ecosystem-diagnostics.json diff --git a/scripts/mypy_primer.sh b/scripts/mypy_primer.sh index 97d3c0bdc7..c140721745 100755 --- a/scripts/mypy_primer.sh +++ b/scripts/mypy_primer.sh @@ -20,10 +20,11 @@ cd .. echo "Project selector: ${PRIMER_SELECTOR}" # Allow the exit code to be 0 or 1, only fail for actual mypy_primer crashes/bugs uvx \ - --from="git+https://github.com/hauntsaninja/mypy_primer@1dcfa196e82a25bcc839a44d2a6d17953859f6ed" \ + --from="git+https://github.com/hauntsaninja/mypy_primer@ab5d30e2d4ecdaf7d6cc89395c7130143d6d3c82" \ mypy_primer \ --repo ruff \ --type-checker ty \ + --cargo-profile profiling \ --old base_commit \ --new "${GITHUB_SHA}" \ --project-selector "/($PRIMER_SELECTOR)\$" \ From c6959381f898f4ffd768b7f058ebcf904b489b6c Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 15 Oct 2025 12:39:31 +0200 Subject: [PATCH 047/113] Remove `release` CI job (#20887) --- .github/workflows/ci.yaml | 48 +++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d8606cd463..7e5bd027e6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -361,6 +361,37 @@ jobs: cargo nextest run --all-features --profile ci cargo test --all-features --doc + cargo-test-macos: + name: "cargo test (macos)" + runs-on: depot-windows-2022-16 + needs: determine_changes + if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + - name: "Install Rust toolchain" + run: rustup show + - name: "Install mold" + uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - name: "Install cargo nextest" + uses: taiki-e/install-action@522492a8c115f1b6d4d318581f09638e9442547b # v2.62.21 + with: + tool: cargo-nextest + - name: "Install uv" + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + enable-cache: "true" + - name: "Run tests" + shell: bash + env: + NEXTEST_PROFILE: "ci" + run: | + cargo nextest run --all-features --profile ci + cargo test --all-features --doc + cargo-test-wasm: name: "cargo test (wasm)" runs-on: ubuntu-latest @@ -391,23 +422,6 @@ jobs: cd crates/ty_wasm wasm-pack test --node - cargo-build-release: - name: "cargo build (release)" - runs-on: macos-latest - if: ${{ github.ref == 'refs/heads/main' }} - timeout-minutes: 20 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 - - name: "Install Rust toolchain" - run: rustup show - - name: "Install mold" - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - - name: "Build" - run: cargo build --release --locked - cargo-build-msrv: name: "cargo build (msrv)" runs-on: depot-ubuntu-latest-8 From 85ff4f3eefc967ad7e283a0d7f2f417f95984ada Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 15 Oct 2025 14:41:33 +0200 Subject: [PATCH 048/113] Run macos tests on macos (#20889) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e5bd027e6..4b0a223a8f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -363,7 +363,7 @@ jobs: cargo-test-macos: name: "cargo test (macos)" - runs-on: depot-windows-2022-16 + runs-on: macos-latest needs: determine_changes if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 From 8817ea5c84cc2236e208eca91a133d86b82b2811 Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Wed, 15 Oct 2025 09:05:15 -0400 Subject: [PATCH 049/113] [ty] Add (unused) `inferable` parameter to type property methods (#20865) A large part of the diff on #20677 just involves threading a new `inferable` parameter through all of the type property methods. In the interests of making that PR easier to review, I've pulled that bit out into here, so that it can be reviewed in isolation. This should be a pure refactoring, with no logic changes or behavioral changes. --- crates/ty_python_semantic/src/types.rs | 412 ++++++++++++++---- .../ty_python_semantic/src/types/call/bind.rs | 78 +++- crates/ty_python_semantic/src/types/class.rs | 11 +- .../ty_python_semantic/src/types/function.rs | 52 +-- .../ty_python_semantic/src/types/generics.rs | 55 ++- .../src/types/infer/builder.rs | 13 +- .../ty_python_semantic/src/types/instance.rs | 19 +- .../src/types/protocol_class.rs | 21 +- .../src/types/signatures.rs | 55 +-- .../src/types/subclass_of.rs | 4 + crates/ty_python_semantic/src/types/tuple.rs | 45 +- 11 files changed, 580 insertions(+), 185 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 777a2f2ee8..3f8702c858 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -53,8 +53,8 @@ use crate::types::function::{ DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction, }; use crate::types::generics::{ - GenericContext, PartialSpecialization, Specialization, bind_typevar, typing_self, - walk_generic_context, + GenericContext, InferableTypeVars, PartialSpecialization, Specialization, bind_typevar, + typing_self, walk_generic_context, }; use crate::types::infer::infer_unpack_types; use crate::types::mro::{Mro, MroError, MroIterator}; @@ -571,21 +571,27 @@ impl<'db> PropertyInstanceType<'db> { } } - fn when_equivalent_to(self, db: &'db dyn Db, other: Self) -> ConstraintSet<'db> { - self.is_equivalent_to_impl(db, other, &IsEquivalentVisitor::default()) + fn when_equivalent_to( + self, + db: &'db dyn Db, + other: Self, + inferable: InferableTypeVars<'_, 'db>, + ) -> ConstraintSet<'db> { + self.is_equivalent_to_impl(db, other, inferable, &IsEquivalentVisitor::default()) } fn is_equivalent_to_impl( self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { let getter_equivalence = if let Some(getter) = self.getter(db) { let Some(other_getter) = other.getter(db) else { return ConstraintSet::from(false); }; - getter.is_equivalent_to_impl(db, other_getter, visitor) + getter.is_equivalent_to_impl(db, other_getter, inferable, visitor) } else { if other.getter(db).is_some() { return ConstraintSet::from(false); @@ -598,7 +604,7 @@ impl<'db> PropertyInstanceType<'db> { let Some(other_setter) = other.setter(db) else { return ConstraintSet::from(false); }; - setter.is_equivalent_to_impl(db, other_setter, visitor) + setter.is_equivalent_to_impl(db, other_setter, inferable, visitor) } else { if other.setter(db).is_some() { return ConstraintSet::from(false); @@ -1197,9 +1203,18 @@ impl<'db> Type<'db> { } /// Remove the union elements that are not related to `target`. - pub(crate) fn filter_disjoint_elements(self, db: &'db dyn Db, target: Type<'db>) -> Type<'db> { + pub(crate) fn filter_disjoint_elements( + self, + db: &'db dyn Db, + target: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, + ) -> Type<'db> { if let Type::Union(union) = self { - union.filter(db, |elem| !elem.is_disjoint_from(db, target)) + union.filter(db, |elem| { + !elem + .when_disjoint_from(db, target, inferable) + .is_always_satisfied() + }) } else { self } @@ -1495,29 +1510,41 @@ impl<'db> Type<'db> { /// /// See [`TypeRelation::Subtyping`] for more details. pub(crate) fn is_subtype_of(self, db: &'db dyn Db, target: Type<'db>) -> bool { - self.when_subtype_of(db, target).is_always_satisfied() + self.when_subtype_of(db, target, InferableTypeVars::None) + .is_always_satisfied() } - fn when_subtype_of(self, db: &'db dyn Db, target: Type<'db>) -> ConstraintSet<'db> { - self.has_relation_to(db, target, TypeRelation::Subtyping) + fn when_subtype_of( + self, + db: &'db dyn Db, + target: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, + ) -> ConstraintSet<'db> { + self.has_relation_to(db, target, inferable, TypeRelation::Subtyping) } /// Return true if this type is assignable to type `target`. /// /// See [`TypeRelation::Assignability`] for more details. pub(crate) fn is_assignable_to(self, db: &'db dyn Db, target: Type<'db>) -> bool { - self.when_assignable_to(db, target).is_always_satisfied() + self.when_assignable_to(db, target, InferableTypeVars::None) + .is_always_satisfied() } - fn when_assignable_to(self, db: &'db dyn Db, target: Type<'db>) -> ConstraintSet<'db> { - self.has_relation_to(db, target, TypeRelation::Assignability) + fn when_assignable_to( + self, + db: &'db dyn Db, + target: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, + ) -> ConstraintSet<'db> { + self.has_relation_to(db, target, inferable, TypeRelation::Assignability) } /// Return `true` if it would be redundant to add `self` to a union that already contains `other`. /// /// See [`TypeRelation::Redundancy`] for more details. pub(crate) fn is_redundant_with(self, db: &'db dyn Db, other: Type<'db>) -> bool { - self.has_relation_to(db, other, TypeRelation::Redundancy) + self.has_relation_to(db, other, InferableTypeVars::None, TypeRelation::Redundancy) .is_always_satisfied() } @@ -1525,11 +1552,13 @@ impl<'db> Type<'db> { self, db: &'db dyn Db, target: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, ) -> ConstraintSet<'db> { self.has_relation_to_impl( db, target, + inferable, relation, &HasRelationToVisitor::default(), &IsDisjointVisitor::default(), @@ -1540,6 +1569,7 @@ impl<'db> Type<'db> { self, db: &'db dyn Db, target: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -1579,6 +1609,7 @@ impl<'db> Type<'db> { self_alias.value_type(db).has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1591,6 +1622,7 @@ impl<'db> Type<'db> { self.has_relation_to_impl( db, target_alias.value_type(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -1607,6 +1639,7 @@ impl<'db> Type<'db> { field.default_type(db).has_relation_to_impl( db, right, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1689,6 +1722,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1698,6 +1732,7 @@ impl<'db> Type<'db> { constraint.has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1719,6 +1754,7 @@ impl<'db> Type<'db> { self.has_relation_to_impl( db, *constraint, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1739,6 +1775,7 @@ impl<'db> Type<'db> { self.has_relation_to_impl( db, *constraint, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1763,6 +1800,7 @@ impl<'db> Type<'db> { elem_ty.has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1773,6 +1811,7 @@ impl<'db> Type<'db> { self.has_relation_to_impl( db, elem_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1789,6 +1828,7 @@ impl<'db> Type<'db> { self.has_relation_to_impl( db, pos_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1820,6 +1860,7 @@ impl<'db> Type<'db> { self_ty.is_disjoint_from_impl( db, neg_ty, + inferable, disjointness_visitor, relation_visitor, ) @@ -1831,6 +1872,7 @@ impl<'db> Type<'db> { elem_ty.has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1852,6 +1894,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, bound, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1865,6 +1908,7 @@ impl<'db> Type<'db> { self.has_relation_to_impl( db, bound, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1886,7 +1930,7 @@ impl<'db> Type<'db> { (left, Type::AlwaysTruthy) => ConstraintSet::from(left.bool(db).is_always_true()), // Currently, the only supertype of `AlwaysFalsy` and `AlwaysTruthy` is the universal set (object instance). (Type::AlwaysFalsy | Type::AlwaysTruthy, _) => { - target.when_equivalent_to(db, Type::object()) + target.when_equivalent_to(db, Type::object(), inferable) } // These clauses handle type variants that include function literals. A function @@ -1895,18 +1939,33 @@ impl<'db> Type<'db> { // applied to the signature. Different specializations of the same function literal are // only subtypes of each other if they result in the same signature. (Type::FunctionLiteral(self_function), Type::FunctionLiteral(target_function)) => { - self_function.has_relation_to_impl(db, target_function, relation, relation_visitor) + self_function.has_relation_to_impl( + db, + target_function, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) } (Type::BoundMethod(self_method), Type::BoundMethod(target_method)) => self_method .has_relation_to_impl( db, target_method, + inferable, relation, relation_visitor, disjointness_visitor, ), (Type::KnownBoundMethod(self_method), Type::KnownBoundMethod(target_method)) => { - self_method.has_relation_to_impl(db, target_method, relation, relation_visitor) + self_method.has_relation_to_impl( + db, + target_method, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) } // No literal type is a subtype of any other literal type, unless they are the same @@ -1935,6 +1994,7 @@ impl<'db> Type<'db> { self_callable.has_relation_to_impl( db, other_callable, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1946,6 +2006,7 @@ impl<'db> Type<'db> { callable.has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1958,6 +2019,7 @@ impl<'db> Type<'db> { self.satisfies_protocol( db, protocol, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2001,6 +2063,7 @@ impl<'db> Type<'db> { instance.has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2008,16 +2071,36 @@ impl<'db> Type<'db> { }), // The same reasoning applies for these special callable types: - (Type::BoundMethod(_), _) => KnownClass::MethodType - .to_instance(db) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), - (Type::KnownBoundMethod(method), _) => method - .class() - .to_instance(db) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), + (Type::BoundMethod(_), _) => { + KnownClass::MethodType.to_instance(db).has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + } + (Type::KnownBoundMethod(method), _) => { + method.class().to_instance(db).has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + } (Type::WrapperDescriptor(_), _) => KnownClass::WrapperDescriptorType .to_instance(db) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), + .has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), (Type::DataclassDecorator(_) | Type::DataclassTransformer(_), _) => { // TODO: Implement subtyping using an equivalent `Callable` type. @@ -2030,6 +2113,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, right.return_type(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -2038,6 +2122,7 @@ impl<'db> Type<'db> { right.return_type(db).has_relation_to_impl( db, left.return_type(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -2048,6 +2133,7 @@ impl<'db> Type<'db> { (Type::TypeIs(_), _) => KnownClass::Bool.to_instance(db).has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2060,6 +2146,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2068,10 +2155,13 @@ impl<'db> Type<'db> { (Type::Callable(_), _) => ConstraintSet::from(false), - (Type::BoundSuper(_), Type::BoundSuper(_)) => self.when_equivalent_to(db, target), + (Type::BoundSuper(_), Type::BoundSuper(_)) => { + self.when_equivalent_to(db, target, inferable) + } (Type::BoundSuper(_), _) => KnownClass::Super.to_instance(db).has_relation_to_impl( db, target, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2086,6 +2176,7 @@ impl<'db> Type<'db> { ClassType::NonGeneric(class).has_relation_to_impl( db, subclass_of_class, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2099,6 +2190,7 @@ impl<'db> Type<'db> { ClassType::Generic(alias).has_relation_to_impl( db, subclass_of_class, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2111,6 +2203,7 @@ impl<'db> Type<'db> { self_subclass_ty.has_relation_to_impl( db, target_subclass_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2120,12 +2213,26 @@ impl<'db> Type<'db> { // `Literal[str]` is a subtype of `type` because the `str` class object is an instance of its metaclass `type`. // `Literal[abc.ABC]` is a subtype of `abc.ABCMeta` because the `abc.ABC` class object // is an instance of its metaclass `abc.ABCMeta`. - (Type::ClassLiteral(class), _) => class - .metaclass_instance_type(db) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), + (Type::ClassLiteral(class), _) => { + class.metaclass_instance_type(db).has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + } (Type::GenericAlias(alias), _) => ClassType::from(alias) .metaclass_instance_type(db) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), + .has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), // `type[Any]` is a subtype of `type[object]`, and is assignable to any `type[...]` (Type::SubclassOf(subclass_of_ty), other) if subclass_of_ty.is_dynamic() => { @@ -2134,6 +2241,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, other, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2143,6 +2251,7 @@ impl<'db> Type<'db> { other.has_relation_to_impl( db, KnownClass::Type.to_instance(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -2158,6 +2267,7 @@ impl<'db> Type<'db> { other.has_relation_to_impl( db, KnownClass::Type.to_instance(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -2176,7 +2286,14 @@ impl<'db> Type<'db> { .into_class() .map(|class| class.metaclass_instance_type(db)) .unwrap_or_else(|| KnownClass::Type.to_instance(db)) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), + .has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), // For example: `Type::SpecialForm(SpecialFormType::Type)` is a subtype of `Type::NominalInstance(_SpecialForm)`, // because `Type::SpecialForm(SpecialFormType::Type)` is a set with exactly one runtime value in it @@ -2184,6 +2301,7 @@ impl<'db> Type<'db> { (Type::SpecialForm(left), right) => left.instance_fallback(db).has_relation_to_impl( db, right, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2192,6 +2310,7 @@ impl<'db> Type<'db> { (Type::KnownInstance(left), right) => left.instance_fallback(db).has_relation_to_impl( db, right, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2204,6 +2323,7 @@ impl<'db> Type<'db> { self_instance.has_relation_to_impl( db, target_instance, + inferable, relation, relation_visitor, disjointness_visitor, @@ -2211,12 +2331,20 @@ impl<'db> Type<'db> { }) } - (Type::PropertyInstance(_), _) => KnownClass::Property - .to_instance(db) - .has_relation_to_impl(db, target, relation, relation_visitor, disjointness_visitor), + (Type::PropertyInstance(_), _) => { + KnownClass::Property.to_instance(db).has_relation_to_impl( + db, + target, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + } (_, Type::PropertyInstance(_)) => self.has_relation_to_impl( db, KnownClass::Property.to_instance(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -2243,17 +2371,24 @@ impl<'db> Type<'db> { /// /// [equivalent to]: https://typing.python.org/en/latest/spec/glossary.html#term-equivalent pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Type<'db>) -> bool { - self.when_equivalent_to(db, other).is_always_satisfied() + self.when_equivalent_to(db, other, InferableTypeVars::None) + .is_always_satisfied() } - fn when_equivalent_to(self, db: &'db dyn Db, other: Type<'db>) -> ConstraintSet<'db> { - self.is_equivalent_to_impl(db, other, &IsEquivalentVisitor::default()) + fn when_equivalent_to( + self, + db: &'db dyn Db, + other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, + ) -> ConstraintSet<'db> { + self.is_equivalent_to_impl(db, other, inferable, &IsEquivalentVisitor::default()) } pub(crate) fn is_equivalent_to_impl( self, db: &'db dyn Db, other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self == other { @@ -2282,44 +2417,44 @@ impl<'db> Type<'db> { (Type::TypeAlias(self_alias), _) => { let self_alias_ty = self_alias.value_type(db).normalized(db); visitor.visit((self_alias_ty, other), || { - self_alias_ty.is_equivalent_to_impl(db, other, visitor) + self_alias_ty.is_equivalent_to_impl(db, other, inferable, visitor) }) } (_, Type::TypeAlias(other_alias)) => { let other_alias_ty = other_alias.value_type(db).normalized(db); visitor.visit((self, other_alias_ty), || { - self.is_equivalent_to_impl(db, other_alias_ty, visitor) + self.is_equivalent_to_impl(db, other_alias_ty, inferable, visitor) }) } (Type::NominalInstance(first), Type::NominalInstance(second)) => { - first.is_equivalent_to_impl(db, second, visitor) + first.is_equivalent_to_impl(db, second, inferable, visitor) } (Type::Union(first), Type::Union(second)) => { - first.is_equivalent_to_impl(db, second, visitor) + first.is_equivalent_to_impl(db, second, inferable, visitor) } (Type::Intersection(first), Type::Intersection(second)) => { - first.is_equivalent_to_impl(db, second, visitor) + first.is_equivalent_to_impl(db, second, inferable, visitor) } (Type::FunctionLiteral(self_function), Type::FunctionLiteral(target_function)) => { - self_function.is_equivalent_to_impl(db, target_function, visitor) + self_function.is_equivalent_to_impl(db, target_function, inferable, visitor) } (Type::BoundMethod(self_method), Type::BoundMethod(target_method)) => { - self_method.is_equivalent_to_impl(db, target_method, visitor) + self_method.is_equivalent_to_impl(db, target_method, inferable, visitor) } (Type::KnownBoundMethod(self_method), Type::KnownBoundMethod(target_method)) => { - self_method.is_equivalent_to_impl(db, target_method, visitor) + self_method.is_equivalent_to_impl(db, target_method, inferable, visitor) } (Type::Callable(first), Type::Callable(second)) => { - first.is_equivalent_to_impl(db, second, visitor) + first.is_equivalent_to_impl(db, second, inferable, visitor) } (Type::ProtocolInstance(first), Type::ProtocolInstance(second)) => { - first.is_equivalent_to_impl(db, second, visitor) + first.is_equivalent_to_impl(db, second, inferable, visitor) } (Type::ProtocolInstance(protocol), nominal @ Type::NominalInstance(n)) | (nominal @ Type::NominalInstance(n), Type::ProtocolInstance(protocol)) => { @@ -2336,7 +2471,7 @@ impl<'db> Type<'db> { } (Type::PropertyInstance(left), Type::PropertyInstance(right)) => { - left.is_equivalent_to_impl(db, right, visitor) + left.is_equivalent_to_impl(db, right, inferable, visitor) } _ => ConstraintSet::from(false), @@ -2359,13 +2494,20 @@ impl<'db> Type<'db> { /// This function aims to have no false positives, but might return wrong /// `false` answers in some cases. pub(crate) fn is_disjoint_from(self, db: &'db dyn Db, other: Type<'db>) -> bool { - self.when_disjoint_from(db, other).is_always_satisfied() + self.when_disjoint_from(db, other, InferableTypeVars::None) + .is_always_satisfied() } - fn when_disjoint_from(self, db: &'db dyn Db, other: Type<'db>) -> ConstraintSet<'db> { + fn when_disjoint_from( + self, + db: &'db dyn Db, + other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, + ) -> ConstraintSet<'db> { self.is_disjoint_from_impl( db, other, + inferable, &IsDisjointVisitor::default(), &HasRelationToVisitor::default(), ) @@ -2375,6 +2517,7 @@ impl<'db> Type<'db> { self, db: &'db dyn Db, other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, disjointness_visitor: &IsDisjointVisitor<'db>, relation_visitor: &HasRelationToVisitor<'db>, ) -> ConstraintSet<'db> { @@ -2382,6 +2525,7 @@ impl<'db> Type<'db> { db: &'db dyn Db, protocol: ProtocolInstanceType<'db>, other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, disjointness_visitor: &IsDisjointVisitor<'db>, relation_visitor: &HasRelationToVisitor<'db>, ) -> ConstraintSet<'db> { @@ -2394,6 +2538,7 @@ impl<'db> Type<'db> { member.has_disjoint_type_from( db, attribute_type, + inferable, disjointness_visitor, relation_visitor, ) @@ -2412,6 +2557,7 @@ impl<'db> Type<'db> { self_alias_ty.is_disjoint_from_impl( db, other, + inferable, disjointness_visitor, relation_visitor, ) @@ -2424,6 +2570,7 @@ impl<'db> Type<'db> { self.is_disjoint_from_impl( db, other_alias_ty, + inferable, disjointness_visitor, relation_visitor, ) @@ -2462,12 +2609,19 @@ impl<'db> Type<'db> { match bound_typevar.typevar(db).bound_or_constraints(db) { None => ConstraintSet::from(false), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => bound - .is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor), + .is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ), Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { constraints.elements(db).iter().when_all(db, |constraint| { constraint.is_disjoint_from_impl( db, other, + inferable, disjointness_visitor, relation_visitor, ) @@ -2481,7 +2635,13 @@ impl<'db> Type<'db> { (Type::Union(union), other) | (other, Type::Union(union)) => { union.elements(db).iter().when_all(db, |e| { - e.is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor) + e.is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ) }) } @@ -2497,6 +2657,7 @@ impl<'db> Type<'db> { p.is_disjoint_from_impl( db, other, + inferable, disjointness_visitor, relation_visitor, ) @@ -2506,6 +2667,7 @@ impl<'db> Type<'db> { p.is_disjoint_from_impl( db, self, + inferable, disjointness_visitor, relation_visitor, ) @@ -2524,6 +2686,7 @@ impl<'db> Type<'db> { p.is_disjoint_from_impl( db, non_intersection, + inferable, disjointness_visitor, relation_visitor, ) @@ -2534,6 +2697,7 @@ impl<'db> Type<'db> { non_intersection.has_relation_to_impl( db, neg_ty, + inferable, TypeRelation::Subtyping, relation_visitor, disjointness_visitor, @@ -2617,7 +2781,7 @@ impl<'db> Type<'db> { (Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => disjointness_visitor .visit((self, other), || { - left.is_disjoint_from_impl(db, right, disjointness_visitor) + left.is_disjoint_from_impl(db, right, inferable, disjointness_visitor) }), (Type::ProtocolInstance(protocol), Type::SpecialForm(special_form)) @@ -2627,6 +2791,7 @@ impl<'db> Type<'db> { db, protocol, special_form.instance_fallback(db), + inferable, disjointness_visitor, relation_visitor, ) @@ -2640,6 +2805,7 @@ impl<'db> Type<'db> { db, protocol, known_instance.instance_fallback(db), + inferable, disjointness_visitor, relation_visitor, ) @@ -2703,6 +2869,7 @@ impl<'db> Type<'db> { db, protocol, ty, + inferable, disjointness_visitor, relation_visitor, ) @@ -2720,6 +2887,7 @@ impl<'db> Type<'db> { db, protocol, nominal, + inferable, disjointness_visitor, relation_visitor, ) @@ -2734,6 +2902,7 @@ impl<'db> Type<'db> { Place::Type(attribute_type, _) => member.has_disjoint_type_from( db, attribute_type, + inferable, disjointness_visitor, relation_visitor, ), @@ -2758,25 +2927,37 @@ impl<'db> Type<'db> { match subclass_of_ty.subclass_of() { SubclassOfInner::Dynamic(_) => ConstraintSet::from(false), SubclassOfInner::Class(class_a) => ClassType::from(alias_b) - .when_subclass_of(db, class_a) + .when_subclass_of(db, class_a, inferable) .negate(db), } } (Type::SubclassOf(left), Type::SubclassOf(right)) => { - left.is_disjoint_from_impl(db, right, disjointness_visitor) + left.is_disjoint_from_impl(db, right, inferable, disjointness_visitor) } // for `type[Any]`/`type[Unknown]`/`type[Todo]`, we know the type cannot be any larger than `type`, // so although the type is dynamic we can still determine disjointedness in some situations (Type::SubclassOf(subclass_of_ty), other) | (other, Type::SubclassOf(subclass_of_ty)) => match subclass_of_ty.subclass_of() { - SubclassOfInner::Dynamic(_) => KnownClass::Type - .to_instance(db) - .is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor), - SubclassOfInner::Class(class) => class - .metaclass_instance_type(db) - .is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor), + SubclassOfInner::Dynamic(_) => { + KnownClass::Type.to_instance(db).is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ) + } + SubclassOfInner::Class(class) => { + class.metaclass_instance_type(db).is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ) + } }, (Type::SpecialForm(special_form), Type::NominalInstance(instance)) @@ -2843,6 +3024,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, instance, + inferable, TypeRelation::Subtyping, relation_visitor, disjointness_visitor, @@ -2857,7 +3039,7 @@ impl<'db> Type<'db> { (Type::ClassLiteral(class), instance @ Type::NominalInstance(_)) | (instance @ Type::NominalInstance(_), Type::ClassLiteral(class)) => class .metaclass_instance_type(db) - .when_subtype_of(db, instance) + .when_subtype_of(db, instance, inferable) .negate(db), (Type::GenericAlias(alias), instance @ Type::NominalInstance(_)) | (instance @ Type::NominalInstance(_), Type::GenericAlias(alias)) => { @@ -2866,6 +3048,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, instance, + inferable, TypeRelation::Subtyping, relation_visitor, disjointness_visitor, @@ -2884,12 +3067,19 @@ impl<'db> Type<'db> { (Type::BoundMethod(_), other) | (other, Type::BoundMethod(_)) => KnownClass::MethodType .to_instance(db) - .is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor), + .is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ), (Type::KnownBoundMethod(method), other) | (other, Type::KnownBoundMethod(method)) => { method.class().to_instance(db).is_disjoint_from_impl( db, other, + inferable, disjointness_visitor, relation_visitor, ) @@ -2898,7 +3088,13 @@ impl<'db> Type<'db> { (Type::WrapperDescriptor(_), other) | (other, Type::WrapperDescriptor(_)) => { KnownClass::WrapperDescriptorType .to_instance(db) - .is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor) + .is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ) } (Type::Callable(_) | Type::FunctionLiteral(_), Type::Callable(_)) @@ -2946,6 +3142,7 @@ impl<'db> Type<'db> { .has_relation_to_impl( db, CallableType::unknown(db), + inferable, TypeRelation::Assignability, relation_visitor, disjointness_visitor, @@ -2971,6 +3168,7 @@ impl<'db> Type<'db> { other.is_disjoint_from_impl( db, KnownClass::ModuleType.to_instance(db), + inferable, disjointness_visitor, relation_visitor, ) @@ -2978,24 +3176,37 @@ impl<'db> Type<'db> { (Type::NominalInstance(left), Type::NominalInstance(right)) => disjointness_visitor .visit((self, other), || { - left.is_disjoint_from_impl(db, right, disjointness_visitor, relation_visitor) + left.is_disjoint_from_impl( + db, + right, + inferable, + disjointness_visitor, + relation_visitor, + ) }), (Type::PropertyInstance(_), other) | (other, Type::PropertyInstance(_)) => { KnownClass::Property.to_instance(db).is_disjoint_from_impl( db, other, + inferable, disjointness_visitor, relation_visitor, ) } (Type::BoundSuper(_), Type::BoundSuper(_)) => { - self.when_equivalent_to(db, other).negate(db) + self.when_equivalent_to(db, other, inferable).negate(db) + } + (Type::BoundSuper(_), other) | (other, Type::BoundSuper(_)) => { + KnownClass::Super.to_instance(db).is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ) } - (Type::BoundSuper(_), other) | (other, Type::BoundSuper(_)) => KnownClass::Super - .to_instance(db) - .is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor), } } @@ -9819,6 +10030,7 @@ impl<'db> BoundMethodType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -9828,11 +10040,19 @@ impl<'db> BoundMethodType<'db> { // differently), and of the bound self parameter (taking care that parameters, including a // bound self parameter, are contravariant.) self.function(db) - .has_relation_to_impl(db, other.function(db), relation, relation_visitor) + .has_relation_to_impl( + db, + other.function(db), + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) .and(db, || { other.self_instance(db).has_relation_to_impl( db, self.self_instance(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -9844,14 +10064,18 @@ impl<'db> BoundMethodType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { self.function(db) - .is_equivalent_to_impl(db, other.function(db), visitor) + .is_equivalent_to_impl(db, other.function(db), inferable, visitor) .and(db, || { - other - .self_instance(db) - .is_equivalent_to_impl(db, self.self_instance(db), visitor) + other.self_instance(db).is_equivalent_to_impl( + db, + self.self_instance(db), + inferable, + visitor, + ) }) } } @@ -9973,6 +10197,7 @@ impl<'db> CallableType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -9983,6 +10208,7 @@ impl<'db> CallableType<'db> { self.signatures(db).has_relation_to_impl( db, other.signatures(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -9996,6 +10222,7 @@ impl<'db> CallableType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self == other { @@ -10004,7 +10231,7 @@ impl<'db> CallableType<'db> { ConstraintSet::from(self.is_function_like(db) == other.is_function_like(db)).and(db, || { self.signatures(db) - .is_equivalent_to_impl(db, other.signatures(db), visitor) + .is_equivalent_to_impl(db, other.signatures(db), inferable, visitor) }) } } @@ -10065,19 +10292,35 @@ impl<'db> KnownBoundMethodType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, - visitor: &HasRelationToVisitor<'db>, + relation_visitor: &HasRelationToVisitor<'db>, + disjointness_visitor: &IsDisjointVisitor<'db>, ) -> ConstraintSet<'db> { match (self, other) { ( KnownBoundMethodType::FunctionTypeDunderGet(self_function), KnownBoundMethodType::FunctionTypeDunderGet(other_function), - ) => self_function.has_relation_to_impl(db, other_function, relation, visitor), + ) => self_function.has_relation_to_impl( + db, + other_function, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), ( KnownBoundMethodType::FunctionTypeDunderCall(self_function), KnownBoundMethodType::FunctionTypeDunderCall(other_function), - ) => self_function.has_relation_to_impl(db, other_function, relation, visitor), + ) => self_function.has_relation_to_impl( + db, + other_function, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ), ( KnownBoundMethodType::PropertyDunderGet(self_property), @@ -10086,7 +10329,7 @@ impl<'db> KnownBoundMethodType<'db> { | ( KnownBoundMethodType::PropertyDunderSet(self_property), KnownBoundMethodType::PropertyDunderSet(other_property), - ) => self_property.when_equivalent_to(db, other_property), + ) => self_property.when_equivalent_to(db, other_property, inferable), (KnownBoundMethodType::StrStartswith(_), KnownBoundMethodType::StrStartswith(_)) => { ConstraintSet::from(self == other) @@ -10117,18 +10360,19 @@ impl<'db> KnownBoundMethodType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { match (self, other) { ( KnownBoundMethodType::FunctionTypeDunderGet(self_function), KnownBoundMethodType::FunctionTypeDunderGet(other_function), - ) => self_function.is_equivalent_to_impl(db, other_function, visitor), + ) => self_function.is_equivalent_to_impl(db, other_function, inferable, visitor), ( KnownBoundMethodType::FunctionTypeDunderCall(self_function), KnownBoundMethodType::FunctionTypeDunderCall(other_function), - ) => self_function.is_equivalent_to_impl(db, other_function, visitor), + ) => self_function.is_equivalent_to_impl(db, other_function, inferable, visitor), ( KnownBoundMethodType::PropertyDunderGet(self_property), @@ -10137,7 +10381,7 @@ impl<'db> KnownBoundMethodType<'db> { | ( KnownBoundMethodType::PropertyDunderSet(self_property), KnownBoundMethodType::PropertyDunderSet(other_property), - ) => self_property.is_equivalent_to_impl(db, other_property, visitor), + ) => self_property.is_equivalent_to_impl(db, other_property, inferable, visitor), (KnownBoundMethodType::StrStartswith(_), KnownBoundMethodType::StrStartswith(_)) => { ConstraintSet::from(self == other) @@ -10982,6 +11226,7 @@ impl<'db> UnionType<'db> { self, db: &'db dyn Db, other: Self, + _inferable: InferableTypeVars<'_, 'db>, _visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self == other { @@ -11083,6 +11328,7 @@ impl<'db> IntersectionType<'db> { self, db: &'db dyn Db, other: Self, + _inferable: InferableTypeVars<'_, 'db>, _visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self == other { diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b1b1b074aa..41a392a88b 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -26,7 +26,9 @@ use crate::types::enums::is_enum_class; use crate::types::function::{ DataclassTransformerParams, FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral, }; -use crate::types::generics::{Specialization, SpecializationBuilder, SpecializationError}; +use crate::types::generics::{ + InferableTypeVars, Specialization, SpecializationBuilder, SpecializationError, +}; use crate::types::signatures::{Parameter, ParameterForm, ParameterKind, Parameters}; use crate::types::tuple::{TupleLength, TupleType}; use crate::types::{ @@ -597,7 +599,8 @@ impl<'db> Bindings<'db> { Type::FunctionLiteral(function_type) => match function_type.known(db) { Some(KnownFunction::IsEquivalentTo) => { if let [Some(ty_a), Some(ty_b)] = overload.parameter_types() { - let constraints = ty_a.when_equivalent_to(db, *ty_b); + let constraints = + ty_a.when_equivalent_to(db, *ty_b, InferableTypeVars::None); let tracked = TrackedConstraintSet::new(db, constraints); overload.set_return_type(Type::KnownInstance( KnownInstanceType::ConstraintSet(tracked), @@ -607,7 +610,8 @@ impl<'db> Bindings<'db> { Some(KnownFunction::IsSubtypeOf) => { if let [Some(ty_a), Some(ty_b)] = overload.parameter_types() { - let constraints = ty_a.when_subtype_of(db, *ty_b); + let constraints = + ty_a.when_subtype_of(db, *ty_b, InferableTypeVars::None); let tracked = TrackedConstraintSet::new(db, constraints); overload.set_return_type(Type::KnownInstance( KnownInstanceType::ConstraintSet(tracked), @@ -617,7 +621,8 @@ impl<'db> Bindings<'db> { Some(KnownFunction::IsAssignableTo) => { if let [Some(ty_a), Some(ty_b)] = overload.parameter_types() { - let constraints = ty_a.when_assignable_to(db, *ty_b); + let constraints = + ty_a.when_assignable_to(db, *ty_b, InferableTypeVars::None); let tracked = TrackedConstraintSet::new(db, constraints); overload.set_return_type(Type::KnownInstance( KnownInstanceType::ConstraintSet(tracked), @@ -627,7 +632,8 @@ impl<'db> Bindings<'db> { Some(KnownFunction::IsDisjointFrom) => { if let [Some(ty_a), Some(ty_b)] = overload.parameter_types() { - let constraints = ty_a.when_disjoint_from(db, *ty_b); + let constraints = + ty_a.when_disjoint_from(db, *ty_b, InferableTypeVars::None); let tracked = TrackedConstraintSet::new(db, constraints); overload.set_return_type(Type::KnownInstance( KnownInstanceType::ConstraintSet(tracked), @@ -1407,7 +1413,10 @@ impl<'db> CallableBinding<'db> { let parameter_type = overload.signature.parameters()[*parameter_index] .annotated_type() .unwrap_or(Type::unknown()); - if argument_type.is_assignable_to(db, parameter_type) { + if argument_type + .when_assignable_to(db, parameter_type, overload.inferable_typevars) + .is_always_satisfied() + { is_argument_assignable_to_any_overload = true; break 'overload; } @@ -1633,7 +1642,14 @@ impl<'db> CallableBinding<'db> { .unwrap_or(Type::unknown()); let first_parameter_type = &mut first_parameter_types[parameter_index]; if let Some(first_parameter_type) = first_parameter_type { - if !first_parameter_type.is_equivalent_to(db, current_parameter_type) { + if !first_parameter_type + .when_equivalent_to( + db, + current_parameter_type, + overload.inferable_typevars, + ) + .is_always_satisfied() + { participating_parameter_indexes.insert(parameter_index); } } else { @@ -1750,7 +1766,12 @@ impl<'db> CallableBinding<'db> { matching_overloads.all(|(_, overload)| { overload .return_type() - .is_equivalent_to(db, first_overload_return_type) + .when_equivalent_to( + db, + first_overload_return_type, + overload.inferable_typevars, + ) + .is_always_satisfied() }) } else { // No matching overload @@ -2461,6 +2482,7 @@ struct ArgumentTypeChecker<'a, 'db> { call_expression_tcx: &'a TypeContext<'db>, errors: &'a mut Vec>, + inferable_typevars: InferableTypeVars<'db, 'db>, specialization: Option>, } @@ -2482,6 +2504,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { parameter_tys, call_expression_tcx, errors, + inferable_typevars: InferableTypeVars::None, specialization: None, } } @@ -2514,11 +2537,12 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } fn infer_specialization(&mut self) { - if self.signature.generic_context.is_none() { + let Some(generic_context) = self.signature.generic_context else { return; - } + }; - let mut builder = SpecializationBuilder::new(self.db); + // TODO: Use the list of inferable typevars from the generic context of the callable. + let mut builder = SpecializationBuilder::new(self.db, self.inferable_typevars); // Note that we infer the annotated type _before_ the arguments if this call is part of // an annotated assignment, to closer match the order of any unions written in the type @@ -2563,10 +2587,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - self.specialization = self - .signature - .generic_context - .map(|gc| builder.build(gc, *self.call_expression_tcx)); + self.specialization = Some(builder.build(generic_context, *self.call_expression_tcx)); } fn check_argument_type( @@ -2590,7 +2611,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // constraint set that we get from this assignability check, instead of inferring and // building them in an earlier separate step. if argument_type - .when_assignable_to(self.db, expected_ty) + .when_assignable_to(self.db, expected_ty, self.inferable_typevars) .is_never_satisfied() { let positional = matches!(argument, Argument::Positional | Argument::Synthetic) @@ -2719,7 +2740,14 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { return; }; - if !key_type.is_assignable_to(self.db, KnownClass::Str.to_instance(self.db)) { + if !key_type + .when_assignable_to( + self.db, + KnownClass::Str.to_instance(self.db), + self.inferable_typevars, + ) + .is_always_satisfied() + { self.errors.push(BindingError::InvalidKeyType { argument_index: adjusted_argument_index, provided_ty: key_type, @@ -2754,8 +2782,8 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - fn finish(self) -> Option> { - self.specialization + fn finish(self) -> (InferableTypeVars<'db, 'db>, Option>) { + (self.inferable_typevars, self.specialization) } } @@ -2819,6 +2847,9 @@ pub(crate) struct Binding<'db> { /// Return type of the call. return_ty: Type<'db>, + /// The inferable typevars in this signature. + inferable_typevars: InferableTypeVars<'db, 'db>, + /// The specialization that was inferred from the argument types, if the callable is generic. specialization: Option>, @@ -2845,6 +2876,7 @@ impl<'db> Binding<'db> { callable_type: signature_type, signature_type, return_ty: Type::unknown(), + inferable_typevars: InferableTypeVars::None, specialization: None, argument_matches: Box::from([]), variadic_argument_matched_to_variadic_parameter: false, @@ -2916,7 +2948,7 @@ impl<'db> Binding<'db> { checker.infer_specialization(); checker.check_argument_types(); - self.specialization = checker.finish(); + (self.inferable_typevars, self.specialization) = checker.finish(); if let Some(specialization) = self.specialization { self.return_ty = self.return_ty.apply_specialization(db, specialization); } @@ -3010,6 +3042,7 @@ impl<'db> Binding<'db> { fn snapshot(&self) -> BindingSnapshot<'db> { BindingSnapshot { return_ty: self.return_ty, + inferable_typevars: self.inferable_typevars, specialization: self.specialization, argument_matches: self.argument_matches.clone(), parameter_tys: self.parameter_tys.clone(), @@ -3020,6 +3053,7 @@ impl<'db> Binding<'db> { fn restore(&mut self, snapshot: BindingSnapshot<'db>) { let BindingSnapshot { return_ty, + inferable_typevars, specialization, argument_matches, parameter_tys, @@ -3027,6 +3061,7 @@ impl<'db> Binding<'db> { } = snapshot; self.return_ty = return_ty; + self.inferable_typevars = inferable_typevars; self.specialization = specialization; self.argument_matches = argument_matches; self.parameter_tys = parameter_tys; @@ -3046,6 +3081,7 @@ impl<'db> Binding<'db> { /// Resets the state of this binding to its initial state. fn reset(&mut self) { self.return_ty = Type::unknown(); + self.inferable_typevars = InferableTypeVars::None; self.specialization = None; self.argument_matches = Box::from([]); self.parameter_tys = Box::from([]); @@ -3056,6 +3092,7 @@ impl<'db> Binding<'db> { #[derive(Clone, Debug)] struct BindingSnapshot<'db> { return_ty: Type<'db>, + inferable_typevars: InferableTypeVars<'db, 'db>, specialization: Option>, argument_matches: Box<[MatchedArgument<'db>]>, parameter_tys: Box<[Option>]>, @@ -3095,6 +3132,7 @@ impl<'db> CallableBindingSnapshot<'db> { // ... and update the snapshot with the current state of the binding. snapshot.return_ty = binding.return_ty; + snapshot.inferable_typevars = binding.inferable_typevars; snapshot.specialization = binding.specialization; snapshot .argument_matches diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index b9b63a50a1..876f6d3930 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -22,7 +22,7 @@ use crate::types::diagnostic::INVALID_TYPE_ALIAS_TYPE; use crate::types::enums::enum_metadata; use crate::types::function::{DataclassTransformerParams, KnownFunction}; use crate::types::generics::{ - GenericContext, Specialization, walk_generic_context, walk_specialization, + GenericContext, InferableTypeVars, Specialization, walk_generic_context, walk_specialization, }; use crate::types::infer::nearest_enclosing_class; use crate::types::member::{Member, class_member}; @@ -540,17 +540,20 @@ impl<'db> ClassType<'db> { /// Return `true` if `other` is present in this class's MRO. pub(super) fn is_subclass_of(self, db: &'db dyn Db, other: ClassType<'db>) -> bool { - self.when_subclass_of(db, other).is_always_satisfied() + self.when_subclass_of(db, other, InferableTypeVars::None) + .is_always_satisfied() } pub(super) fn when_subclass_of( self, db: &'db dyn Db, other: ClassType<'db>, + inferable: InferableTypeVars<'_, 'db>, ) -> ConstraintSet<'db> { self.has_relation_to_impl( db, other, + inferable, TypeRelation::Subtyping, &HasRelationToVisitor::default(), &IsDisjointVisitor::default(), @@ -561,6 +564,7 @@ impl<'db> ClassType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -586,6 +590,7 @@ impl<'db> ClassType<'db> { base.specialization(db).has_relation_to_impl( db, other.specialization(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -610,6 +615,7 @@ impl<'db> ClassType<'db> { self, db: &'db dyn Db, other: ClassType<'db>, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self == other { @@ -628,6 +634,7 @@ impl<'db> ClassType<'db> { this.specialization(db).is_equivalent_to_impl( db, other.specialization(db), + inferable, visitor, ) }) diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index b6867b84ea..a94c222a37 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -73,7 +73,7 @@ use crate::types::diagnostic::{ report_runtime_check_against_non_runtime_checkable_protocol, }; use crate::types::display::DisplaySettings; -use crate::types::generics::GenericContext; +use crate::types::generics::{GenericContext, InferableTypeVars}; use crate::types::ide_support::all_members; use crate::types::narrow::ClassInfoConstraintFunction; use crate::types::signatures::{CallableSignature, Signature}; @@ -81,9 +81,9 @@ use crate::types::visitor::any_over_type; use crate::types::{ ApplyTypeMappingVisitor, BoundMethodType, BoundTypeVarInstance, CallableType, ClassBase, ClassLiteral, ClassType, DeprecatedInstance, DynamicType, FindLegacyTypeVarsVisitor, - HasRelationToVisitor, IsEquivalentVisitor, KnownClass, KnownInstanceType, NormalizedVisitor, - SpecialFormType, TrackedConstraintSet, Truthiness, Type, TypeContext, TypeMapping, - TypeRelation, UnionBuilder, binding_type, todo_type, walk_signature, + HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, KnownClass, KnownInstanceType, + NormalizedVisitor, SpecialFormType, TrackedConstraintSet, Truthiness, Type, TypeContext, + TypeMapping, TypeRelation, UnionBuilder, binding_type, todo_type, walk_signature, }; use crate::{Db, FxOrderSet, ModuleName, resolve_module}; @@ -959,46 +959,42 @@ impl<'db> FunctionType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, - _visitor: &HasRelationToVisitor<'db>, + relation_visitor: &HasRelationToVisitor<'db>, + disjointness_visitor: &IsDisjointVisitor<'db>, ) -> ConstraintSet<'db> { - match relation { - TypeRelation::Subtyping | TypeRelation::Redundancy => { - ConstraintSet::from(self.is_subtype_of(db, other)) - } - TypeRelation::Assignability => ConstraintSet::from(self.is_assignable_to(db, other)), - } - } - - pub(crate) fn is_subtype_of(self, db: &'db dyn Db, other: Self) -> bool { // A function type is the subtype of itself, and not of any other function type. However, // our representation of a function type includes any specialization that should be applied // to the signature. Different specializations of the same function type are only subtypes // of each other if they result in subtype signatures. - if self.normalized(db) == other.normalized(db) { - return true; + if matches!(relation, TypeRelation::Subtyping | TypeRelation::Redundancy) + && self.normalized(db) == other.normalized(db) + { + return ConstraintSet::from(true); } + if self.literal(db) != other.literal(db) { - return false; + return ConstraintSet::from(false); } + let self_signature = self.signature(db); let other_signature = other.signature(db); - self_signature.is_subtype_of(db, other_signature) - } - - pub(crate) fn is_assignable_to(self, db: &'db dyn Db, other: Self) -> bool { - // A function type is assignable to itself, and not to any other function type. However, - // our representation of a function type includes any specialization that should be applied - // to the signature. Different specializations of the same function type are only - // assignable to each other if they result in assignable signatures. - self.literal(db) == other.literal(db) - && self.signature(db).is_assignable_to(db, other.signature(db)) + self_signature.has_relation_to_impl( + db, + other_signature, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) } pub(crate) fn is_equivalent_to_impl( self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self.normalized(db) == other.normalized(db) { @@ -1009,7 +1005,7 @@ impl<'db> FunctionType<'db> { } let self_signature = self.signature(db); let other_signature = other.signature(db); - self_signature.is_equivalent_to_impl(db, other_signature, visitor) + self_signature.is_equivalent_to_impl(db, other_signature, inferable, visitor) } pub(crate) fn find_legacy_typevars_impl( diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 68b4d2a95c..20e1ab127c 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1,4 +1,4 @@ -use crate::types::constraints::ConstraintSet; +use std::marker::PhantomData; use itertools::Itertools; use ruff_python_ast as ast; @@ -9,6 +9,7 @@ use crate::semantic_index::scope::{FileScopeId, NodeWithScopeKind, ScopeId}; use crate::semantic_index::{SemanticIndex, semantic_index}; use crate::types::class::ClassType; use crate::types::class_base::ClassBase; +use crate::types::constraints::ConstraintSet; use crate::types::infer::infer_definition_types; use crate::types::instance::{Protocol, ProtocolInstanceType}; use crate::types::signatures::{Parameter, Parameters, Signature}; @@ -140,6 +141,16 @@ pub(crate) fn typing_self<'db>( .map(typevar_to_type) } +#[derive(Clone, Copy, Debug)] +pub(crate) enum InferableTypeVars<'a, 'db> { + None, + // TODO: This variant isn't used, and only exists so that we can include the 'a and 'db in the + // type definition. They will be used soon when we start creating real InferableTypeVars + // instances. + #[expect(unused)] + Unused(PhantomData<&'a &'db ()>), +} + #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] pub struct GenericContextTypeVar<'db> { bound_typevar: BoundTypeVarInstance<'db>, @@ -593,12 +604,14 @@ pub(super) fn walk_specialization<'db, V: super::visitor::TypeVisitor<'db> + ?Si } } +#[expect(clippy::too_many_arguments)] fn is_subtype_in_invariant_position<'db>( db: &'db dyn Db, derived_type: &Type<'db>, derived_materialization: MaterializationKind, base_type: &Type<'db>, base_materialization: MaterializationKind, + inferable: InferableTypeVars<'_, 'db>, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, ) -> ConstraintSet<'db> { @@ -623,6 +636,7 @@ fn is_subtype_in_invariant_position<'db>( derived.has_relation_to_impl( db, base, + inferable, TypeRelation::Subtyping, relation_visitor, disjointness_visitor, @@ -675,6 +689,7 @@ fn has_relation_in_invariant_position<'db>( derived_materialization: Option, base_type: &Type<'db>, base_materialization: Option, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -688,6 +703,7 @@ fn has_relation_in_invariant_position<'db>( derived_mat, base_type, base_mat, + inferable, relation_visitor, disjointness_visitor, ), @@ -707,6 +723,7 @@ fn has_relation_in_invariant_position<'db>( .has_relation_to_impl( db, *base_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -715,6 +732,7 @@ fn has_relation_in_invariant_position<'db>( base_type.has_relation_to_impl( db, *derived_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -728,6 +746,7 @@ fn has_relation_in_invariant_position<'db>( MaterializationKind::Top, base_type, base_mat, + inferable, relation_visitor, disjointness_visitor, ) @@ -739,6 +758,7 @@ fn has_relation_in_invariant_position<'db>( derived_mat, base_type, MaterializationKind::Bottom, + inferable, relation_visitor, disjointness_visitor, ) @@ -750,6 +770,7 @@ fn has_relation_in_invariant_position<'db>( MaterializationKind::Bottom, base_type, base_mat, + inferable, relation_visitor, disjointness_visitor, ), @@ -759,6 +780,7 @@ fn has_relation_in_invariant_position<'db>( derived_mat, base_type, MaterializationKind::Top, + inferable, relation_visitor, disjointness_visitor, ), @@ -1002,6 +1024,7 @@ impl<'db> Specialization<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -1016,6 +1039,7 @@ impl<'db> Specialization<'db> { return self_tuple.has_relation_to_impl( db, other_tuple, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1043,6 +1067,7 @@ impl<'db> Specialization<'db> { self_materialization_kind, other_type, other_materialization_kind, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1050,6 +1075,7 @@ impl<'db> Specialization<'db> { TypeVarVariance::Covariant => self_type.has_relation_to_impl( db, *other_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1057,6 +1083,7 @@ impl<'db> Specialization<'db> { TypeVarVariance::Contravariant => other_type.has_relation_to_impl( db, *self_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1075,6 +1102,7 @@ impl<'db> Specialization<'db> { self, db: &'db dyn Db, other: Specialization<'db>, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self.materialization_kind(db) != other.materialization_kind(db) { @@ -1100,7 +1128,7 @@ impl<'db> Specialization<'db> { TypeVarVariance::Invariant | TypeVarVariance::Covariant | TypeVarVariance::Contravariant => { - self_type.is_equivalent_to_impl(db, *other_type, visitor) + self_type.is_equivalent_to_impl(db, *other_type, inferable, visitor) } TypeVarVariance::Bivariant => ConstraintSet::from(true), }; @@ -1113,7 +1141,8 @@ impl<'db> Specialization<'db> { (Some(_), None) | (None, Some(_)) => return ConstraintSet::from(false), (None, None) => {} (Some(self_tuple), Some(other_tuple)) => { - let compatible = self_tuple.is_equivalent_to_impl(db, other_tuple, visitor); + let compatible = + self_tuple.is_equivalent_to_impl(db, other_tuple, inferable, visitor); if result.intersect(db, compatible).is_never_satisfied() { return result; } @@ -1168,13 +1197,15 @@ impl<'db> PartialSpecialization<'_, 'db> { /// specialization of a generic function. pub(crate) struct SpecializationBuilder<'db> { db: &'db dyn Db, + inferable: InferableTypeVars<'db, 'db>, types: FxHashMap, Type<'db>>, } impl<'db> SpecializationBuilder<'db> { - pub(crate) fn new(db: &'db dyn Db) -> Self { + pub(crate) fn new(db: &'db dyn Db, inferable: InferableTypeVars<'db, 'db>) -> Self { Self { db, + inferable, types: FxHashMap::default(), } } @@ -1244,14 +1275,16 @@ impl<'db> SpecializationBuilder<'db> { // without specializing `T` to `None`. if !matches!(formal, Type::ProtocolInstance(_)) && !actual.is_never() - && actual.is_subtype_of(self.db, formal) + && actual + .when_subtype_of(self.db, formal, self.inferable) + .is_always_satisfied() { return Ok(()); } // For example, if `formal` is `list[T]` and `actual` is `list[int] | None`, we want to specialize `T` to `int`. // So, here we remove the union elements that are not related to `formal`. - actual = actual.filter_disjoint_elements(self.db, formal); + actual = actual.filter_disjoint_elements(self.db, formal, self.inferable); match (formal, actual) { // TODO: We haven't implemented a full unification solver yet. If typevars appear in @@ -1324,7 +1357,10 @@ impl<'db> SpecializationBuilder<'db> { (Type::TypeVar(bound_typevar), ty) | (ty, Type::TypeVar(bound_typevar)) => { match bound_typevar.typevar(self.db).bound_or_constraints(self.db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - if !ty.is_assignable_to(self.db, bound) { + if !ty + .when_assignable_to(self.db, bound, self.inferable) + .is_always_satisfied() + { return Err(SpecializationError::MismatchedBound { bound_typevar, argument: ty, @@ -1334,7 +1370,10 @@ impl<'db> SpecializationBuilder<'db> { } Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { for constraint in constraints.elements(self.db) { - if ty.is_assignable_to(self.db, *constraint) { + if ty + .when_assignable_to(self.db, *constraint, self.inferable) + .is_always_satisfied() + { self.add_type_mapping(bound_typevar, *constraint); return Ok(()); } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 4b672f6e10..16b669e65a 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -75,8 +75,10 @@ use crate::types::diagnostic::{ use crate::types::function::{ FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, }; -use crate::types::generics::{GenericContext, bind_typevar, enclosing_generic_contexts}; -use crate::types::generics::{LegacyGenericBase, SpecializationBuilder}; +use crate::types::generics::{ + GenericContext, InferableTypeVars, LegacyGenericBase, SpecializationBuilder, bind_typevar, + enclosing_generic_contexts, +}; use crate::types::infer::nearest_enclosing_function; use crate::types::instance::SliceLiteral; use crate::types::mro::MroErrorKind; @@ -5964,11 +5966,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; }; + // TODO: Use the list of inferable typevars from the generic context of the collection + // class. + let inferable = InferableTypeVars::None; let tcx = tcx.map_annotation(|annotation| { // Remove any union elements of `annotation` that are not related to `collection_ty`. // e.g. `annotation: list[int] | None => list[int]` if `collection_ty: list` let collection_ty = collection_class.to_instance(self.db()); - annotation.filter_disjoint_elements(self.db(), collection_ty) + annotation.filter_disjoint_elements(self.db(), collection_ty, inferable) }); // Extract the annotated type of `T`, if provided. @@ -5977,7 +5982,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .map(|specialization| specialization.types(self.db())); // Create a set of constraints to infer a precise type for `T`. - let mut builder = SpecializationBuilder::new(self.db()); + let mut builder = SpecializationBuilder::new(self.db(), inferable); match annotated_elt_tys { // The annotated type acts as a constraint for `T`. diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 9079536435..ac081ca02a 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -9,7 +9,7 @@ use crate::place::PlaceAndQualifiers; use crate::semantic_index::definition::Definition; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::enums::is_single_member_enum; -use crate::types::generics::walk_specialization; +use crate::types::generics::{InferableTypeVars, walk_specialization}; use crate::types::protocol_class::walk_protocol_interface; use crate::types::tuple::{TupleSpec, TupleType}; use crate::types::{ @@ -121,6 +121,7 @@ impl<'db> Type<'db> { self, db: &'db dyn Db, protocol: ProtocolInstanceType<'db>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -129,6 +130,7 @@ impl<'db> Type<'db> { self_protocol.interface(db).has_relation_to_impl( db, protocol.interface(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -142,6 +144,7 @@ impl<'db> Type<'db> { member.is_satisfied_by( db, self, + inferable, relation, relation_visitor, disjointness_visitor, @@ -173,6 +176,7 @@ impl<'db> Type<'db> { type_to_test.has_relation_to_impl( db, Type::NominalInstance(nominal_instance), + inferable, relation, relation_visitor, disjointness_visitor, @@ -360,6 +364,7 @@ impl<'db> NominalInstanceType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -372,6 +377,7 @@ impl<'db> NominalInstanceType<'db> { ) => tuple1.has_relation_to_impl( db, tuple2, + inferable, relation, relation_visitor, disjointness_visitor, @@ -379,6 +385,7 @@ impl<'db> NominalInstanceType<'db> { _ => self.class(db).has_relation_to_impl( db, other.class(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -390,18 +397,19 @@ impl<'db> NominalInstanceType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { match (self.0, other.0) { ( NominalInstanceInner::ExactTuple(tuple1), NominalInstanceInner::ExactTuple(tuple2), - ) => tuple1.is_equivalent_to_impl(db, tuple2, visitor), + ) => tuple1.is_equivalent_to_impl(db, tuple2, inferable, visitor), (NominalInstanceInner::Object, NominalInstanceInner::Object) => { ConstraintSet::from(true) } (NominalInstanceInner::NonTuple(class1), NominalInstanceInner::NonTuple(class2)) => { - class1.is_equivalent_to_impl(db, class2, visitor) + class1.is_equivalent_to_impl(db, class2, inferable, visitor) } _ => ConstraintSet::from(false), } @@ -411,6 +419,7 @@ impl<'db> NominalInstanceType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, disjointness_visitor: &IsDisjointVisitor<'db>, relation_visitor: &HasRelationToVisitor<'db>, ) -> ConstraintSet<'db> { @@ -423,6 +432,7 @@ impl<'db> NominalInstanceType<'db> { let compatible = self_spec.is_disjoint_from_impl( db, &other_spec, + inferable, disjointness_visitor, relation_visitor, ); @@ -650,6 +660,7 @@ impl<'db> ProtocolInstanceType<'db> { .satisfies_protocol( db, protocol, + InferableTypeVars::None, TypeRelation::Subtyping, &HasRelationToVisitor::default(), &IsDisjointVisitor::default(), @@ -708,6 +719,7 @@ impl<'db> ProtocolInstanceType<'db> { self, db: &'db dyn Db, other: Self, + _inferable: InferableTypeVars<'_, 'db>, _visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { if self == other { @@ -729,6 +741,7 @@ impl<'db> ProtocolInstanceType<'db> { self, _db: &'db dyn Db, _other: Self, + _inferable: InferableTypeVars<'_, 'db>, _visitor: &IsDisjointVisitor<'db>, ) -> ConstraintSet<'db> { ConstraintSet::from(false) diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 716b814aaa..fdcb693508 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -22,6 +22,7 @@ use crate::{ constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension}, context::InferContext, diagnostic::report_undeclared_protocol_member, + generics::InferableTypeVars, signatures::{Parameter, Parameters}, todo_type, }, @@ -235,6 +236,7 @@ impl<'db> ProtocolInterface<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -276,6 +278,7 @@ impl<'db> ProtocolInterface<'db> { our_type.has_relation_to_impl( db, Type::Callable(other_type.bind_self(db)), + inferable, relation, relation_visitor, disjointness_visitor, @@ -288,6 +291,7 @@ impl<'db> ProtocolInterface<'db> { ) => our_method.bind_self(db).has_relation_to_impl( db, other_method.bind_self(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -300,6 +304,7 @@ impl<'db> ProtocolInterface<'db> { .has_relation_to_impl( db, other_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -308,6 +313,7 @@ impl<'db> ProtocolInterface<'db> { other_type.has_relation_to_impl( db, our_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -605,6 +611,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { &self, db: &'db dyn Db, other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, disjointness_visitor: &IsDisjointVisitor<'db>, relation_visitor: &HasRelationToVisitor<'db>, ) -> ConstraintSet<'db> { @@ -613,9 +620,13 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { ProtocolMemberKind::Property(_) | ProtocolMemberKind::Method(_) => { ConstraintSet::from(false) } - ProtocolMemberKind::Other(ty) => { - ty.is_disjoint_from_impl(db, other, disjointness_visitor, relation_visitor) - } + ProtocolMemberKind::Other(ty) => ty.is_disjoint_from_impl( + db, + other, + inferable, + disjointness_visitor, + relation_visitor, + ), } } @@ -625,6 +636,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { &self, db: &'db dyn Db, other: Type<'db>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -664,6 +676,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { attribute_type.has_relation_to_impl( db, Type::Callable(method.bind_self(db)), + inferable, relation, relation_visitor, disjointness_visitor, @@ -684,6 +697,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { .has_relation_to_impl( db, attribute_type, + inferable, relation, relation_visitor, disjointness_visitor, @@ -692,6 +706,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { attribute_type.has_relation_to_impl( db, *member_type, + inferable, relation, relation_visitor, disjointness_visitor, diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index ed96430b48..92fb74ad26 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -23,7 +23,9 @@ use super::{ use crate::semantic_index::definition::Definition; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::function::FunctionType; -use crate::types::generics::{GenericContext, typing_self, walk_generic_context}; +use crate::types::generics::{ + GenericContext, InferableTypeVars, typing_self, walk_generic_context, +}; use crate::types::infer::nearest_enclosing_class; use crate::types::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarInstance, ClassLiteral, @@ -174,41 +176,27 @@ impl<'db> CallableSignature<'db> { } } - /// Check whether this callable type is a subtype of another callable type. - /// - /// See [`Type::is_subtype_of`] for more details. - pub(crate) fn is_subtype_of(&self, db: &'db dyn Db, other: &Self) -> bool { - self.is_subtype_of_impl(db, other).is_always_satisfied() - } - - fn is_subtype_of_impl(&self, db: &'db dyn Db, other: &Self) -> ConstraintSet<'db> { + fn is_subtype_of_impl( + &self, + db: &'db dyn Db, + other: &Self, + inferable: InferableTypeVars<'_, 'db>, + ) -> ConstraintSet<'db> { self.has_relation_to_impl( db, other, + inferable, TypeRelation::Subtyping, &HasRelationToVisitor::default(), &IsDisjointVisitor::default(), ) } - /// Check whether this callable type is assignable to another callable type. - /// - /// See [`Type::is_assignable_to`] for more details. - pub(crate) fn is_assignable_to(&self, db: &'db dyn Db, other: &Self) -> bool { - self.has_relation_to_impl( - db, - other, - TypeRelation::Assignability, - &HasRelationToVisitor::default(), - &IsDisjointVisitor::default(), - ) - .is_always_satisfied() - } - pub(crate) fn has_relation_to_impl( &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -217,6 +205,7 @@ impl<'db> CallableSignature<'db> { db, &self.overloads, &other.overloads, + inferable, relation, relation_visitor, disjointness_visitor, @@ -229,6 +218,7 @@ impl<'db> CallableSignature<'db> { db: &'db dyn Db, self_signatures: &[Signature<'db>], other_signatures: &[Signature<'db>], + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -239,6 +229,7 @@ impl<'db> CallableSignature<'db> { self_signature.has_relation_to_impl( db, other_signature, + inferable, relation, relation_visitor, disjointness_visitor, @@ -251,6 +242,7 @@ impl<'db> CallableSignature<'db> { db, std::slice::from_ref(self_signature), other_signatures, + inferable, relation, relation_visitor, disjointness_visitor, @@ -263,6 +255,7 @@ impl<'db> CallableSignature<'db> { db, self_signatures, std::slice::from_ref(other_signature), + inferable, relation, relation_visitor, disjointness_visitor, @@ -275,6 +268,7 @@ impl<'db> CallableSignature<'db> { db, self_signatures, std::slice::from_ref(other_signature), + inferable, relation, relation_visitor, disjointness_visitor, @@ -290,20 +284,21 @@ impl<'db> CallableSignature<'db> { &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { match (self.overloads.as_slice(), other.overloads.as_slice()) { ([self_signature], [other_signature]) => { // Common case: both callable types contain a single signature, use the custom // equivalence check instead of delegating it to the subtype check. - self_signature.is_equivalent_to_impl(db, other_signature, visitor) + self_signature.is_equivalent_to_impl(db, other_signature, inferable, visitor) } (_, _) => { if self == other { return ConstraintSet::from(true); } - self.is_subtype_of_impl(db, other) - .and(db, || other.is_subtype_of_impl(db, self)) + self.is_subtype_of_impl(db, other, inferable) + .and(db, || other.is_subtype_of_impl(db, self, inferable)) } } } @@ -619,6 +614,7 @@ impl<'db> Signature<'db> { &self, db: &'db dyn Db, other: &Signature<'db>, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { let mut result = ConstraintSet::from(true); @@ -626,7 +622,10 @@ impl<'db> Signature<'db> { let self_type = self_type.unwrap_or(Type::unknown()); let other_type = other_type.unwrap_or(Type::unknown()); !result - .intersect(db, self_type.is_equivalent_to_impl(db, other_type, visitor)) + .intersect( + db, + self_type.is_equivalent_to_impl(db, other_type, inferable, visitor), + ) .is_never_satisfied() }; @@ -702,6 +701,7 @@ impl<'db> Signature<'db> { &self, db: &'db dyn Db, other: &Signature<'db>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -777,6 +777,7 @@ impl<'db> Signature<'db> { type1.has_relation_to_impl( db, type2, + inferable, relation, relation_visitor, disjointness_visitor, diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index c6a16620a9..db55132c1e 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -1,6 +1,7 @@ use crate::place::PlaceAndQualifiers; use crate::semantic_index::definition::Definition; use crate::types::constraints::ConstraintSet; +use crate::types::generics::InferableTypeVars; use crate::types::variance::VarianceInferable; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, ClassType, DynamicType, @@ -135,6 +136,7 @@ impl<'db> SubclassOfType<'db> { self, db: &'db dyn Db, other: SubclassOfType<'db>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -157,6 +159,7 @@ impl<'db> SubclassOfType<'db> { .has_relation_to_impl( db, other_class, + inferable, relation, relation_visitor, disjointness_visitor, @@ -171,6 +174,7 @@ impl<'db> SubclassOfType<'db> { self, db: &'db dyn Db, other: Self, + _inferable: InferableTypeVars<'_, 'db>, _visitor: &IsDisjointVisitor<'db>, ) -> ConstraintSet<'db> { match (self.subclass_of, other.subclass_of) { diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index a94a0610e7..e70c7708a2 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -24,6 +24,7 @@ use itertools::{Either, EitherOrBoth, Itertools}; use crate::semantic_index::definition::Definition; use crate::types::class::{ClassType, KnownClass}; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; +use crate::types::generics::InferableTypeVars; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, NormalizedVisitor, Type, TypeMapping, TypeRelation, @@ -258,6 +259,7 @@ impl<'db> TupleType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -265,6 +267,7 @@ impl<'db> TupleType<'db> { self.tuple(db).has_relation_to_impl( db, other.tuple(db), + inferable, relation, relation_visitor, disjointness_visitor, @@ -275,10 +278,11 @@ impl<'db> TupleType<'db> { self, db: &'db dyn Db, other: Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { self.tuple(db) - .is_equivalent_to_impl(db, other.tuple(db), visitor) + .is_equivalent_to_impl(db, other.tuple(db), inferable, visitor) } pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool { @@ -442,6 +446,7 @@ impl<'db> FixedLengthTuple> { &self, db: &'db dyn Db, other: &Tuple>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -453,6 +458,7 @@ impl<'db> FixedLengthTuple> { self_ty.has_relation_to_impl( db, *other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -473,6 +479,7 @@ impl<'db> FixedLengthTuple> { let element_constraints = self_ty.has_relation_to_impl( db, *other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -491,6 +498,7 @@ impl<'db> FixedLengthTuple> { let element_constraints = self_ty.has_relation_to_impl( db, *other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -510,6 +518,7 @@ impl<'db> FixedLengthTuple> { self_ty.has_relation_to_impl( db, other.variable, + inferable, relation, relation_visitor, disjointness_visitor, @@ -524,13 +533,14 @@ impl<'db> FixedLengthTuple> { &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { ConstraintSet::from(self.0.len() == other.0.len()).and(db, || { (self.0.iter()) .zip(&other.0) .when_all(db, |(self_ty, other_ty)| { - self_ty.is_equivalent_to_impl(db, *other_ty, visitor) + self_ty.is_equivalent_to_impl(db, *other_ty, inferable, visitor) }) }) } @@ -793,6 +803,7 @@ impl<'db> VariableLengthTuple> { &self, db: &'db dyn Db, other: &Tuple>, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -825,6 +836,7 @@ impl<'db> VariableLengthTuple> { let element_constraints = self_ty.has_relation_to_impl( db, other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -844,6 +856,7 @@ impl<'db> VariableLengthTuple> { let element_constraints = self_ty.has_relation_to_impl( db, other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -884,6 +897,7 @@ impl<'db> VariableLengthTuple> { EitherOrBoth::Both(self_ty, other_ty) => self_ty.has_relation_to_impl( db, other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -891,6 +905,7 @@ impl<'db> VariableLengthTuple> { EitherOrBoth::Left(self_ty) => self_ty.has_relation_to_impl( db, other.variable, + inferable, relation, relation_visitor, disjointness_visitor, @@ -918,6 +933,7 @@ impl<'db> VariableLengthTuple> { EitherOrBoth::Both(self_ty, other_ty) => self_ty.has_relation_to_impl( db, *other_ty, + inferable, relation, relation_visitor, disjointness_visitor, @@ -925,6 +941,7 @@ impl<'db> VariableLengthTuple> { EitherOrBoth::Left(self_ty) => self_ty.has_relation_to_impl( db, other.variable, + inferable, relation, relation_visitor, disjointness_visitor, @@ -945,6 +962,7 @@ impl<'db> VariableLengthTuple> { self.variable.has_relation_to_impl( db, other.variable, + inferable, relation, relation_visitor, disjointness_visitor, @@ -958,16 +976,17 @@ impl<'db> VariableLengthTuple> { &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { self.variable - .is_equivalent_to_impl(db, other.variable, visitor) + .is_equivalent_to_impl(db, other.variable, inferable, visitor) .and(db, || { (self.prenormalized_prefix_elements(db, None)) .zip_longest(other.prenormalized_prefix_elements(db, None)) .when_all(db, |pair| match pair { EitherOrBoth::Both(self_ty, other_ty) => { - self_ty.is_equivalent_to_impl(db, other_ty, visitor) + self_ty.is_equivalent_to_impl(db, other_ty, inferable, visitor) } EitherOrBoth::Left(_) | EitherOrBoth::Right(_) => { ConstraintSet::from(false) @@ -979,7 +998,7 @@ impl<'db> VariableLengthTuple> { .zip_longest(other.prenormalized_suffix_elements(db, None)) .when_all(db, |pair| match pair { EitherOrBoth::Both(self_ty, other_ty) => { - self_ty.is_equivalent_to_impl(db, other_ty, visitor) + self_ty.is_equivalent_to_impl(db, other_ty, inferable, visitor) } EitherOrBoth::Left(_) | EitherOrBoth::Right(_) => { ConstraintSet::from(false) @@ -1170,6 +1189,7 @@ impl<'db> Tuple> { &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, relation: TypeRelation, relation_visitor: &HasRelationToVisitor<'db>, disjointness_visitor: &IsDisjointVisitor<'db>, @@ -1178,6 +1198,7 @@ impl<'db> Tuple> { Tuple::Fixed(self_tuple) => self_tuple.has_relation_to_impl( db, other, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1185,6 +1206,7 @@ impl<'db> Tuple> { Tuple::Variable(self_tuple) => self_tuple.has_relation_to_impl( db, other, + inferable, relation, relation_visitor, disjointness_visitor, @@ -1196,14 +1218,15 @@ impl<'db> Tuple> { &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { match (self, other) { (Tuple::Fixed(self_tuple), Tuple::Fixed(other_tuple)) => { - self_tuple.is_equivalent_to_impl(db, other_tuple, visitor) + self_tuple.is_equivalent_to_impl(db, other_tuple, inferable, visitor) } (Tuple::Variable(self_tuple), Tuple::Variable(other_tuple)) => { - self_tuple.is_equivalent_to_impl(db, other_tuple, visitor) + self_tuple.is_equivalent_to_impl(db, other_tuple, inferable, visitor) } (Tuple::Fixed(_), Tuple::Variable(_)) | (Tuple::Variable(_), Tuple::Fixed(_)) => { ConstraintSet::from(false) @@ -1215,6 +1238,7 @@ impl<'db> Tuple> { &self, db: &'db dyn Db, other: &Self, + inferable: InferableTypeVars<'_, 'db>, disjointness_visitor: &IsDisjointVisitor<'db>, relation_visitor: &HasRelationToVisitor<'db>, ) -> ConstraintSet<'db> { @@ -1234,6 +1258,7 @@ impl<'db> Tuple> { db: &'db dyn Db, a: impl IntoIterator>, b: impl IntoIterator>, + inferable: InferableTypeVars<'_, 'db>, disjointness_visitor: &IsDisjointVisitor<'db>, relation_visitor: &HasRelationToVisitor<'db>, ) -> ConstraintSet<'db> @@ -1244,6 +1269,7 @@ impl<'db> Tuple> { self_element.is_disjoint_from_impl( db, *other_element, + inferable, disjointness_visitor, relation_visitor, ) @@ -1255,6 +1281,7 @@ impl<'db> Tuple> { db, self_tuple.elements(), other_tuple.elements(), + inferable, disjointness_visitor, relation_visitor, ), @@ -1266,6 +1293,7 @@ impl<'db> Tuple> { db, self_tuple.prefix_elements(), other_tuple.prefix_elements(), + inferable, disjointness_visitor, relation_visitor, ) @@ -1274,6 +1302,7 @@ impl<'db> Tuple> { db, self_tuple.suffix_elements().rev(), other_tuple.suffix_elements().rev(), + inferable, disjointness_visitor, relation_visitor, ) @@ -1284,6 +1313,7 @@ impl<'db> Tuple> { db, fixed.elements(), variable.prefix_elements(), + inferable, disjointness_visitor, relation_visitor, ) @@ -1292,6 +1322,7 @@ impl<'db> Tuple> { db, fixed.elements().rev(), variable.suffix_elements().rev(), + inferable, disjointness_visitor, relation_visitor, ) From 8b9ab48ac61b7c73df7d03ccda06085e61e62a5e Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:23:16 -0400 Subject: [PATCH 050/113] Fix syntax error false positives for escapes and quotes in f-strings (#20867) Summary -- Fixes #20844 by refining the unsupported syntax error check for [PEP 701] f-strings before Python 3.12 to allow backslash escapes and escaped outer quotes in the format spec part of f-strings. These are only disallowed within the f-string expression part on earlier versions. Using the examples from the PR: ```pycon >>> f"{1:\x64}" '1' >>> f"{1:\"d\"}" Traceback (most recent call last): File "", line 1, in ValueError: Invalid format specifier '"d"' for object of type 'int' ``` Note that the second case is a runtime error, but this is actually avoidable if you override `__format__`, so despite being pretty weird, this could actually be a valid use case. ```pycon >>> class C: ... def __format__(*args, **kwargs): return "" ... >>> f"{C():\"d\"}" '' ``` At first I thought narrowing the range we check to exclude the format spec would only work for escapes, but it turns out that cases like `f"{1:""}"` are already covered by an existing `ParseError`, so we can just narrow the range of both our escape and quote checks. Our comment check also seems to be working correctly because it's based on the actual tokens. A case like [this](https://play.ruff.rs/9f1c2ff2-cd8e-4ad7-9f40-56c0a524209f): ```python f"""{1:# }""" ``` doesn't include a comment token, instead the `#` is part of an `InterpolatedStringLiteralElement`. Test Plan -- New inline parser tests [PEP 701]: https://peps.python.org/pep-0701/ --- .../test/fixtures/ruff/expression/fstring.py | 2 - .../format@expression__fstring.py.snap | 54 -------- .../err/nested_quote_in_format_spec_py312.py | 2 + .../non_nested_quote_in_format_spec_py311.py | 2 + .../inline/ok/pep701_f_string_py311.py | 2 + .../src/parser/expression.rs | 26 +++- ...@nested_quote_in_format_spec_py312.py.snap | 90 +++++++++++++ ..._nested_quote_in_format_spec_py311.py.snap | 77 +++++++++++ ...valid_syntax@pep701_f_string_py311.py.snap | 124 +++++++++++++++++- 9 files changed, 317 insertions(+), 62 deletions(-) create mode 100644 crates/ruff_python_parser/resources/inline/err/nested_quote_in_format_spec_py312.py create mode 100644 crates/ruff_python_parser/resources/inline/ok/non_nested_quote_in_format_spec_py311.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index 541af99baa..fd658cc5ce 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -706,8 +706,6 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' -# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -# spec, which is valid even before 3.12. f'{x:a{z:hy "user"}} \'\'\'' # Changing the outer quotes is fine because the format-spec is in a nested expression. diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index 2eb1e09a08..c9d90b1764 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -712,8 +712,6 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' -# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -# spec, which is valid even before 3.12. f'{x:a{z:hy "user"}} \'\'\'' # Changing the outer quotes is fine because the format-spec is in a nested expression. @@ -1536,8 +1534,6 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{"aa"}" }' f'{1=: "abcd {'aa'}}' -# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -# spec, which is valid even before 3.12. f"{x:a{z:hy \"user\"}} '''" # Changing the outer quotes is fine because the format-spec is in a nested expression. @@ -2365,8 +2361,6 @@ f'{1:hy "user"}' f'{1: abcd "{1}" }' f'{1: abcd "{"aa"}" }' f'{1=: "abcd {'aa'}}' -# FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -# spec, which is valid even before 3.12. f"{x:a{z:hy \"user\"}} '''" # Changing the outer quotes is fine because the format-spec is in a nested expression. @@ -2418,30 +2412,6 @@ print(f"{ {}, 1 }") ### Unsupported Syntax Errors -error[invalid-syntax]: Cannot use an escape sequence (backslash) in f-strings on Python 3.10 (syntax was added in Python 3.12) - --> fstring.py:764:19 - | -762 | # FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -763 | # spec, which is valid even before 3.12. -764 | f"{x:a{z:hy \"user\"}} '''" - | ^ -765 | -766 | # Changing the outer quotes is fine because the format-spec is in a nested expression. - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. - -error[invalid-syntax]: Cannot use an escape sequence (backslash) in f-strings on Python 3.10 (syntax was added in Python 3.12) - --> fstring.py:764:13 - | -762 | # FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -763 | # spec, which is valid even before 3.12. -764 | f"{x:a{z:hy \"user\"}} '''" - | ^ -765 | -766 | # Changing the outer quotes is fine because the format-spec is in a nested expression. - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. - error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) --> fstring.py:178:8 | @@ -2452,27 +2422,3 @@ error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 179 | f"foo {'"bar"'}" | warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. - -error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) - --> fstring.py:773:14 - | -771 | f'{1=: "abcd \'\'}' # Don't change the outer quotes, or it results in a syntax error -772 | f"{1=: abcd \'\'}" # Changing the quotes here is fine because the inner quotes aren't the opposite quotes -773 | f"{1=: abcd \"\"}" # Changing the quotes here is fine because the inner quotes are escaped - | ^ -774 | # Don't change the quotes in the following cases: -775 | f'{x=:hy "user"} \'\'\'' - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. - -error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) - --> fstring.py:764:14 - | -762 | # FIXME(brent) This should not be a syntax error on output. The escaped quotes are in the format -763 | # spec, which is valid even before 3.12. -764 | f"{x:a{z:hy \"user\"}} '''" - | ^ -765 | -766 | # Changing the outer quotes is fine because the format-spec is in a nested expression. - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. diff --git a/crates/ruff_python_parser/resources/inline/err/nested_quote_in_format_spec_py312.py b/crates/ruff_python_parser/resources/inline/err/nested_quote_in_format_spec_py312.py new file mode 100644 index 0000000000..e11455f9c7 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/nested_quote_in_format_spec_py312.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.12"} +f"{1:""}" # this is a ParseError on all versions diff --git a/crates/ruff_python_parser/resources/inline/ok/non_nested_quote_in_format_spec_py311.py b/crates/ruff_python_parser/resources/inline/ok/non_nested_quote_in_format_spec_py311.py new file mode 100644 index 0000000000..ce69b8518b --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/non_nested_quote_in_format_spec_py311.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.11"} +f"{1:''}" # but this is okay on all versions diff --git a/crates/ruff_python_parser/resources/inline/ok/pep701_f_string_py311.py b/crates/ruff_python_parser/resources/inline/ok/pep701_f_string_py311.py index 40c0958df6..a50bc7593b 100644 --- a/crates/ruff_python_parser/resources/inline/ok/pep701_f_string_py311.py +++ b/crates/ruff_python_parser/resources/inline/ok/pep701_f_string_py311.py @@ -5,3 +5,5 @@ f"""{f'''{f'{"# not a comment"}'}'''}""" f"""{f'''# before expression {f'# aro{f"#{1+1}#"}und #'}'''} # after expression""" f"escape outside of \t {expr}\n" f"test\"abcd" +f"{1:\x64}" # escapes are valid in the format spec +f"{1:\"d\"}" # this also means that escaped outer quotes are valid diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 043de1772c..6920e7cdd1 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -1571,6 +1571,8 @@ impl<'src> Parser<'src> { // f"""{f'''# before expression {f'# aro{f"#{1+1}#"}und #'}'''} # after expression""" // f"escape outside of \t {expr}\n" // f"test\"abcd" + // f"{1:\x64}" # escapes are valid in the format spec + // f"{1:\"d\"}" # this also means that escaped outer quotes are valid // test_err pep701_f_string_py311 // # parse_options: {"target-version": "3.11"} @@ -1586,6 +1588,13 @@ impl<'src> Parser<'src> { // f"""{f"""{x}"""}""" # mark the whole triple quote // f"{'\n'.join(['\t', '\v', '\r'])}" # multiple escape sequences, multiple errors + // test_err nested_quote_in_format_spec_py312 + // # parse_options: {"target-version": "3.12"} + // f"{1:""}" # this is a ParseError on all versions + + // test_ok non_nested_quote_in_format_spec_py311 + // # parse_options: {"target-version": "3.11"} + // f"{1:''}" # but this is okay on all versions let range = self.node_range(start); if !self.options.target_version.supports_pep_701() @@ -1594,22 +1603,29 @@ impl<'src> Parser<'src> { let quote_bytes = flags.quote_str().as_bytes(); let quote_len = flags.quote_len(); for expr in elements.interpolations() { - for slash_position in memchr::memchr_iter(b'\\', self.source[expr.range].as_bytes()) - { + // We need to check the whole expression range, including any leading or trailing + // debug text, but exclude the format spec, where escapes and escaped, reused quotes + // are allowed. + let range = expr + .format_spec + .as_ref() + .map(|format_spec| TextRange::new(expr.start(), format_spec.start())) + .unwrap_or(expr.range); + for slash_position in memchr::memchr_iter(b'\\', self.source[range].as_bytes()) { let slash_position = TextSize::try_from(slash_position).unwrap(); self.add_unsupported_syntax_error( UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::Backslash), - TextRange::at(expr.range.start() + slash_position, '\\'.text_len()), + TextRange::at(range.start() + slash_position, '\\'.text_len()), ); } if let Some(quote_position) = - memchr::memmem::find(self.source[expr.range].as_bytes(), quote_bytes) + memchr::memmem::find(self.source[range].as_bytes(), quote_bytes) { let quote_position = TextSize::try_from(quote_position).unwrap(); self.add_unsupported_syntax_error( UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::NestedQuote), - TextRange::at(expr.range.start() + quote_position, quote_len), + TextRange::at(range.start() + quote_position, quote_len), ); } } diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap new file mode 100644 index 0000000000..ba7e665b79 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap @@ -0,0 +1,90 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/nested_quote_in_format_spec_py312.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..94, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..53, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 44..53, + value: FStringValue { + inner: Concatenated( + [ + FString( + FString { + range: 44..50, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 46..49, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 47..48, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: Some( + InterpolatedStringFormatSpec { + range: 49..49, + node_index: NodeIndex(None), + elements: [], + }, + ), + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + }, + }, + ), + Literal( + StringLiteral { + range: 50..53, + node_index: NodeIndex(None), + value: "}", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + }, + }, + ), + ], + ), + }, + }, + ), + }, + ), + ], + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.12"} +2 | f"{1:""}" # this is a ParseError on all versions + | ^ Syntax Error: f-string: expecting '}' + | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap new file mode 100644 index 0000000000..eea96fe16f --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap @@ -0,0 +1,77 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/non_nested_quote_in_format_spec_py311.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..90, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..53, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 44..53, + value: FStringValue { + inner: Single( + FString( + FString { + range: 44..53, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 46..52, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 47..48, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: Some( + InterpolatedStringFormatSpec { + range: 49..51, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 49..51, + node_index: NodeIndex(None), + value: "''", + }, + ), + ], + }, + ), + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + }, + }, + ), + ), + }, + }, + ), + }, + ), + ], + }, +) +``` diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap index 4b294d49ea..f9d61369a7 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap @@ -8,7 +8,7 @@ input_file: crates/ruff_python_parser/resources/inline/ok/pep701_f_string_py311. Module( ModModule { node_index: NodeIndex(None), - range: 0..278, + range: 0..398, body: [ Expr( StmtExpr { @@ -604,6 +604,128 @@ Module( ), }, ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 278..289, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 278..289, + value: FStringValue { + inner: Single( + FString( + FString { + range: 278..289, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 280..288, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 281..282, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: Some( + InterpolatedStringFormatSpec { + range: 283..287, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 283..287, + node_index: NodeIndex(None), + value: "d", + }, + ), + ], + }, + ), + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + }, + }, + ), + ), + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 330..342, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 330..342, + value: FStringValue { + inner: Single( + FString( + FString { + range: 330..342, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 332..341, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 333..334, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: Some( + InterpolatedStringFormatSpec { + range: 335..340, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 335..340, + node_index: NodeIndex(None), + value: "\"d\"", + }, + ), + ], + }, + ), + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + }, + }, + ), + ), + }, + }, + ), + }, + ), ], }, ) From 9e404a30c324cfa16605721c7dbc2564dea0d19c Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 15 Oct 2025 15:21:24 +0100 Subject: [PATCH 051/113] Update parser snapshots (#20893) --- .../invalid_syntax@nested_quote_in_format_spec_py312.py.snap | 2 ++ .../valid_syntax@non_nested_quote_in_format_spec_py311.py.snap | 1 + .../tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap | 2 ++ 3 files changed, 5 insertions(+) diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap index ba7e665b79..c39b322387 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_quote_in_format_spec_py312.py.snap @@ -55,6 +55,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -67,6 +68,7 @@ Module( quote_style: Double, prefix: Empty, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap index eea96fe16f..76a3604fd6 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_nested_quote_in_format_spec_py311.py.snap @@ -62,6 +62,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap index f9d61369a7..676cadad44 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep701_f_string_py311.py.snap @@ -656,6 +656,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), @@ -717,6 +718,7 @@ Module( quote_style: Double, prefix: Regular, triple_quoted: false, + unclosed: false, }, }, ), From c06c3f9505cdc975b8b79734cd73215458b7712f Mon Sep 17 00:00:00 2001 From: Dan Parizher <105245560+danparizher@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:51:55 -0400 Subject: [PATCH 052/113] [`pyupgrade`] Fix false negative for `TypeVar` with default argument in `non-pep695-generic-class` (`UP046`) (#20660) ## Summary Fixes #20656 --------- Co-authored-by: Brent Westbrook --- .../test/fixtures/pyupgrade/UP040.py | 10 +- .../test/fixtures/pyupgrade/UP046_0.py | 10 +- .../test/fixtures/pyupgrade/UP047.py | 6 +- crates/ruff_linter/src/preview.rs | 5 + crates/ruff_linter/src/rules/pyupgrade/mod.rs | 24 +++- .../src/rules/pyupgrade/rules/pep695/mod.rs | 127 +++++++++--------- .../rules/pep695/non_pep695_generic_class.rs | 2 +- .../pep695/non_pep695_generic_function.rs | 2 +- .../rules/pep695/non_pep695_type_alias.rs | 7 +- ...er__rules__pyupgrade__tests__UP040.py.snap | 26 +++- ...pgrade__tests__UP040.py__preview_diff.snap | 49 +++++++ ...grade__tests__UP040.pyi__preview_diff.snap | 10 ++ ...rade__tests__UP046_0.py__preview_diff.snap | 48 +++++++ ...rade__tests__UP046_1.py__preview_diff.snap | 10 ++ ...pgrade__tests__UP047.py__preview_diff.snap | 29 ++++ 15 files changed, 289 insertions(+), 76 deletions(-) create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.pyi__preview_diff.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py__preview_diff.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py__preview_diff.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py__preview_diff.snap diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py index f6797167e1..1d7ed4faab 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py @@ -43,7 +43,7 @@ class Foo: T = typing.TypeVar(*args) x: typing.TypeAlias = list[T] -# `default` should be skipped for now, added in Python 3.13 +# `default` was added in Python 3.13 T = typing.TypeVar("T", default=Any) x: typing.TypeAlias = list[T] @@ -90,9 +90,9 @@ PositiveList = TypeAliasType( "PositiveList2", list[Annotated[T, Gt(0)]], type_params=(T,) ) -# `default` should be skipped for now, added in Python 3.13 +# `default` was added in Python 3.13 T = typing.TypeVar("T", default=Any) -AnyList = TypeAliasType("AnyList", list[T], typep_params=(T,)) +AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) # unsafe fix if comments within the fix T = TypeVar("T") @@ -128,3 +128,7 @@ T: TypeAlias = ( # comment0 str # comment6 # comment7 ) # comment8 + +# Test case for TypeVar with default - should be converted when preview mode is enabled +T_default = TypeVar("T_default", default=int) +DefaultList: TypeAlias = list[T_default] diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py index 1d3bcc58be..7983ed4756 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py @@ -122,7 +122,7 @@ class MixedGenerics[U]: return (u, t) -# TODO(brent) default requires 3.13 +# default requires 3.13 V = TypeVar("V", default=Any, bound=str) @@ -130,6 +130,14 @@ class DefaultTypeVar(Generic[V]): # -> [V: str = Any] var: V +# Test case for TypeVar with default but no bound +W = TypeVar("W", default=int) + + +class DefaultOnlyTypeVar(Generic[W]): # -> [W = int] + var: W + + # nested classes and functions are skipped class Outer: class Inner(Generic[T]): diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py index 48b5d431de..e22d79e52e 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py @@ -44,9 +44,7 @@ def any_str_param(s: AnyStr) -> AnyStr: return s -# these cases are not handled - -# TODO(brent) default requires 3.13 +# default requires 3.13 V = TypeVar("V", default=Any, bound=str) @@ -54,6 +52,8 @@ def default_var(v: V) -> V: return v +# these cases are not handled + def outer(): def inner(t: T) -> T: return t diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 87895090e5..dfe95b94bb 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -242,6 +242,11 @@ pub(crate) const fn is_refined_submodule_import_match_enabled(settings: &LinterS settings.preview.is_enabled() } +// https://github.com/astral-sh/ruff/pull/20660 +pub(crate) const fn is_type_var_default_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} + // github.com/astral-sh/ruff/issues/20004 pub(crate) const fn is_b006_check_guaranteed_mutable_expr_enabled( settings: &LinterSettings, diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs index d882cd5cca..044c90b3a2 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs @@ -19,7 +19,7 @@ mod tests { use crate::rules::{isort, pyupgrade}; use crate::settings::types::PreviewMode; use crate::test::{test_path, test_snippet}; - use crate::{assert_diagnostics, settings}; + use crate::{assert_diagnostics, assert_diagnostics_diff, settings}; #[test_case(Rule::ConvertNamedTupleFunctionalToClass, Path::new("UP014.py"))] #[test_case(Rule::ConvertTypedDictFunctionalToClass, Path::new("UP013.py"))] @@ -140,6 +140,28 @@ mod tests { Ok(()) } + #[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.py"))] + #[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.pyi"))] + #[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_0.py"))] + #[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_1.py"))] + #[test_case(Rule::NonPEP695GenericFunction, Path::new("UP047.py"))] + fn type_var_default_preview(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}__preview_diff", path.to_string_lossy()); + assert_diagnostics_diff!( + snapshot, + Path::new("pyupgrade").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Disabled, + ..settings::LinterSettings::for_rule(rule_code) + }, + &settings::LinterSettings { + preview: PreviewMode::Enabled, + ..settings::LinterSettings::for_rule(rule_code) + }, + ); + Ok(()) + } + #[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))] #[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))] #[test_case(Rule::QuotedAnnotation, Path::new("UP037_2.pyi"))] diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs index 41ae88289f..c633c5b4ee 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs @@ -14,13 +14,14 @@ use ruff_python_ast::{ use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; +use crate::checkers::ast::Checker; +use crate::preview::is_type_var_default_enabled; + pub(crate) use non_pep695_generic_class::*; pub(crate) use non_pep695_generic_function::*; pub(crate) use non_pep695_type_alias::*; pub(crate) use private_type_parameter::*; -use crate::checkers::ast::Checker; - mod non_pep695_generic_class; mod non_pep695_generic_function; mod non_pep695_type_alias; @@ -122,6 +123,10 @@ impl Display for DisplayTypeVar<'_> { } } } + if let Some(default) = self.type_var.default { + f.write_str(" = ")?; + f.write_str(&self.source[default.range()])?; + } Ok(()) } @@ -133,66 +138,63 @@ impl<'a> From<&'a TypeVar<'a>> for TypeParam { name, restriction, kind, - default: _, // TODO(brent) see below + default, }: &'a TypeVar<'a>, ) -> Self { + let default = default.map(|expr| Box::new(expr.clone())); match kind { - TypeParamKind::TypeVar => { - TypeParam::TypeVar(TypeParamTypeVar { - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - name: Identifier::new(*name, TextRange::default()), - bound: match restriction { - Some(TypeVarRestriction::Bound(bound)) => Some(Box::new((*bound).clone())), - Some(TypeVarRestriction::Constraint(constraints)) => { - Some(Box::new(Expr::Tuple(ast::ExprTuple { - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - elts: constraints.iter().map(|expr| (*expr).clone()).collect(), - ctx: ast::ExprContext::Load, - parenthesized: true, - }))) - } - Some(TypeVarRestriction::AnyStr) => { - Some(Box::new(Expr::Tuple(ast::ExprTuple { - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - elts: vec![ - Expr::Name(ExprName { - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - id: Name::from("str"), - ctx: ast::ExprContext::Load, - }), - Expr::Name(ExprName { - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - id: Name::from("bytes"), - ctx: ast::ExprContext::Load, - }), - ], - ctx: ast::ExprContext::Load, - parenthesized: true, - }))) - } - None => None, - }, - // We don't handle defaults here yet. Should perhaps be a different rule since - // defaults are only valid in 3.13+. - default: None, - }) - } + TypeParamKind::TypeVar => TypeParam::TypeVar(TypeParamTypeVar { + range: TextRange::default(), + node_index: ruff_python_ast::AtomicNodeIndex::NONE, + name: Identifier::new(*name, TextRange::default()), + bound: match restriction { + Some(TypeVarRestriction::Bound(bound)) => Some(Box::new((*bound).clone())), + Some(TypeVarRestriction::Constraint(constraints)) => { + Some(Box::new(Expr::Tuple(ast::ExprTuple { + range: TextRange::default(), + node_index: ruff_python_ast::AtomicNodeIndex::NONE, + elts: constraints.iter().map(|expr| (*expr).clone()).collect(), + ctx: ast::ExprContext::Load, + parenthesized: true, + }))) + } + Some(TypeVarRestriction::AnyStr) => { + Some(Box::new(Expr::Tuple(ast::ExprTuple { + range: TextRange::default(), + node_index: ruff_python_ast::AtomicNodeIndex::NONE, + elts: vec![ + Expr::Name(ExprName { + range: TextRange::default(), + node_index: ruff_python_ast::AtomicNodeIndex::NONE, + id: Name::from("str"), + ctx: ast::ExprContext::Load, + }), + Expr::Name(ExprName { + range: TextRange::default(), + node_index: ruff_python_ast::AtomicNodeIndex::NONE, + id: Name::from("bytes"), + ctx: ast::ExprContext::Load, + }), + ], + ctx: ast::ExprContext::Load, + parenthesized: true, + }))) + } + None => None, + }, + default, + }), TypeParamKind::TypeVarTuple => TypeParam::TypeVarTuple(TypeParamTypeVarTuple { range: TextRange::default(), node_index: ruff_python_ast::AtomicNodeIndex::NONE, name: Identifier::new(*name, TextRange::default()), - default: None, + default, }), TypeParamKind::ParamSpec => TypeParam::ParamSpec(TypeParamParamSpec { range: TextRange::default(), node_index: ruff_python_ast::AtomicNodeIndex::NONE, name: Identifier::new(*name, TextRange::default()), - default: None, + default, }), } } @@ -318,8 +320,8 @@ pub(crate) fn expr_name_to_type_var<'a>( .first() .is_some_and(Expr::is_string_literal_expr) { - // TODO(brent) `default` was added in PEP 696 and Python 3.13 but can't be used in - // generic type parameters before that + // `default` was added in PEP 696 and Python 3.13. We now support converting + // TypeVars with defaults to PEP 695 type parameters. // // ```python // T = TypeVar("T", default=Any, bound=str) @@ -367,21 +369,22 @@ fn in_nested_context(checker: &Checker) -> bool { } /// Deduplicate `vars`, returning `None` if `vars` is empty or any duplicates are found. -fn check_type_vars(vars: Vec>) -> Option>> { +/// Also returns `None` if any `TypeVar` has a default value and preview mode is not enabled. +fn check_type_vars<'a>(vars: Vec>, checker: &Checker) -> Option>> { if vars.is_empty() { return None; } + // If any type variables have defaults and preview mode is not enabled, skip the rule + if vars.iter().any(|tv| tv.default.is_some()) + && !is_type_var_default_enabled(checker.settings()) + { + return None; + } + // If any type variables were not unique, just bail out here. this is a runtime error and we - // can't predict what the user wanted. also bail out if any Python 3.13+ default values are - // found on the type parameters - (vars - .iter() - .unique_by(|tvar| tvar.name) - .filter(|tvar| tvar.default.is_none()) - .count() - == vars.len()) - .then_some(vars) + // can't predict what the user wanted. + (vars.iter().unique_by(|tvar| tvar.name).count() == vars.len()).then_some(vars) } /// Search `class_bases` for a `typing.Generic` base class. Returns the `Generic` expression (if diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs index 00e76a5360..0d9f8ad2d6 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs @@ -186,7 +186,7 @@ pub(crate) fn non_pep695_generic_class(checker: &Checker, class_def: &StmtClassD // // just because we can't confirm that `SomethingElse` is a `TypeVar` if !visitor.any_skipped { - let Some(type_vars) = check_type_vars(visitor.vars) else { + let Some(type_vars) = check_type_vars(visitor.vars, checker) else { diagnostic.defuse(); return; }; diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs index 645fbcad61..93bd368f45 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs @@ -154,7 +154,7 @@ pub(crate) fn non_pep695_generic_function(checker: &Checker, function_def: &Stmt } } - let Some(type_vars) = check_type_vars(type_vars) else { + let Some(type_vars) = check_type_vars(type_vars, checker) else { return; }; diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs index e14c5c8632..54390c998c 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs @@ -8,6 +8,7 @@ use ruff_python_ast::{Expr, ExprCall, ExprName, Keyword, StmtAnnAssign, StmtAssi use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::preview::is_type_var_default_enabled; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; use ruff_python_ast::PythonVersion; @@ -232,8 +233,10 @@ pub(crate) fn non_pep695_type_alias(checker: &Checker, stmt: &StmtAnnAssign) { .unique_by(|tvar| tvar.name) .collect::>(); - // TODO(brent) handle `default` arg for Python 3.13+ - if vars.iter().any(|tv| tv.default.is_some()) { + // Skip if any TypeVar has defaults and preview mode is not enabled + if vars.iter().any(|tv| tv.default.is_some()) + && !is_type_var_default_enabled(checker.settings()) + { return; } diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap index 2ea0df676d..ac492e70d3 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap @@ -217,7 +217,7 @@ UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of the `type` keywo 44 | x: typing.TypeAlias = list[T] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 45 | -46 | # `default` should be skipped for now, added in Python 3.13 +46 | # `default` was added in Python 3.13 | help: Use the `type` keyword 41 | @@ -226,7 +226,7 @@ help: Use the `type` keyword - x: typing.TypeAlias = list[T] 44 + type x = list[T] 45 | -46 | # `default` should be skipped for now, added in Python 3.13 +46 | # `default` was added in Python 3.13 47 | T = typing.TypeVar("T", default=Any) note: This is an unsafe fix and may change runtime behavior @@ -355,6 +355,26 @@ help: Use the `type` keyword 87 | # OK: Other name 88 | T = TypeVar("T", bound=SupportGt) +UP040 [*] Type alias `AnyList` uses `TypeAliasType` assignment instead of the `type` keyword + --> UP040.py:95:1 + | +93 | # `default` was added in Python 3.13 +94 | T = typing.TypeVar("T", default=Any) +95 | AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +96 | +97 | # unsafe fix if comments within the fix + | +help: Use the `type` keyword +92 | +93 | # `default` was added in Python 3.13 +94 | T = typing.TypeVar("T", default=Any) + - AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) +95 + type AnyList[T = Any] = list[T] +96 | +97 | # unsafe fix if comments within the fix +98 | T = TypeVar("T") + UP040 [*] Type alias `PositiveList` uses `TypeAliasType` assignment instead of the `type` keyword --> UP040.py:99:1 | @@ -469,6 +489,8 @@ UP040 [*] Type alias `T` uses `TypeAlias` annotation instead of the `type` keywo 129 | | # comment7 130 | | ) # comment8 | |_^ +131 | +132 | # Test case for TypeVar with default - should be converted when preview mode is enabled | help: Use the `type` keyword 119 | | str diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap new file mode 100644 index 0000000000..475e758873 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap @@ -0,0 +1,49 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 2 + +--- Added --- +UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of the `type` keyword + --> UP040.py:48:1 + | +46 | # `default` was added in Python 3.13 +47 | T = typing.TypeVar("T", default=Any) +48 | x: typing.TypeAlias = list[T] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +49 | +50 | # OK + | +help: Use the `type` keyword +45 | +46 | # `default` was added in Python 3.13 +47 | T = typing.TypeVar("T", default=Any) + - x: typing.TypeAlias = list[T] +48 + type x[T = Any] = list[T] +49 | +50 | # OK +51 | x: TypeAlias +note: This is an unsafe fix and may change runtime behavior + + +UP040 [*] Type alias `DefaultList` uses `TypeAlias` annotation instead of the `type` keyword + --> UP040.py:134:1 + | +132 | # Test case for TypeVar with default - should be converted when preview mode is enabled +133 | T_default = TypeVar("T_default", default=int) +134 | DefaultList: TypeAlias = list[T_default] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Use the `type` keyword +131 | +132 | # Test case for TypeVar with default - should be converted when preview mode is enabled +133 | T_default = TypeVar("T_default", default=int) + - DefaultList: TypeAlias = list[T_default] +134 + type DefaultList[T_default = int] = list[T_default] +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.pyi__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.pyi__preview_diff.snap new file mode 100644 index 0000000000..7e33841de0 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.pyi__preview_diff.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py__preview_diff.snap new file mode 100644 index 0000000000..3763460d1d --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py__preview_diff.snap @@ -0,0 +1,48 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 2 + +--- Added --- +UP046 [*] Generic class `DefaultTypeVar` uses `Generic` subclass instead of type parameters + --> UP046_0.py:129:22 + | +129 | class DefaultTypeVar(Generic[V]): # -> [V: str = Any] + | ^^^^^^^^^^ +130 | var: V + | +help: Use type parameters +126 | V = TypeVar("V", default=Any, bound=str) +127 | +128 | + - class DefaultTypeVar(Generic[V]): # -> [V: str = Any] +129 + class DefaultTypeVar[V: str = Any]: # -> [V: str = Any] +130 | var: V +131 | +132 | +note: This is an unsafe fix and may change runtime behavior + + +UP046 [*] Generic class `DefaultOnlyTypeVar` uses `Generic` subclass instead of type parameters + --> UP046_0.py:137:26 + | +137 | class DefaultOnlyTypeVar(Generic[W]): # -> [W = int] + | ^^^^^^^^^^ +138 | var: W + | +help: Use type parameters +134 | W = TypeVar("W", default=int) +135 | +136 | + - class DefaultOnlyTypeVar(Generic[W]): # -> [W = int] +137 + class DefaultOnlyTypeVar[W = int]: # -> [W = int] +138 | var: W +139 | +140 | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py__preview_diff.snap new file mode 100644 index 0000000000..7e33841de0 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py__preview_diff.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py__preview_diff.snap new file mode 100644 index 0000000000..2a11cd2e59 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py__preview_diff.snap @@ -0,0 +1,29 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 1 + +--- Added --- +UP047 [*] Generic function `default_var` should use type parameters + --> UP047.py:51:5 + | +51 | def default_var(v: V) -> V: + | ^^^^^^^^^^^^^^^^^ +52 | return v + | +help: Use type parameters +48 | V = TypeVar("V", default=Any, bound=str) +49 | +50 | + - def default_var(v: V) -> V: +51 + def default_var[V: str = Any](v: V) -> V: +52 | return v +53 | +54 | +note: This is an unsafe fix and may change runtime behavior From 98d27c412810e157f8a65ea75726d66676628225 Mon Sep 17 00:00:00 2001 From: Dan Parizher <105245560+danparizher@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:06:03 -0400 Subject: [PATCH 053/113] [`flake8-pyi`] Fix operator precedence by adding parentheses when needed (`PYI061`) (#20508) ## Summary Fixes #20265 --------- Co-authored-by: Brent Westbrook --- .../test/fixtures/flake8_pyi/PYI061.py | 8 + .../rules/redundant_none_literal.rs | 50 +++++- ...__flake8_pyi__tests__PYI061_PYI061.py.snap | 112 +++++++++++++ ...ke8_pyi__tests__py38_PYI061_PYI061.py.snap | 148 ++++++++++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI061.py b/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI061.py index 4bdc3d9879..f3f23663d9 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI061.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI061.py @@ -78,3 +78,11 @@ b: None | Literal[None] | None c: (None | Literal[None]) | None d: None | (Literal[None] | None) e: None | ((None | Literal[None]) | None) | None + +# Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs index eb188c245d..78441d1f8b 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_none_literal.rs @@ -4,6 +4,8 @@ use ruff_python_ast::{ self as ast, Expr, ExprBinOp, ExprContext, ExprNoneLiteral, Operator, PythonVersion, helpers::{pep_604_union, typing_optional}, name::Name, + operator_precedence::OperatorPrecedence, + parenthesize::parenthesized_range, }; use ruff_python_semantic::analyze::typing::{traverse_literal, traverse_union}; use ruff_text_size::{Ranged, TextRange}; @@ -238,7 +240,19 @@ fn create_fix( node_index: ruff_python_ast::AtomicNodeIndex::NONE, }); let union_expr = pep_604_union(&[new_literal_expr, none_expr]); - let content = checker.generator().expr(&union_expr); + + // Check if we need parentheses to preserve operator precedence + let content = if needs_parentheses_for_precedence( + semantic, + literal_expr, + checker.comment_ranges(), + checker.source(), + ) { + format!("({})", checker.generator().expr(&union_expr)) + } else { + checker.generator().expr(&union_expr) + }; + let union_edit = Edit::range_replacement(content, literal_expr.range()); Fix::applicable_edit(union_edit, applicability) } @@ -256,3 +270,37 @@ enum UnionKind { TypingOptional, BitOr, } + +/// Check if the union expression needs parentheses to preserve operator precedence. +/// This is needed when the union is part of a larger expression where the `|` operator +/// has lower precedence than the surrounding operations (like attribute access). +fn needs_parentheses_for_precedence( + semantic: &ruff_python_semantic::SemanticModel, + literal_expr: &Expr, + comment_ranges: &ruff_python_trivia::CommentRanges, + source: &str, +) -> bool { + // Get the parent expression to check if we're in a context that needs parentheses + let Some(parent_expr) = semantic.current_expression_parent() else { + return false; + }; + + // Check if the literal expression is already parenthesized + if parenthesized_range( + literal_expr.into(), + parent_expr.into(), + comment_ranges, + source, + ) + .is_some() + { + return false; // Already parenthesized, don't add more + } + + // Check if the parent expression has higher precedence than the `|` operator + let union_precedence = OperatorPrecedence::BitOr; + let parent_precedence = OperatorPrecedence::from(parent_expr); + + // If the parent operation has higher precedence than `|`, we need parentheses + parent_precedence > union_precedence +} diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.py.snap index db21e97656..42a4d5de5d 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.py.snap +++ b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI061_PYI061.py.snap @@ -423,5 +423,117 @@ PYI061 Use `None` rather than `Literal[None]` 79 | d: None | (Literal[None] | None) 80 | e: None | ((None | Literal[None]) | None) | None | ^^^^ +81 | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) | help: Replace with `None` + +PYI061 [*] Use `Literal[...] | None` rather than `Literal[None, ...]` + --> PYI061.py:83:18 + | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ + | ^^^^ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] + | +help: Replace with `Literal[...] | None` +80 | e: None | ((None | Literal[None]) | None) | None +81 | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) + - print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +83 + print((Literal[1] | None).__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + +PYI061 [*] Use `Literal[...] | None` rather than `Literal[None, ...]` + --> PYI061.py:84:18 + | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() + | ^^^^ +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + | +help: Replace with `Literal[...] | None` +81 | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ + - print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +84 + print((Literal[1] | None).method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + +PYI061 [*] Use `Literal[...] | None` rather than `Literal[None, ...]` + --> PYI061.py:85:18 + | +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] + | ^^^^ +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + | +help: Replace with `Literal[...] | None` +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() + - print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +85 + print((Literal[1] | None)[0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + +PYI061 [*] Use `Literal[...] | None` rather than `Literal[None, ...]` + --> PYI061.py:86:18 + | +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + | ^^^^ +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + | +help: Replace with `Literal[...] | None` +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] + - print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +86 + print((Literal[1] | None) + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + +PYI061 [*] Use `Literal[...] | None` rather than `Literal[None, ...]` + --> PYI061.py:87:18 + | +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + | ^^^^ +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + | +help: Replace with `Literal[...] | None` +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + - print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +87 + print((Literal[1] | None) * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + +PYI061 [*] Use `Literal[...] | None` rather than `Literal[None, ...]` + --> PYI061.py:88:19 + | +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + | ^^^^ + | +help: Replace with `Literal[...] | None` +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + - print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ +88 + print((Literal[1] | None).__dict__) # Should become ((Literal[1] | None)).__dict__ diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.py.snap index 2236235dfb..7419d8db9b 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.py.snap +++ b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__py38_PYI061_PYI061.py.snap @@ -465,5 +465,153 @@ PYI061 Use `None` rather than `Literal[None]` 79 | d: None | (Literal[None] | None) 80 | e: None | ((None | Literal[None]) | None) | None | ^^^^ +81 | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) | help: Replace with `None` + +PYI061 [*] Use `Optional[Literal[...]]` rather than `Literal[None, ...]` + --> PYI061.py:83:18 + | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ + | ^^^^ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] + | +help: Replace with `Optional[Literal[...]]` + - from typing import Literal, Union +1 + from typing import Literal, Union, Optional +2 | +3 | +4 | def func1(arg1: Literal[None]): +-------------------------------------------------------------------------------- +80 | e: None | ((None | Literal[None]) | None) | None +81 | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) + - print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +83 + print(Optional[Literal[1]].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + +PYI061 [*] Use `Optional[Literal[...]]` rather than `Literal[None, ...]` + --> PYI061.py:84:18 + | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() + | ^^^^ +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + | +help: Replace with `Optional[Literal[...]]` + - from typing import Literal, Union +1 + from typing import Literal, Union, Optional +2 | +3 | +4 | def func1(arg1: Literal[None]): +-------------------------------------------------------------------------------- +81 | +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ + - print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +84 + print(Optional[Literal[1]].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + +PYI061 [*] Use `Optional[Literal[...]]` rather than `Literal[None, ...]` + --> PYI061.py:85:18 + | +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] + | ^^^^ +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + | +help: Replace with `Optional[Literal[...]]` + - from typing import Literal, Union +1 + from typing import Literal, Union, Optional +2 | +3 | +4 | def func1(arg1: Literal[None]): +-------------------------------------------------------------------------------- +82 | # Test cases for operator precedence issue (https://github.com/astral-sh/ruff/issues/20265) +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() + - print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +85 + print(Optional[Literal[1]][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + +PYI061 [*] Use `Optional[Literal[...]]` rather than `Literal[None, ...]` + --> PYI061.py:86:18 + | +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + | ^^^^ +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + | +help: Replace with `Optional[Literal[...]]` + - from typing import Literal, Union +1 + from typing import Literal, Union, Optional +2 | +3 | +4 | def func1(arg1: Literal[None]): +-------------------------------------------------------------------------------- +83 | print(Literal[1, None].__dict__) # Should become (Literal[1] | None).__dict__ +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] + - print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +86 + print(Optional[Literal[1]] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + +PYI061 [*] Use `Optional[Literal[...]]` rather than `Literal[None, ...]` + --> PYI061.py:87:18 + | +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + | ^^^^ +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + | +help: Replace with `Optional[Literal[...]]` + - from typing import Literal, Union +1 + from typing import Literal, Union, Optional +2 | +3 | +4 | def func1(arg1: Literal[None]): +-------------------------------------------------------------------------------- +84 | print(Literal[1, None].method()) # Should become (Literal[1] | None).method() +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 + - print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +87 + print(Optional[Literal[1]] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + +PYI061 [*] Use `Optional[Literal[...]]` rather than `Literal[None, ...]` + --> PYI061.py:88:19 + | +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 +88 | print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ + | ^^^^ + | +help: Replace with `Optional[Literal[...]]` + - from typing import Literal, Union +1 + from typing import Literal, Union, Optional +2 | +3 | +4 | def func1(arg1: Literal[None]): +-------------------------------------------------------------------------------- +85 | print(Literal[1, None][0]) # Should become (Literal[1] | None)[0] +86 | print(Literal[1, None] + 1) # Should become (Literal[1] | None) + 1 +87 | print(Literal[1, None] * 2) # Should become (Literal[1] | None) * 2 + - print((Literal[1, None]).__dict__) # Should become ((Literal[1] | None)).__dict__ +88 + print((Optional[Literal[1]]).__dict__) # Should become ((Literal[1] | None)).__dict__ From d2a6ef7491a2a854748f64186d09bbd8478ea569 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Thu, 16 Oct 2025 00:19:55 +0800 Subject: [PATCH 054/113] [`airflow`] Add warning to `airflow.datasets.DatasetEvent` usage (`AIR301`) (#20551) ## Summary `airflow.datasets.DatasetEvent` has been removed in 3 but `AssetEvent` might be added in the future ## Test Plan update the test fixture and reorg in the second commit --- .../test/fixtures/airflow/AIR301_names.py | 3 +- .../src/rules/airflow/rules/removal_in_3.rs | 5 + ...irflow__tests__AIR301_AIR301_names.py.snap | 243 +++++++++--------- 3 files changed, 134 insertions(+), 117 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/airflow/AIR301_names.py b/crates/ruff_linter/resources/test/fixtures/airflow/AIR301_names.py index 68e5435884..cbf49b061b 100644 --- a/crates/ruff_linter/resources/test/fixtures/airflow/AIR301_names.py +++ b/crates/ruff_linter/resources/test/fixtures/airflow/AIR301_names.py @@ -11,7 +11,7 @@ from airflow import ( ) from airflow.api_connexion.security import requires_access from airflow.contrib.aws_athena_hook import AWSAthenaHook -from airflow.datasets import DatasetAliasEvent +from airflow.datasets import DatasetAliasEvent, DatasetEvent from airflow.operators.postgres_operator import Mapping from airflow.operators.subdag import SubDagOperator from airflow.secrets.cache import SecretCache @@ -48,6 +48,7 @@ AWSAthenaHook() # airflow.datasets DatasetAliasEvent() +DatasetEvent() # airflow.operators.subdag.* diff --git a/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs b/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs index 2baf804d15..41defac3d0 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs @@ -655,6 +655,11 @@ fn check_name(checker: &Checker, expr: &Expr, range: TextRange) { }, // airflow.datasets ["airflow", "datasets", "DatasetAliasEvent"] => Replacement::None, + ["airflow", "datasets", "DatasetEvent"] => Replacement::Message( + "`DatasetEvent` has been made private in Airflow 3. \ + Use `dict[str, Any]` for the time being. \ + An `AssetEvent` type will be added to the apache-airflow-task-sdk in a future version.", + ), // airflow.hooks ["airflow", "hooks", "base_hook", "BaseHook"] => Replacement::Rename { diff --git a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names.py.snap b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names.py.snap index 95b78f3d97..38603d1067 100644 --- a/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names.py.snap +++ b/crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_names.py.snap @@ -104,38 +104,49 @@ AIR301 `airflow.datasets.DatasetAliasEvent` is removed in Airflow 3.0 49 | # airflow.datasets 50 | DatasetAliasEvent() | ^^^^^^^^^^^^^^^^^ +51 | DatasetEvent() | -AIR301 `airflow.operators.subdag.SubDagOperator` is removed in Airflow 3.0 - --> AIR301_names.py:54:1 +AIR301 `airflow.datasets.DatasetEvent` is removed in Airflow 3.0 + --> AIR301_names.py:51:1 | -53 | # airflow.operators.subdag.* -54 | SubDagOperator() +49 | # airflow.datasets +50 | DatasetAliasEvent() +51 | DatasetEvent() + | ^^^^^^^^^^^^ + | +help: `DatasetEvent` has been made private in Airflow 3. Use `dict[str, Any]` for the time being. An `AssetEvent` type will be added to the apache-airflow-task-sdk in a future version. + +AIR301 `airflow.operators.subdag.SubDagOperator` is removed in Airflow 3.0 + --> AIR301_names.py:55:1 + | +54 | # airflow.operators.subdag.* +55 | SubDagOperator() | ^^^^^^^^^^^^^^ -55 | -56 | # airflow.operators.postgres_operator +56 | +57 | # airflow.operators.postgres_operator | help: The whole `airflow.subdag` module has been removed. AIR301 `airflow.operators.postgres_operator.Mapping` is removed in Airflow 3.0 - --> AIR301_names.py:57:1 + --> AIR301_names.py:58:1 | -56 | # airflow.operators.postgres_operator -57 | Mapping() +57 | # airflow.operators.postgres_operator +58 | Mapping() | ^^^^^^^ -58 | -59 | # airflow.secrets +59 | +60 | # airflow.secrets | AIR301 [*] `airflow.secrets.cache.SecretCache` is removed in Airflow 3.0 - --> AIR301_names.py:64:1 + --> AIR301_names.py:65:1 | -63 | # airflow.secrets.cache -64 | SecretCache() +64 | # airflow.secrets.cache +65 | SecretCache() | ^^^^^^^^^^^ | help: Use `SecretCache` from `airflow.sdk` instead. -14 | from airflow.datasets import DatasetAliasEvent +14 | from airflow.datasets import DatasetAliasEvent, DatasetEvent 15 | from airflow.operators.postgres_operator import Mapping 16 | from airflow.operators.subdag import SubDagOperator - from airflow.secrets.cache import SecretCache @@ -153,211 +164,211 @@ help: Use `SecretCache` from `airflow.sdk` instead. note: This is an unsafe fix and may change runtime behavior AIR301 `airflow.triggers.external_task.TaskStateTrigger` is removed in Airflow 3.0 - --> AIR301_names.py:68:1 + --> AIR301_names.py:69:1 | -67 | # airflow.triggers.external_task -68 | TaskStateTrigger() +68 | # airflow.triggers.external_task +69 | TaskStateTrigger() | ^^^^^^^^^^^^^^^^ -69 | -70 | # airflow.utils.date +70 | +71 | # airflow.utils.date | AIR301 `airflow.utils.dates.date_range` is removed in Airflow 3.0 - --> AIR301_names.py:71:1 + --> AIR301_names.py:72:1 | -70 | # airflow.utils.date -71 | dates.date_range +71 | # airflow.utils.date +72 | dates.date_range | ^^^^^^^^^^^^^^^^ -72 | dates.days_ago +73 | dates.days_ago | AIR301 `airflow.utils.dates.days_ago` is removed in Airflow 3.0 - --> AIR301_names.py:72:1 + --> AIR301_names.py:73:1 | -70 | # airflow.utils.date -71 | dates.date_range -72 | dates.days_ago +71 | # airflow.utils.date +72 | dates.date_range +73 | dates.days_ago | ^^^^^^^^^^^^^^ -73 | -74 | date_range +74 | +75 | date_range | help: Use `pendulum.today('UTC').add(days=-N, ...)` instead AIR301 `airflow.utils.dates.date_range` is removed in Airflow 3.0 - --> AIR301_names.py:74:1 + --> AIR301_names.py:75:1 | -72 | dates.days_ago -73 | -74 | date_range +73 | dates.days_ago +74 | +75 | date_range | ^^^^^^^^^^ -75 | days_ago -76 | infer_time_unit +76 | days_ago +77 | infer_time_unit | AIR301 `airflow.utils.dates.days_ago` is removed in Airflow 3.0 - --> AIR301_names.py:75:1 + --> AIR301_names.py:76:1 | -74 | date_range -75 | days_ago +75 | date_range +76 | days_ago | ^^^^^^^^ -76 | infer_time_unit -77 | parse_execution_date +77 | infer_time_unit +78 | parse_execution_date | help: Use `pendulum.today('UTC').add(days=-N, ...)` instead AIR301 `airflow.utils.dates.infer_time_unit` is removed in Airflow 3.0 - --> AIR301_names.py:76:1 + --> AIR301_names.py:77:1 | -74 | date_range -75 | days_ago -76 | infer_time_unit +75 | date_range +76 | days_ago +77 | infer_time_unit | ^^^^^^^^^^^^^^^ -77 | parse_execution_date -78 | round_time +78 | parse_execution_date +79 | round_time | AIR301 `airflow.utils.dates.parse_execution_date` is removed in Airflow 3.0 - --> AIR301_names.py:77:1 + --> AIR301_names.py:78:1 | -75 | days_ago -76 | infer_time_unit -77 | parse_execution_date +76 | days_ago +77 | infer_time_unit +78 | parse_execution_date | ^^^^^^^^^^^^^^^^^^^^ -78 | round_time -79 | scale_time_units +79 | round_time +80 | scale_time_units | AIR301 `airflow.utils.dates.round_time` is removed in Airflow 3.0 - --> AIR301_names.py:78:1 + --> AIR301_names.py:79:1 | -76 | infer_time_unit -77 | parse_execution_date -78 | round_time +77 | infer_time_unit +78 | parse_execution_date +79 | round_time | ^^^^^^^^^^ -79 | scale_time_units +80 | scale_time_units | AIR301 `airflow.utils.dates.scale_time_units` is removed in Airflow 3.0 - --> AIR301_names.py:79:1 + --> AIR301_names.py:80:1 | -77 | parse_execution_date -78 | round_time -79 | scale_time_units +78 | parse_execution_date +79 | round_time +80 | scale_time_units | ^^^^^^^^^^^^^^^^ -80 | -81 | # This one was not deprecated. +81 | +82 | # This one was not deprecated. | AIR301 `airflow.utils.dag_cycle_tester.test_cycle` is removed in Airflow 3.0 - --> AIR301_names.py:86:1 + --> AIR301_names.py:87:1 | -85 | # airflow.utils.dag_cycle_tester -86 | test_cycle +86 | # airflow.utils.dag_cycle_tester +87 | test_cycle | ^^^^^^^^^^ | AIR301 `airflow.utils.db.create_session` is removed in Airflow 3.0 - --> AIR301_names.py:90:1 + --> AIR301_names.py:91:1 | -89 | # airflow.utils.db -90 | create_session +90 | # airflow.utils.db +91 | create_session | ^^^^^^^^^^^^^^ -91 | -92 | # airflow.utils.decorators +92 | +93 | # airflow.utils.decorators | AIR301 `airflow.utils.decorators.apply_defaults` is removed in Airflow 3.0 - --> AIR301_names.py:93:1 + --> AIR301_names.py:94:1 | -92 | # airflow.utils.decorators -93 | apply_defaults +93 | # airflow.utils.decorators +94 | apply_defaults | ^^^^^^^^^^^^^^ -94 | -95 | # airflow.utils.file +95 | +96 | # airflow.utils.file | help: `apply_defaults` is now unconditionally done and can be safely removed. AIR301 `airflow.utils.file.mkdirs` is removed in Airflow 3.0 - --> AIR301_names.py:96:1 + --> AIR301_names.py:97:1 | -95 | # airflow.utils.file -96 | mkdirs +96 | # airflow.utils.file +97 | mkdirs | ^^^^^^ | help: Use `pathlib.Path({path}).mkdir` instead AIR301 `airflow.utils.state.SHUTDOWN` is removed in Airflow 3.0 - --> AIR301_names.py:100:1 + --> AIR301_names.py:101:1 | - 99 | # airflow.utils.state -100 | SHUTDOWN +100 | # airflow.utils.state +101 | SHUTDOWN | ^^^^^^^^ -101 | terminating_states +102 | terminating_states | AIR301 `airflow.utils.state.terminating_states` is removed in Airflow 3.0 - --> AIR301_names.py:101:1 + --> AIR301_names.py:102:1 | - 99 | # airflow.utils.state -100 | SHUTDOWN -101 | terminating_states +100 | # airflow.utils.state +101 | SHUTDOWN +102 | terminating_states | ^^^^^^^^^^^^^^^^^^ -102 | -103 | # airflow.utils.trigger_rule +103 | +104 | # airflow.utils.trigger_rule | AIR301 `airflow.utils.trigger_rule.TriggerRule.DUMMY` is removed in Airflow 3.0 - --> AIR301_names.py:104:1 + --> AIR301_names.py:105:1 | -103 | # airflow.utils.trigger_rule -104 | TriggerRule.DUMMY +104 | # airflow.utils.trigger_rule +105 | TriggerRule.DUMMY | ^^^^^^^^^^^^^^^^^ -105 | TriggerRule.NONE_FAILED_OR_SKIPPED +106 | TriggerRule.NONE_FAILED_OR_SKIPPED | AIR301 `airflow.utils.trigger_rule.TriggerRule.NONE_FAILED_OR_SKIPPED` is removed in Airflow 3.0 - --> AIR301_names.py:105:1 + --> AIR301_names.py:106:1 | -103 | # airflow.utils.trigger_rule -104 | TriggerRule.DUMMY -105 | TriggerRule.NONE_FAILED_OR_SKIPPED +104 | # airflow.utils.trigger_rule +105 | TriggerRule.DUMMY +106 | TriggerRule.NONE_FAILED_OR_SKIPPED | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | AIR301 `airflow.www.auth.has_access` is removed in Airflow 3.0 - --> AIR301_names.py:109:1 + --> AIR301_names.py:110:1 | -108 | # airflow.www.auth -109 | has_access +109 | # airflow.www.auth +110 | has_access | ^^^^^^^^^^ -110 | has_access_dataset +111 | has_access_dataset | AIR301 `airflow.www.auth.has_access_dataset` is removed in Airflow 3.0 - --> AIR301_names.py:110:1 + --> AIR301_names.py:111:1 | -108 | # airflow.www.auth -109 | has_access -110 | has_access_dataset +109 | # airflow.www.auth +110 | has_access +111 | has_access_dataset | ^^^^^^^^^^^^^^^^^^ -111 | -112 | # airflow.www.utils +112 | +113 | # airflow.www.utils | AIR301 `airflow.www.utils.get_sensitive_variables_fields` is removed in Airflow 3.0 - --> AIR301_names.py:113:1 + --> AIR301_names.py:114:1 | -112 | # airflow.www.utils -113 | get_sensitive_variables_fields +113 | # airflow.www.utils +114 | get_sensitive_variables_fields | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -114 | should_hide_value_for_key +115 | should_hide_value_for_key | AIR301 `airflow.www.utils.should_hide_value_for_key` is removed in Airflow 3.0 - --> AIR301_names.py:114:1 + --> AIR301_names.py:115:1 | -112 | # airflow.www.utils -113 | get_sensitive_variables_fields -114 | should_hide_value_for_key +113 | # airflow.www.utils +114 | get_sensitive_variables_fields +115 | should_hide_value_for_key | ^^^^^^^^^^^^^^^^^^^^^^^^^ | From 4b7f184ab7848fee4f6641600b4f52a06e772e7a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 15 Oct 2025 17:37:08 +0100 Subject: [PATCH 055/113] Auto-accept snapshot changes as part of typeshed-sync PRs (#20892) --- .github/workflows/sync_typeshed.yaml | 72 +++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/.github/workflows/sync_typeshed.yaml b/.github/workflows/sync_typeshed.yaml index 2185d474d1..5d3b8a7fe4 100644 --- a/.github/workflows/sync_typeshed.yaml +++ b/.github/workflows/sync_typeshed.yaml @@ -16,8 +16,10 @@ name: Sync typeshed # 3. Once the Windows worker is done, a MacOS worker: # a. Checks out the branch created by the Linux worker # b. Syncs all docstrings available on MacOS that are not available on Linux or Windows -# c. Commits the changes and pushes them to the same upstream branch -# d. Creates a PR against the `main` branch using the branch all three workers have pushed to +# c. Attempts to update any snapshots that might have changed +# (this sub-step is allowed to fail) +# d. Commits the changes and pushes them to the same upstream branch +# e. Creates a PR against the `main` branch using the branch all three workers have pushed to # 4. If any of steps 1-3 failed, an issue is created in the `astral-sh/ruff` repository on: @@ -27,7 +29,12 @@ on: - cron: "0 0 1,15 * *" env: - FORCE_COLOR: 1 + # Don't set this flag globally for the workflow: it does strange things + # to the snapshots in the `cargo insta test --accept` step in the MacOS job. + # + # FORCE_COLOR: 1 + + CARGO_TERM_COLOR: always GH_TOKEN: ${{ github.token }} # The name of the upstream branch that the first worker creates, @@ -86,6 +93,8 @@ jobs: git commit -m "Sync typeshed. Source commit: https://github.com/python/typeshed/commit/$(git -C ../typeshed rev-parse HEAD)" --allow-empty - name: Sync Linux docstrings if: ${{ success() }} + env: + FORCE_COLOR: 1 run: | cd ruff ./scripts/codemod_docstrings.sh @@ -125,6 +134,8 @@ jobs: - name: Sync Windows docstrings id: docstrings shell: bash + env: + FORCE_COLOR: 1 run: ./scripts/codemod_docstrings.sh - name: Commit the changes if: ${{ steps.docstrings.outcome == 'success' }} @@ -161,26 +172,63 @@ jobs: git config --global user.name typeshedbot git config --global user.email '<>' - name: Sync macOS docstrings - run: ./scripts/codemod_docstrings.sh - - name: Commit and push the changes if: ${{ success() }} + env: + FORCE_COLOR: 1 run: | + ./scripts/codemod_docstrings.sh git commit -am "Sync macOS docstrings" --allow-empty - + - name: Format the changes + if: ${{ success() }} + env: + FORCE_COLOR: 1 + run: | # Here we just reformat the codemodded stubs so that they are # consistent with the other typeshed stubs around them. # Typeshed formats code using black in their CI, so we just invoke # black on the stubs the same way that typeshed does. uvx black "${VENDORED_TYPESHED}/stdlib" --config "${VENDORED_TYPESHED}/pyproject.toml" || true git commit -am "Format codemodded docstrings" --allow-empty - - rm "${VENDORED_TYPESHED}/pyproject.toml" - git commit -am "Remove pyproject.toml file" - - git push - - name: Create a PR + - name: Remove typeshed pyproject.toml file if: ${{ success() }} run: | + rm "${VENDORED_TYPESHED}/pyproject.toml" + git commit -am "Remove pyproject.toml file" + - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + - name: "Install Rust toolchain" + if: ${{ success() }} + run: rustup show + - name: "Install mold" + if: ${{ success() }} + uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - name: "Install cargo nextest" + if: ${{ success() }} + uses: taiki-e/install-action@522492a8c115f1b6d4d318581f09638e9442547b # v2.62.21 + with: + tool: cargo-nextest + - name: "Install cargo insta" + if: ${{ success() }} + uses: taiki-e/install-action@522492a8c115f1b6d4d318581f09638e9442547b # v2.62.21 + with: + tool: cargo-insta + - name: Update snapshots + if: ${{ success() }} + run: | + # The `cargo insta` docs indicate that `--unreferenced=delete` might be a good option, + # but from local testing it appears to just revert all changes made by `cargo insta test --accept`. + # + # If there were only snapshot-related failures, `cargo insta test --accept` will have exit code 0, + # but if there were also other mdtest failures (for example), it will return a nonzero exit code. + # We don't care about other tests failing here, we just want snapshots updated where possible, + # so we use `|| true` here to ignore the exit code. + cargo insta test --accept --color=always --all-features --test-runner=nextest || true + - name: Commit snapshot changes + if: ${{ success() }} + run: git commit -am "Update snapshots" || echo "No snapshot changes to commit" + - name: Push changes upstream and create a PR + if: ${{ success() }} + run: | + git push gh pr list --repo "${GITHUB_REPOSITORY}" --head "${UPSTREAM_BRANCH}" --json id --jq length | grep 1 && exit 0 # exit if there is existing pr gh pr create --title "[ty] Sync vendored typeshed stubs" --body "Close and reopen this PR to trigger CI" --label "ty" From 9de34e7ac144777c6d853a780e95f721a04f4b79 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama <45118249+mtshiba@users.noreply.github.com> Date: Thu, 16 Oct 2025 03:19:19 +0900 Subject: [PATCH 056/113] [ty] refactor `Place` (#20871) ## Summary Part of astral-sh/ty#1341 The following changes will be made to `Place`. * Introduce `TypeOrigin` * `Place::Type` -> `Place::Defined` * `Place::Unbound` -> `Place::Undefined` * `Boundness` -> `Definedness` `TypeOrigin::Declared`+`Definedness::PossiblyUndefined` are patterns that weren't considered before, but this PR doesn't address them yet, only refactors. ## Test Plan Refactoring --- crates/ty_python_semantic/src/place.rs | 341 +++++++++++------- .../reachability_constraints.rs | 18 +- crates/ty_python_semantic/src/types.rs | 248 +++++++------ .../ty_python_semantic/src/types/builder.rs | 2 +- .../ty_python_semantic/src/types/call/bind.rs | 14 +- crates/ty_python_semantic/src/types/class.rs | 160 ++++---- .../ty_python_semantic/src/types/display.rs | 2 +- crates/ty_python_semantic/src/types/enums.rs | 18 +- .../ty_python_semantic/src/types/function.rs | 4 +- .../src/types/ide_support.rs | 13 +- crates/ty_python_semantic/src/types/infer.rs | 2 +- .../src/types/infer/builder.rs | 203 ++++++----- .../infer/builder/annotation_expression.rs | 97 +++-- .../types/infer/builder/type_expression.rs | 2 +- crates/ty_python_semantic/src/types/member.rs | 87 ++--- .../src/types/protocol_class.rs | 18 +- 16 files changed, 681 insertions(+), 548 deletions(-) diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 6423ccc7e7..d0fa8ba36c 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -21,83 +21,127 @@ pub(crate) use implicit_globals::{ }; #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize)] -pub(crate) enum Boundness { - Bound, - PossiblyUnbound, +pub(crate) enum Definedness { + AlwaysDefined, + PossiblyUndefined, } -impl Boundness { +impl Definedness { pub(crate) const fn max(self, other: Self) -> Self { match (self, other) { - (Boundness::Bound, _) | (_, Boundness::Bound) => Boundness::Bound, - (Boundness::PossiblyUnbound, Boundness::PossiblyUnbound) => Boundness::PossiblyUnbound, + (Definedness::AlwaysDefined, _) | (_, Definedness::AlwaysDefined) => { + Definedness::AlwaysDefined + } + (Definedness::PossiblyUndefined, Definedness::PossiblyUndefined) => { + Definedness::PossiblyUndefined + } } } } -/// The result of a place lookup, which can either be a (possibly unbound) type -/// or a completely unbound place. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize)] +pub(crate) enum TypeOrigin { + Declared, + Inferred, +} + +impl TypeOrigin { + pub(crate) const fn is_declared(self) -> bool { + matches!(self, TypeOrigin::Declared) + } + + pub(crate) const fn merge(self, other: Self) -> Self { + match (self, other) { + (TypeOrigin::Declared, TypeOrigin::Declared) => TypeOrigin::Declared, + _ => TypeOrigin::Inferred, + } + } +} + +/// The result of a place lookup, which can either be a (possibly undefined) type +/// or a completely undefined place. +/// +/// If a place has both a binding and a declaration, the result of the binding is used. /// /// Consider this example: /// ```py /// bound = 1 +/// declared: int /// /// if flag: /// possibly_unbound = 2 +/// possibly_undeclared: int +/// +/// if flag: +/// bound_or_declared = 1 +/// else: +/// bound_or_declared: int /// ``` /// /// If we look up places in this scope, we would get the following results: /// ```rs -/// bound: Place::Type(Type::IntLiteral(1), Boundness::Bound), -/// possibly_unbound: Place::Type(Type::IntLiteral(2), Boundness::PossiblyUnbound), -/// non_existent: Place::Unbound, +/// bound: Place::Defined(Literal[1], TypeOrigin::Inferred, Definedness::AlwaysDefined), +/// declared: Place::Defined(int, TypeOrigin::Declared, Definedness::AlwaysDefined), +/// possibly_unbound: Place::Defined(Literal[2], TypeOrigin::Inferred, Definedness::PossiblyUndefined), +/// possibly_undeclared: Place::Defined(int, TypeOrigin::Declared, Definedness::PossiblyUndefined), +/// bound_or_declared: Place::Defined(Literal[1], TypeOrigin::Inferred, Definedness::PossiblyUndefined), +/// non_existent: Place::Undefined, /// ``` #[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(crate) enum Place<'db> { - Type(Type<'db>, Boundness), - Unbound, + Defined(Type<'db>, TypeOrigin, Definedness), + Undefined, } impl<'db> Place<'db> { - /// Constructor that creates a `Place` with boundness [`Boundness::Bound`]. + /// Constructor that creates a [`Place`] with type origin [`TypeOrigin::Inferred`] and definedness [`Definedness::AlwaysDefined`]. pub(crate) fn bound(ty: impl Into>) -> Self { - Place::Type(ty.into(), Boundness::Bound) + Place::Defined(ty.into(), TypeOrigin::Inferred, Definedness::AlwaysDefined) + } + + /// Constructor that creates a [`Place`] with type origin [`TypeOrigin::Declared`] and definedness [`Definedness::AlwaysDefined`]. + pub(crate) fn declared(ty: impl Into>) -> Self { + Place::Defined(ty.into(), TypeOrigin::Declared, Definedness::AlwaysDefined) } /// Constructor that creates a [`Place`] with a [`crate::types::TodoType`] type - /// and boundness [`Boundness::Bound`]. + /// and definedness [`Definedness::AlwaysDefined`]. #[allow(unused_variables)] // Only unused in release builds pub(crate) fn todo(message: &'static str) -> Self { - Place::Type(todo_type!(message), Boundness::Bound) + Place::Defined( + todo_type!(message), + TypeOrigin::Inferred, + Definedness::AlwaysDefined, + ) } - pub(crate) fn is_unbound(&self) -> bool { - matches!(self, Place::Unbound) + pub(crate) fn is_undefined(&self) -> bool { + matches!(self, Place::Undefined) } - /// Returns the type of the place, ignoring possible unboundness. + /// Returns the type of the place, ignoring possible undefinedness. /// - /// If the place is *definitely* unbound, this function will return `None`. Otherwise, - /// if there is at least one control-flow path where the place is bound, return the type. - pub(crate) fn ignore_possibly_unbound(&self) -> Option> { + /// If the place is *definitely* undefined, this function will return `None`. Otherwise, + /// if there is at least one control-flow path where the place is defined, return the type. + pub(crate) fn ignore_possibly_undefined(&self) -> Option> { match self { - Place::Type(ty, _) => Some(*ty), - Place::Unbound => None, + Place::Defined(ty, _, _) => Some(*ty), + Place::Undefined => None, } } #[cfg(test)] #[track_caller] pub(crate) fn expect_type(self) -> Type<'db> { - self.ignore_possibly_unbound() - .expect("Expected a (possibly unbound) type, not an unbound place") + self.ignore_possibly_undefined() + .expect("Expected a (possibly undefined) type, not an undefined place") } #[must_use] pub(crate) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Place<'db> { match self { - Place::Type(ty, boundness) => Place::Type(f(ty), boundness), - Place::Unbound => Place::Unbound, + Place::Defined(ty, origin, definedness) => Place::Defined(f(ty), origin, definedness), + Place::Undefined => Place::Undefined, } } @@ -114,46 +158,47 @@ impl<'db> Place<'db> { /// This is used to resolve (potential) descriptor attributes. pub(crate) fn try_call_dunder_get(self, db: &'db dyn Db, owner: Type<'db>) -> Place<'db> { match self { - Place::Type(Type::Union(union), boundness) => union.map_with_boundness(db, |elem| { - Place::Type(*elem, boundness).try_call_dunder_get(db, owner) - }), - - Place::Type(Type::Intersection(intersection), boundness) => intersection + Place::Defined(Type::Union(union), origin, definedness) => union .map_with_boundness(db, |elem| { - Place::Type(*elem, boundness).try_call_dunder_get(db, owner) + Place::Defined(*elem, origin, definedness).try_call_dunder_get(db, owner) }), - Place::Type(self_ty, boundness) => { + Place::Defined(Type::Intersection(intersection), origin, definedness) => intersection + .map_with_boundness(db, |elem| { + Place::Defined(*elem, origin, definedness).try_call_dunder_get(db, owner) + }), + + Place::Defined(self_ty, origin, definedness) => { if let Some((dunder_get_return_ty, _)) = self_ty.try_call_dunder_get(db, Type::none(db), owner) { - Place::Type(dunder_get_return_ty, boundness) + Place::Defined(dunder_get_return_ty, origin, definedness) } else { self } } - Place::Unbound => Place::Unbound, + Place::Undefined => Place::Undefined, } } pub(crate) const fn is_definitely_bound(&self) -> bool { - matches!(self, Place::Type(_, Boundness::Bound)) + matches!(self, Place::Defined(_, _, Definedness::AlwaysDefined)) } } impl<'db> From> for PlaceAndQualifiers<'db> { fn from(value: LookupResult<'db>) -> Self { match value { - Ok(type_and_qualifiers) => { - Place::Type(type_and_qualifiers.inner_type(), Boundness::Bound) - .with_qualifiers(type_and_qualifiers.qualifiers()) - } - Err(LookupError::Unbound(qualifiers)) => Place::Unbound.with_qualifiers(qualifiers), - Err(LookupError::PossiblyUnbound(type_and_qualifiers)) => { - Place::Type(type_and_qualifiers.inner_type(), Boundness::PossiblyUnbound) - .with_qualifiers(type_and_qualifiers.qualifiers()) - } + Ok(type_and_qualifiers) => Place::bound(type_and_qualifiers.inner_type()) + .with_qualifiers(type_and_qualifiers.qualifiers()), + Err(LookupError::Undefined(qualifiers)) => Place::Undefined.with_qualifiers(qualifiers), + Err(LookupError::PossiblyUndefined(type_and_qualifiers)) => Place::Defined( + type_and_qualifiers.inner_type(), + TypeOrigin::Inferred, + Definedness::PossiblyUndefined, + ) + .with_qualifiers(type_and_qualifiers.qualifiers()), } } } @@ -161,8 +206,8 @@ impl<'db> From> for PlaceAndQualifiers<'db> { /// Possible ways in which a place lookup can (possibly or definitely) fail. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub(crate) enum LookupError<'db> { - Unbound(TypeQualifiers), - PossiblyUnbound(TypeAndQualifiers<'db>), + Undefined(TypeQualifiers), + PossiblyUndefined(TypeAndQualifiers<'db>), } impl<'db> LookupError<'db> { @@ -174,15 +219,17 @@ impl<'db> LookupError<'db> { ) -> LookupResult<'db> { let fallback = fallback.into_lookup_result(); match (&self, &fallback) { - (LookupError::Unbound(_), _) => fallback, - (LookupError::PossiblyUnbound { .. }, Err(LookupError::Unbound(_))) => Err(self), - (LookupError::PossiblyUnbound(ty), Ok(ty2)) => Ok(TypeAndQualifiers::new( + (LookupError::Undefined(_), _) => fallback, + (LookupError::PossiblyUndefined { .. }, Err(LookupError::Undefined(_))) => Err(self), + (LookupError::PossiblyUndefined(ty), Ok(ty2)) => Ok(TypeAndQualifiers::new( UnionType::from_elements(db, [ty.inner_type(), ty2.inner_type()]), + ty.origin().merge(ty2.origin()), ty.qualifiers().union(ty2.qualifiers()), )), - (LookupError::PossiblyUnbound(ty), Err(LookupError::PossiblyUnbound(ty2))) => { - Err(LookupError::PossiblyUnbound(TypeAndQualifiers::new( + (LookupError::PossiblyUndefined(ty), Err(LookupError::PossiblyUndefined(ty2))) => { + Err(LookupError::PossiblyUndefined(TypeAndQualifiers::new( UnionType::from_elements(db, [ty.inner_type(), ty2.inner_type()]), + ty.origin().merge(ty2.origin()), ty.qualifiers().union(ty2.qualifiers()), ))) } @@ -236,7 +283,7 @@ pub(crate) fn place<'db>( /// /// Note that all global scopes also include various "implicit globals" such as `__name__`, /// `__doc__` and `__file__`. This function **does not** consider those symbols; it will return -/// `Place::Unbound` for them. Use the (currently test-only) `global_symbol` query to also include +/// `Place::Undefined` for them. Use the (currently test-only) `global_symbol` query to also include /// those additional symbols. /// /// Use [`imported_symbol`] to perform the lookup as seen from outside the file (e.g. via imports). @@ -313,7 +360,7 @@ pub(crate) fn imported_symbol<'db>( ) .or_fall_back_to(db, || { if name == "__getattr__" { - Place::Unbound.into() + Place::Undefined.into() } else if name == "__builtins__" { Place::bound(Type::any()).into() } else { @@ -324,7 +371,7 @@ pub(crate) fn imported_symbol<'db>( /// Lookup the type of `symbol` in the builtins namespace. /// -/// Returns `Place::Unbound` if the `builtins` module isn't available for some reason. +/// Returns `Place::Undefined` if the `builtins` module isn't available for some reason. /// /// Note that this function is only intended for use in the context of the builtins *namespace* /// and should not be used when a symbol is being explicitly imported from the `builtins` module @@ -354,7 +401,7 @@ pub(crate) fn builtins_symbol<'db>(db: &'db dyn Db, symbol: &str) -> PlaceAndQua /// Lookup the type of `symbol` in a given known module. /// -/// Returns `Place::Unbound` if the given known module cannot be resolved for some reason. +/// Returns `Place::Undefined` if the given known module cannot be resolved for some reason. pub(crate) fn known_module_symbol<'db>( db: &'db dyn Db, known_module: KnownModule, @@ -370,7 +417,7 @@ pub(crate) fn known_module_symbol<'db>( /// Lookup the type of `symbol` in the `typing` module namespace. /// -/// Returns `Place::Unbound` if the `typing` module isn't available for some reason. +/// Returns `Place::Undefined` if the `typing` module isn't available for some reason. #[inline] #[cfg(test)] pub(crate) fn typing_symbol<'db>(db: &'db dyn Db, symbol: &str) -> PlaceAndQualifiers<'db> { @@ -379,7 +426,7 @@ pub(crate) fn typing_symbol<'db>(db: &'db dyn Db, symbol: &str) -> PlaceAndQuali /// Lookup the type of `symbol` in the `typing_extensions` module namespace. /// -/// Returns `Place::Unbound` if the `typing_extensions` module isn't available for some reason. +/// Returns `Place::Undefined` if the `typing_extensions` module isn't available for some reason. #[inline] pub(crate) fn typing_extensions_symbol<'db>( db: &'db dyn Db, @@ -479,7 +526,7 @@ impl<'db> PlaceFromDeclarationsResult<'db> { /// variable: ClassVar[int] /// ``` /// If we look up the declared type of `variable` in the scope of class `C`, we will get -/// the type `int`, a "declaredness" of [`Boundness::PossiblyUnbound`], and the information +/// the type `int`, a "declaredness" of [`Definedness::PossiblyUndefined`], and the information /// that this comes with a [`CLASS_VAR`] type qualifier. /// /// [`CLASS_VAR`]: crate::types::TypeQualifiers::CLASS_VAR @@ -492,7 +539,7 @@ pub(crate) struct PlaceAndQualifiers<'db> { impl Default for PlaceAndQualifiers<'_> { fn default() -> Self { PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, qualifiers: TypeQualifiers::empty(), } } @@ -510,6 +557,21 @@ impl<'db> PlaceAndQualifiers<'db> { } } + pub(crate) fn unbound() -> Self { + PlaceAndQualifiers { + place: Place::Undefined, + qualifiers: TypeQualifiers::empty(), + } + } + + pub(crate) fn is_undefined(&self) -> bool { + self.place.is_undefined() + } + + pub(crate) fn ignore_possibly_undefined(&self) -> Option> { + self.place.ignore_possibly_undefined() + } + /// Returns `true` if the place has a `ClassVar` type qualifier. pub(crate) fn is_class_var(&self) -> bool { self.qualifiers.contains(TypeQualifiers::CLASS_VAR) @@ -541,7 +603,7 @@ impl<'db> PlaceAndQualifiers<'db> { PlaceAndQualifiers { place, qualifiers } if (qualifiers.contains(TypeQualifiers::FINAL) && place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .is_some_and(|ty| ty.is_unknown())) => { Some(*qualifiers) @@ -571,24 +633,24 @@ impl<'db> PlaceAndQualifiers<'db> { } /// Transform place and qualifiers into a [`LookupResult`], - /// a [`Result`] type in which the `Ok` variant represents a definitely bound place - /// and the `Err` variant represents a place that is either definitely or possibly unbound. + /// a [`Result`] type in which the `Ok` variant represents a definitely defined place + /// and the `Err` variant represents a place that is either definitely or possibly undefined. pub(crate) fn into_lookup_result(self) -> LookupResult<'db> { match self { PlaceAndQualifiers { - place: Place::Type(ty, Boundness::Bound), + place: Place::Defined(ty, origin, Definedness::AlwaysDefined), qualifiers, - } => Ok(TypeAndQualifiers::new(ty, qualifiers)), + } => Ok(TypeAndQualifiers::new(ty, origin, qualifiers)), PlaceAndQualifiers { - place: Place::Type(ty, Boundness::PossiblyUnbound), + place: Place::Defined(ty, origin, Definedness::PossiblyUndefined), qualifiers, - } => Err(LookupError::PossiblyUnbound(TypeAndQualifiers::new( - ty, qualifiers, + } => Err(LookupError::PossiblyUndefined(TypeAndQualifiers::new( + ty, origin, qualifiers, ))), PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, qualifiers, - } => Err(LookupError::Unbound(qualifiers)), + } => Err(LookupError::Undefined(qualifiers)), } } @@ -612,9 +674,9 @@ impl<'db> PlaceAndQualifiers<'db> { /// 1. If `self` is definitely unbound, return the result of `fallback_fn()`. /// 2. Else, if `fallback` is definitely unbound, return `self`. /// 3. Else, if `self` is possibly unbound and `fallback` is definitely bound, - /// return `Place(, Boundness::Bound)` + /// return `Place(, Definedness::AlwaysDefined)` /// 4. Else, if `self` is possibly unbound and `fallback` is possibly unbound, - /// return `Place(, Boundness::PossiblyUnbound)` + /// return `Place(, Definedness::PossiblyUndefined)` #[must_use] pub(crate) fn or_fall_back_to( self, @@ -693,29 +755,30 @@ pub(crate) fn place_by_id<'db>( // Handle bare `ClassVar` annotations by falling back to the union of `Unknown` and the // inferred type. PlaceAndQualifiers { - place: Place::Type(Type::Dynamic(DynamicType::Unknown), declaredness), + place: Place::Defined(Type::Dynamic(DynamicType::Unknown), origin, definedness), qualifiers, } if qualifiers.contains(TypeQualifiers::CLASS_VAR) => { let bindings = all_considered_bindings(); match place_from_bindings_impl(db, bindings, requires_explicit_reexport) { - Place::Type(inferred, boundness) => Place::Type( + Place::Defined(inferred, origin, boundness) => Place::Defined( UnionType::from_elements(db, [Type::unknown(), inferred]), + origin, boundness, ) .with_qualifiers(qualifiers), - Place::Unbound => { - Place::Type(Type::unknown(), declaredness).with_qualifiers(qualifiers) + Place::Undefined => { + Place::Defined(Type::unknown(), origin, definedness).with_qualifiers(qualifiers) } } } // Place is declared, trust the declared type place_and_quals @ PlaceAndQualifiers { - place: Place::Type(_, Boundness::Bound), + place: Place::Defined(_, _, Definedness::AlwaysDefined), qualifiers: _, } => place_and_quals, // Place is possibly declared PlaceAndQualifiers { - place: Place::Type(declared_ty, Boundness::PossiblyUnbound), + place: Place::Defined(declared_ty, origin, Definedness::PossiblyUndefined), qualifiers, } => { let bindings = all_considered_bindings(); @@ -724,17 +787,18 @@ pub(crate) fn place_by_id<'db>( let place = match inferred { // Place is possibly undeclared and definitely unbound - Place::Unbound => { - // TODO: We probably don't want to report `Bound` here. This requires a bit of + Place::Undefined => { + // TODO: We probably don't want to report `AlwaysDefined` here. This requires a bit of // design work though as we might want a different behavior for stubs and for // normal modules. - Place::Type(declared_ty, Boundness::Bound) + Place::Defined(declared_ty, origin, Definedness::AlwaysDefined) } // Place is possibly undeclared and (possibly) bound - Place::Type(inferred_ty, boundness) => Place::Type( + Place::Defined(inferred_ty, origin, boundness) => Place::Defined( UnionType::from_elements(db, [inferred_ty, declared_ty]), + origin, if boundness_analysis == BoundnessAnalysis::AssumeBound { - Boundness::Bound + Definedness::AlwaysDefined } else { boundness }, @@ -745,7 +809,7 @@ pub(crate) fn place_by_id<'db>( } // Place is undeclared, return the union of `Unknown` with the inferred type PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, qualifiers: _, } => { let bindings = all_considered_bindings(); @@ -753,8 +817,8 @@ pub(crate) fn place_by_id<'db>( let mut inferred = place_from_bindings_impl(db, bindings, requires_explicit_reexport); if boundness_analysis == BoundnessAnalysis::AssumeBound { - if let Place::Type(ty, Boundness::PossiblyUnbound) = inferred { - inferred = Place::Type(ty, Boundness::Bound); + if let Place::Defined(ty, origin, Definedness::PossiblyUndefined) = inferred { + inferred = Place::Defined(ty, origin, Definedness::AlwaysDefined); } } @@ -1026,25 +1090,27 @@ fn place_from_bindings_impl<'db>( }; let boundness = match boundness_analysis { - BoundnessAnalysis::AssumeBound => Boundness::Bound, + BoundnessAnalysis::AssumeBound => Definedness::AlwaysDefined, BoundnessAnalysis::BasedOnUnboundVisibility => match unbound_visibility() { Some(Truthiness::AlwaysTrue) => { unreachable!( "If we have at least one binding, the implicit `unbound` binding should not be definitely visible" ) } - Some(Truthiness::AlwaysFalse) | None => Boundness::Bound, - Some(Truthiness::Ambiguous) => Boundness::PossiblyUnbound, + Some(Truthiness::AlwaysFalse) | None => Definedness::AlwaysDefined, + Some(Truthiness::Ambiguous) => Definedness::PossiblyUndefined, }, }; match deleted_reachability { - Truthiness::AlwaysFalse => Place::Type(ty, boundness), - Truthiness::AlwaysTrue => Place::Unbound, - Truthiness::Ambiguous => Place::Type(ty, Boundness::PossiblyUnbound), + Truthiness::AlwaysFalse => Place::Defined(ty, TypeOrigin::Inferred, boundness), + Truthiness::AlwaysTrue => Place::Undefined, + Truthiness::Ambiguous => { + Place::Defined(ty, TypeOrigin::Inferred, Definedness::PossiblyUndefined) + } } } else { - Place::Unbound + Place::Undefined } } @@ -1145,7 +1211,8 @@ impl<'db> DeclaredTypeBuilder<'db> { } fn build(mut self) -> DeclaredTypeAndConflictingTypes<'db> { - let type_and_quals = TypeAndQualifiers::new(self.inner.build(), self.qualifiers); + let type_and_quals = + TypeAndQualifiers::new(self.inner.build(), TypeOrigin::Declared, self.qualifiers); if self.conflicting_types.is_empty() { (type_and_quals, None) } else { @@ -1245,13 +1312,13 @@ fn place_from_declarations_impl<'db>( let boundness = match boundness_analysis { BoundnessAnalysis::AssumeBound => { if all_declarations_definitely_reachable { - Boundness::Bound + Definedness::AlwaysDefined } else { // For declarations, it is important to consider the possibility that they might only // be bound in one control flow path, while the other path contains a binding. In order // to even consider the bindings as well in `place_by_id`, we return `PossiblyUnbound` // here. - Boundness::PossiblyUnbound + Definedness::PossiblyUndefined } } BoundnessAnalysis::BasedOnUnboundVisibility => match undeclared_reachability { @@ -1260,13 +1327,14 @@ fn place_from_declarations_impl<'db>( "If we have at least one declaration, the implicit `unbound` binding should not be definitely visible" ) } - Truthiness::AlwaysFalse => Boundness::Bound, - Truthiness::Ambiguous => Boundness::PossiblyUnbound, + Truthiness::AlwaysFalse => Definedness::AlwaysDefined, + Truthiness::Ambiguous => Definedness::PossiblyUndefined, }, }; let place_and_quals = - Place::Type(declared.inner_type(), boundness).with_qualifiers(declared.qualifiers()); + Place::Defined(declared.inner_type(), TypeOrigin::Declared, boundness) + .with_qualifiers(declared.qualifiers()); if let Some(conflicting) = conflicting { PlaceFromDeclarationsResult::conflict(place_and_quals, conflicting) @@ -1279,7 +1347,7 @@ fn place_from_declarations_impl<'db>( } } else { PlaceFromDeclarationsResult { - place_and_quals: Place::Unbound.into(), + place_and_quals: Place::Undefined.into(), conflicting_types: None, single_declaration: None, } @@ -1314,7 +1382,7 @@ mod implicit_globals { use crate::Program; use crate::db::Db; - use crate::place::{Boundness, PlaceAndQualifiers}; + use crate::place::{Definedness, PlaceAndQualifiers, TypeOrigin}; use crate::semantic_index::symbol::Symbol; use crate::semantic_index::{place_table, use_def_map}; use crate::types::{CallableType, KnownClass, Parameter, Parameters, Signature, Type}; @@ -1330,16 +1398,16 @@ mod implicit_globals { .iter() .any(|module_type_member| module_type_member == name) { - return Place::Unbound.into(); + return Place::Undefined.into(); } let Type::ClassLiteral(module_type_class) = KnownClass::ModuleType.to_class_literal(db) else { - return Place::Unbound.into(); + return Place::Undefined.into(); }; let module_type_scope = module_type_class.body_scope(db); let place_table = place_table(db, module_type_scope); let Some(symbol_id) = place_table.symbol_id(name) else { - return Place::Unbound.into(); + return Place::Undefined.into(); }; place_from_declarations( db, @@ -1348,7 +1416,7 @@ mod implicit_globals { .ignore_conflicting_declarations() } - /// Looks up the type of an "implicit global symbol". Returns [`Place::Unbound`] if + /// Looks up the type of an "implicit global symbol". Returns [`Place::Undefined`] if /// `name` is not present as an implicit symbol in module-global namespaces. /// /// Implicit global symbols are symbols such as `__doc__`, `__name__`, and `__file__` @@ -1359,7 +1427,7 @@ mod implicit_globals { /// up in the global scope **from within the same file**. If the symbol is being looked up /// from outside the file (e.g. via imports), use [`super::imported_symbol`] (or fallback logic /// like the logic used in that function) instead. The reason is that this function returns - /// [`Place::Unbound`] for `__init__` and `__dict__` (which cannot be found in globals if + /// [`Place::Undefined`] for `__init__` and `__dict__` (which cannot be found in globals if /// the lookup is being done from the same file) -- but these symbols *are* available in the /// global scope if they're being imported **from a different file**. pub(crate) fn module_type_implicit_global_symbol<'db>( @@ -1378,10 +1446,11 @@ mod implicit_globals { // Created lazily by the warnings machinery; may be absent. // Model as possibly-unbound to avoid false negatives. - "__warningregistry__" => Place::Type( + "__warningregistry__" => Place::Defined( KnownClass::Dict .to_specialized_instance(db, [Type::any(), KnownClass::Int.to_instance(db)]), - Boundness::PossiblyUnbound, + TypeOrigin::Inferred, + Definedness::PossiblyUndefined, ) .into(), @@ -1398,9 +1467,10 @@ mod implicit_globals { [KnownClass::Str.to_instance(db), Type::any()], )), ); - Place::Type( + Place::Defined( CallableType::function_like(db, signature), - Boundness::PossiblyUnbound, + TypeOrigin::Inferred, + Definedness::PossiblyUndefined, ) .into() } @@ -1417,7 +1487,7 @@ mod implicit_globals { KnownClass::ModuleType.to_instance(db).member(db, name) } - _ => Place::Unbound.into(), + _ => Place::Undefined.into(), } } @@ -1545,7 +1615,7 @@ pub(crate) enum BoundnessAnalysis { /// `unbound` binding. In the example below, when analyzing the visibility of the /// `x = ` binding from the position of the end of the scope, it would be /// `Truthiness::Ambiguous`, because it could either be visible or not, depending on the - /// `flag()` return value. This would result in a `Boundness::PossiblyUnbound` for `x`. + /// `flag()` return value. This would result in a `Definedness::PossiblyUndefined` for `x`. /// /// ```py /// x = @@ -1563,21 +1633,30 @@ mod tests { #[test] fn test_symbol_or_fall_back_to() { - use Boundness::{Bound, PossiblyUnbound}; + use Definedness::{AlwaysDefined, PossiblyUndefined}; + use TypeOrigin::Inferred; let db = setup_db(); let ty1 = Type::IntLiteral(1); let ty2 = Type::IntLiteral(2); - let unbound = || Place::Unbound.with_qualifiers(TypeQualifiers::empty()); + let unbound = || Place::Undefined.with_qualifiers(TypeQualifiers::empty()); - let possibly_unbound_ty1 = - || Place::Type(ty1, PossiblyUnbound).with_qualifiers(TypeQualifiers::empty()); - let possibly_unbound_ty2 = - || Place::Type(ty2, PossiblyUnbound).with_qualifiers(TypeQualifiers::empty()); + let possibly_unbound_ty1 = || { + Place::Defined(ty1, Inferred, PossiblyUndefined) + .with_qualifiers(TypeQualifiers::empty()) + }; + let possibly_unbound_ty2 = || { + Place::Defined(ty2, Inferred, PossiblyUndefined) + .with_qualifiers(TypeQualifiers::empty()) + }; - let bound_ty1 = || Place::Type(ty1, Bound).with_qualifiers(TypeQualifiers::empty()); - let bound_ty2 = || Place::Type(ty2, Bound).with_qualifiers(TypeQualifiers::empty()); + let bound_ty1 = || { + Place::Defined(ty1, Inferred, AlwaysDefined).with_qualifiers(TypeQualifiers::empty()) + }; + let bound_ty2 = || { + Place::Defined(ty2, Inferred, AlwaysDefined).with_qualifiers(TypeQualifiers::empty()) + }; // Start from an unbound symbol assert_eq!(unbound().or_fall_back_to(&db, unbound), unbound()); @@ -1594,11 +1673,21 @@ mod tests { ); assert_eq!( possibly_unbound_ty1().or_fall_back_to(&db, possibly_unbound_ty2), - Place::Type(UnionType::from_elements(&db, [ty1, ty2]), PossiblyUnbound).into() + Place::Defined( + UnionType::from_elements(&db, [ty1, ty2]), + Inferred, + PossiblyUndefined + ) + .into() ); assert_eq!( possibly_unbound_ty1().or_fall_back_to(&db, bound_ty2), - Place::Type(UnionType::from_elements(&db, [ty1, ty2]), Bound).into() + Place::Defined( + UnionType::from_elements(&db, [ty1, ty2]), + Inferred, + AlwaysDefined + ) + .into() ); // Start from a definitely bound symbol @@ -1614,7 +1703,7 @@ mod tests { fn assert_bound_string_symbol<'db>(db: &'db dyn Db, symbol: Place<'db>) { assert!(matches!( symbol, - Place::Type(Type::NominalInstance(_), Boundness::Bound) + Place::Defined(Type::NominalInstance(_), _, Definedness::AlwaysDefined) )); assert_eq!(symbol.expect_type(), KnownClass::Str.to_instance(db)); } diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 67b266b3ff..3d09733324 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -917,13 +917,17 @@ impl ReachabilityConstraints { ) .place { - crate::place::Place::Type(_, crate::place::Boundness::Bound) => { - Truthiness::AlwaysTrue - } - crate::place::Place::Type(_, crate::place::Boundness::PossiblyUnbound) => { - Truthiness::Ambiguous - } - crate::place::Place::Unbound => Truthiness::AlwaysFalse, + crate::place::Place::Defined( + _, + _, + crate::place::Definedness::AlwaysDefined, + ) => Truthiness::AlwaysTrue, + crate::place::Place::Defined( + _, + _, + crate::place::Definedness::PossiblyUndefined, + ) => Truthiness::Ambiguous, + crate::place::Place::Undefined => Truthiness::AlwaysFalse, } } } diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 3f8702c858..e603f66bc2 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -32,7 +32,7 @@ pub(crate) use self::signatures::{CallableSignature, Parameter, Parameters, Sign pub(crate) use self::subclass_of::{SubclassOfInner, SubclassOfType}; use crate::module_name::ModuleName; use crate::module_resolver::{KnownModule, resolve_module}; -use crate::place::{Boundness, Place, PlaceAndQualifiers, imported_symbol}; +use crate::place::{Definedness, Place, PlaceAndQualifiers, TypeOrigin, imported_symbol}; use crate::semantic_index::definition::{Definition, DefinitionKind}; use crate::semantic_index::place::ScopedPlaceId; use crate::semantic_index::scope::ScopeId; @@ -1440,7 +1440,7 @@ impl<'db> Type<'db> { ) .place; - if let Place::Type(ty, Boundness::Bound) = call_symbol { + if let Place::Defined(ty, _, Definedness::AlwaysDefined) = call_symbol { ty.try_upcast_to_callable(db) } else { None @@ -2533,7 +2533,7 @@ impl<'db> Type<'db> { other .member(db, member.name()) .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .when_none_or(|attribute_type| { member.has_disjoint_type_from( db, @@ -2899,14 +2899,14 @@ impl<'db> Type<'db> { disjointness_visitor.visit((self, other), || { protocol.interface(db).members(db).when_any(db, |member| { match other.member(db, member.name()).place { - Place::Type(attribute_type, _) => member.has_disjoint_type_from( + Place::Defined(attribute_type, _, _) => member.has_disjoint_type_from( db, attribute_type, inferable, disjointness_visitor, relation_visitor, ), - Place::Unbound => ConstraintSet::from(false), + Place::Undefined => ConstraintSet::from(false), } }) }) @@ -3136,7 +3136,7 @@ impl<'db> Type<'db> { MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .when_none_or(|dunder_call| { dunder_call .has_relation_to_impl( @@ -3458,7 +3458,7 @@ impl<'db> Type<'db> { ), (Some(KnownClass::FunctionType), "__set__" | "__delete__") => { // Hard code this knowledge, as we look up `__set__` and `__delete__` on `FunctionType` often. - Some(Place::Unbound.into()) + Some(Place::Undefined.into()) } (Some(KnownClass::Property), "__get__") => Some( Place::bound(Type::WrapperDescriptor( @@ -3511,7 +3511,7 @@ impl<'db> Type<'db> { // MRO of the class `object`. Type::NominalInstance(instance) if instance.has_known_class(db, KnownClass::Type) => { if policy.mro_no_object_fallback() { - Some(Place::Unbound.into()) + Some(Place::Undefined.into()) } else { KnownClass::Object .to_class_literal(db) @@ -3672,7 +3672,7 @@ impl<'db> Type<'db> { of type variable {} in inferable position", self.display(db) ); - Place::Unbound.into() + Place::Undefined.into() } Type::IntLiteral(_) => KnownClass::Int.to_instance(db).instance_member(db, name), @@ -3692,7 +3692,7 @@ impl<'db> Type<'db> { .to_instance(db) .instance_member(db, name), - Type::SpecialForm(_) | Type::KnownInstance(_) => Place::Unbound.into(), + Type::SpecialForm(_) | Type::KnownInstance(_) => Place::Undefined.into(), Type::PropertyInstance(_) => KnownClass::Property .to_instance(db) @@ -3710,10 +3710,10 @@ impl<'db> Type<'db> { // required, as `instance_member` is only called for instance-like types through `member`, // but we might want to add this in the future. Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) => { - Place::Unbound.into() + Place::Undefined.into() } - Type::TypedDict(_) => Place::Unbound.into(), + Type::TypedDict(_) => Place::Undefined.into(), Type::TypeAlias(alias) => alias.value_type(db).instance_member(db, name), } @@ -3726,9 +3726,9 @@ impl<'db> Type<'db> { fn static_member(&self, db: &'db dyn Db, name: &str) -> Place<'db> { if let Type::ModuleLiteral(module) = self { module.static_member(db, name).place - } else if let place @ Place::Type(_, _) = self.class_member(db, name.into()).place { + } else if let place @ Place::Defined(_, _, _) = self.class_member(db, name.into()).place { place - } else if let Some(place @ Place::Type(_, _)) = + } else if let Some(place @ Place::Defined(_, _, _)) = self.find_name_in_mro(db, name).map(|inner| inner.place) { place @@ -3781,11 +3781,11 @@ impl<'db> Type<'db> { let descr_get = self.class_member(db, "__get__".into()).place; - if let Place::Type(descr_get, descr_get_boundness) = descr_get { + if let Place::Defined(descr_get, _, descr_get_boundness) = descr_get { let return_ty = descr_get .try_call(db, &CallArguments::positional([self, instance, owner])) .map(|bindings| { - if descr_get_boundness == Boundness::Bound { + if descr_get_boundness == Definedness::AlwaysDefined { bindings.return_type(db) } else { UnionType::from_elements(db, [bindings.return_type(db), self]) @@ -3824,19 +3824,20 @@ impl<'db> Type<'db> { // // The same is true for `Never`. PlaceAndQualifiers { - place: Place::Type(Type::Dynamic(_) | Type::Never, _), + place: Place::Defined(Type::Dynamic(_) | Type::Never, _, _), qualifiers: _, } => (attribute, AttributeKind::DataDescriptor), PlaceAndQualifiers { - place: Place::Type(Type::Union(union), boundness), + place: Place::Defined(Type::Union(union), origin, boundness), qualifiers, } => ( union .map_with_boundness(db, |elem| { - Place::Type( + Place::Defined( elem.try_call_dunder_get(db, instance, owner) .map_or(*elem, |(ty, _)| ty), + origin, boundness, ) }) @@ -3853,14 +3854,15 @@ impl<'db> Type<'db> { ), PlaceAndQualifiers { - place: Place::Type(Type::Intersection(intersection), boundness), + place: Place::Defined(Type::Intersection(intersection), origin, boundness), qualifiers, } => ( intersection .map_with_boundness(db, |elem| { - Place::Type( + Place::Defined( elem.try_call_dunder_get(db, instance, owner) .map_or(*elem, |(ty, _)| ty), + origin, boundness, ) }) @@ -3870,13 +3872,16 @@ impl<'db> Type<'db> { ), PlaceAndQualifiers { - place: Place::Type(attribute_ty, boundness), + place: Place::Defined(attribute_ty, origin, boundness), qualifiers: _, } => { if let Some((return_ty, attribute_kind)) = attribute_ty.try_call_dunder_get(db, instance, owner) { - (Place::Type(return_ty, boundness).into(), attribute_kind) + ( + Place::Defined(return_ty, origin, boundness).into(), + attribute_kind, + ) } else { (attribute, AttributeKind::NormalOrNonDataDescriptor) } @@ -3915,11 +3920,11 @@ impl<'db> Type<'db> { .iter_positive(db) .any(|ty| ty.is_data_descriptor_impl(db, any_of_union)), _ => { - !self.class_member(db, "__set__".into()).place.is_unbound() + !self.class_member(db, "__set__".into()).place.is_undefined() || !self .class_member(db, "__delete__".into()) .place - .is_unbound() + .is_undefined() } } } @@ -3967,25 +3972,28 @@ impl<'db> Type<'db> { match (meta_attr, meta_attr_kind, fallback) { // The fallback type is unbound, so we can just return `meta_attr` unconditionally, // no matter if it's data descriptor, a non-data descriptor, or a normal attribute. - (meta_attr @ Place::Type(_, _), _, Place::Unbound) => { + (meta_attr @ Place::Defined(_, _, _), _, Place::Undefined) => { meta_attr.with_qualifiers(meta_attr_qualifiers) } // `meta_attr` is the return type of a data descriptor and definitely bound, so we // return it. - (meta_attr @ Place::Type(_, Boundness::Bound), AttributeKind::DataDescriptor, _) => { - meta_attr.with_qualifiers(meta_attr_qualifiers) - } + ( + meta_attr @ Place::Defined(_, _, Definedness::AlwaysDefined), + AttributeKind::DataDescriptor, + _, + ) => meta_attr.with_qualifiers(meta_attr_qualifiers), // `meta_attr` is the return type of a data descriptor, but the attribute on the // meta-type is possibly-unbound. This means that we "fall through" to the next // stage of the descriptor protocol and union with the fallback type. ( - Place::Type(meta_attr_ty, Boundness::PossiblyUnbound), + Place::Defined(meta_attr_ty, meta_origin, Definedness::PossiblyUndefined), AttributeKind::DataDescriptor, - Place::Type(fallback_ty, fallback_boundness), - ) => Place::Type( + Place::Defined(fallback_ty, fallback_origin, fallback_boundness), + ) => Place::Defined( UnionType::from_elements(db, [meta_attr_ty, fallback_ty]), + meta_origin.merge(fallback_origin), fallback_boundness, ) .with_qualifiers(meta_attr_qualifiers.union(fallback_qualifiers)), @@ -3999,9 +4007,9 @@ impl<'db> Type<'db> { // would require us to statically infer if an instance attribute is always set, which // is something we currently don't attempt to do. ( - Place::Type(_, _), + Place::Defined(_, _, _), AttributeKind::NormalOrNonDataDescriptor, - fallback @ Place::Type(_, Boundness::Bound), + fallback @ Place::Defined(_, _, Definedness::AlwaysDefined), ) if policy == InstanceFallbackShadowsNonDataDescriptor::Yes => { fallback.with_qualifiers(fallback_qualifiers) } @@ -4010,17 +4018,18 @@ impl<'db> Type<'db> { // unbound or the policy argument is `No`. In both cases, the `fallback` type does // not completely shadow the non-data descriptor, so we build a union of the two. ( - Place::Type(meta_attr_ty, meta_attr_boundness), + Place::Defined(meta_attr_ty, meta_origin, meta_attr_boundness), AttributeKind::NormalOrNonDataDescriptor, - Place::Type(fallback_ty, fallback_boundness), - ) => Place::Type( + Place::Defined(fallback_ty, fallback_origin, fallback_boundness), + ) => Place::Defined( UnionType::from_elements(db, [meta_attr_ty, fallback_ty]), + meta_origin.merge(fallback_origin), meta_attr_boundness.max(fallback_boundness), ) .with_qualifiers(meta_attr_qualifiers.union(fallback_qualifiers)), // If the attribute is not found on the meta-type, we simply return the fallback. - (Place::Unbound, _, fallback) => fallback.with_qualifiers(fallback_qualifiers), + (Place::Undefined, _, fallback) => fallback.with_qualifiers(fallback_qualifiers), } } @@ -4183,7 +4192,7 @@ impl<'db> Type<'db> { Type::ModuleLiteral(module) => module.static_member(db, name_str), // If a protocol does not include a member and the policy disables falling back to - // `object`, we return `Place::Unbound` here. This short-circuits attribute lookup + // `object`, we return `Place::Undefined` here. This short-circuits attribute lookup // before we find the "fallback to attribute access on `object`" logic later on // (otherwise we would infer that all synthesized protocols have `__getattribute__` // methods, and therefore that all synthesized protocols have all possible attributes.) @@ -4196,13 +4205,13 @@ impl<'db> Type<'db> { }) if policy.mro_no_object_fallback() && !protocol.interface().includes_member(db, name_str) => { - Place::Unbound.into() + Place::Undefined.into() } _ if policy.no_instance_fallback() => self.invoke_descriptor_protocol( db, name_str, - Place::Unbound.into(), + Place::Undefined.into(), InstanceFallbackShadowsNonDataDescriptor::No, policy, ), @@ -4230,7 +4239,7 @@ impl<'db> Type<'db> { { enum_metadata(db, enum_literal.enum_class(db)) .and_then(|metadata| metadata.members.get(enum_literal.name(db))) - .map_or_else(|| Place::Unbound, Place::bound) + .map_or_else(|| Place::Undefined, Place::bound) .into() } @@ -4275,7 +4284,7 @@ impl<'db> Type<'db> { .and_then(|instance| instance.known_class(db)), Some(KnownClass::ModuleType | KnownClass::GenericAlias) ) { - return Place::Unbound.into(); + return Place::Undefined.into(); } self.try_call_dunder( @@ -4286,14 +4295,14 @@ impl<'db> Type<'db> { ) .map(|outcome| Place::bound(outcome.return_type(db))) // TODO: Handle call errors here. - .unwrap_or(Place::Unbound) + .unwrap_or(Place::Undefined) .into() }; let custom_getattribute_result = || { // Avoid cycles when looking up `__getattribute__` if "__getattribute__" == name.as_str() { - return Place::Unbound.into(); + return Place::Undefined.into(); } // Typeshed has a `__getattribute__` method defined on `builtins.object` so we @@ -4307,29 +4316,29 @@ impl<'db> Type<'db> { ) .map(|outcome| Place::bound(outcome.return_type(db))) // TODO: Handle call errors here. - .unwrap_or(Place::Unbound) + .unwrap_or(Place::Undefined) .into() }; if result.is_class_var() && self.is_typed_dict() { // `ClassVar`s on `TypedDictFallback` can not be accessed on inhabitants of `SomeTypedDict`. // They can only be accessed on `SomeTypedDict` directly. - return Place::Unbound.into(); + return Place::Undefined.into(); } match result { member @ PlaceAndQualifiers { - place: Place::Type(_, Boundness::Bound), + place: Place::Defined(_, _, Definedness::AlwaysDefined), qualifiers: _, } => member, member @ PlaceAndQualifiers { - place: Place::Type(_, Boundness::PossiblyUnbound), + place: Place::Defined(_, _, Definedness::PossiblyUndefined), qualifiers: _, } => member .or_fall_back_to(db, custom_getattribute_result) .or_fall_back_to(db, custom_getattr_result), PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, qualifiers: _, } => custom_getattribute_result().or_fall_back_to(db, custom_getattr_result), } @@ -4354,14 +4363,11 @@ impl<'db> Type<'db> { } { if let Some(metadata) = enum_metadata(db, enum_class) { if let Some(resolved_name) = metadata.resolve_member(&name) { - return Place::Type( - Type::EnumLiteral(EnumLiteralType::new( - db, - enum_class, - resolved_name, - )), - Boundness::Bound, - ) + return Place::bound(Type::EnumLiteral(EnumLiteralType::new( + db, + enum_class, + resolved_name, + ))) .into(); } } @@ -5367,15 +5373,15 @@ impl<'db> Type<'db> { ) .place { - Place::Type(dunder_callable, boundness) => { + Place::Defined(dunder_callable, _, boundness) => { let mut bindings = dunder_callable.bindings(db); bindings.replace_callable_type(dunder_callable, self); - if boundness == Boundness::PossiblyUnbound { + if boundness == Definedness::PossiblyUndefined { bindings.set_dunder_call_is_possibly_unbound(); } bindings } - Place::Unbound => CallableBinding::not_callable(self).into(), + Place::Undefined => CallableBinding::not_callable(self).into(), } } @@ -5488,17 +5494,17 @@ impl<'db> Type<'db> { ) .place { - Place::Type(dunder_callable, boundness) => { + Place::Defined(dunder_callable, _, boundness) => { let bindings = dunder_callable .bindings(db) .match_parameters(db, argument_types) .check_types(db, argument_types, &tcx)?; - if boundness == Boundness::PossiblyUnbound { + if boundness == Definedness::PossiblyUndefined { return Err(CallDunderError::PossiblyUnbound(Box::new(bindings))); } Ok(bindings) } - Place::Unbound => Err(CallDunderError::MethodNotAvailable), + Place::Undefined => Err(CallDunderError::MethodNotAvailable), } } @@ -6005,16 +6011,16 @@ impl<'db> Type<'db> { let new_method = self_type.lookup_dunder_new(db, ()); let new_call_outcome = new_method.and_then(|new_method| { match new_method.place.try_call_dunder_get(db, self_type) { - Place::Type(new_method, boundness) => { + Place::Defined(new_method, _, boundness) => { let result = new_method.try_call(db, argument_types.with_self(Some(self_type)).as_ref()); - if boundness == Boundness::PossiblyUnbound { + if boundness == Definedness::PossiblyUndefined { Some(Err(DunderNewCallError::PossiblyUnbound(result.err()))) } else { Some(result.map_err(DunderNewCallError::CallError)) } } - Place::Unbound => None, + Place::Undefined => None, } }); @@ -6034,7 +6040,7 @@ impl<'db> Type<'db> { | MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, ) .place - .is_unbound() + .is_undefined() { Some(init_ty.try_call_dunder(db, "__init__", argument_types, tcx)) } else { @@ -7743,18 +7749,23 @@ impl TypeQualifiers { #[derive(Clone, Debug, Copy, Eq, PartialEq, salsa::Update, get_size2::GetSize)] pub(crate) struct TypeAndQualifiers<'db> { inner: Type<'db>, + origin: TypeOrigin, qualifiers: TypeQualifiers, } impl<'db> TypeAndQualifiers<'db> { - pub(crate) fn new(inner: Type<'db>, qualifiers: TypeQualifiers) -> Self { - Self { inner, qualifiers } + pub(crate) fn new(inner: Type<'db>, origin: TypeOrigin, qualifiers: TypeQualifiers) -> Self { + Self { + inner, + origin, + qualifiers, + } } - /// Constructor that creates a [`TypeAndQualifiers`] instance with type `Unknown` and no qualifiers. - pub(crate) fn unknown() -> Self { + pub(crate) fn declared(inner: Type<'db>) -> Self { Self { - inner: Type::unknown(), + inner, + origin: TypeOrigin::Declared, qualifiers: TypeQualifiers::empty(), } } @@ -7764,6 +7775,10 @@ impl<'db> TypeAndQualifiers<'db> { self.inner } + pub(crate) fn origin(&self) -> TypeOrigin { + self.origin + } + /// Insert/add an additional type qualifier. pub(crate) fn add_qualifier(&mut self, qualifier: TypeQualifiers) { self.qualifiers |= qualifier; @@ -7775,15 +7790,6 @@ impl<'db> TypeAndQualifiers<'db> { } } -impl<'db> From> for TypeAndQualifiers<'db> { - fn from(inner: Type<'db>) -> Self { - Self { - inner, - qualifiers: TypeQualifiers::empty(), - } - } -} - /// Error struct providing information on type(s) that were deemed to be invalid /// in a type expression context, and the type we should therefore fallback to /// for the problematic type expression. @@ -7929,7 +7935,7 @@ impl<'db> InvalidTypeExpression<'db> { let Some(module_member_with_same_name) = ty .member(db, module_name_final_part) .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() else { return; }; @@ -10676,18 +10682,18 @@ impl<'db> ModuleLiteralType<'db> { // if it exists. First, we need to look up the `__getattr__` function in the module's scope. if let Some(file) = self.module(db).file(db) { let getattr_symbol = imported_symbol(db, file, "__getattr__", None); - if let Place::Type(getattr_type, boundness) = getattr_symbol.place { + if let Place::Defined(getattr_type, origin, boundness) = getattr_symbol.place { // If we found a __getattr__ function, try to call it with the name argument if let Ok(outcome) = getattr_type.try_call( db, &CallArguments::positional([Type::string_literal(db, name)]), ) { - return Place::Type(outcome.return_type(db), boundness).into(); + return Place::Defined(outcome.return_type(db), origin, boundness).into(); } } } - Place::Unbound.into() + Place::Undefined.into() } fn static_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { @@ -10722,7 +10728,7 @@ impl<'db> ModuleLiteralType<'db> { .unwrap_or_default(); // If the normal lookup failed, try to call the module's `__getattr__` function - if place_and_qualifiers.place.is_unbound() { + if place_and_qualifiers.place.is_undefined() { return self.try_module_getattr(db, name); } @@ -11119,14 +11125,16 @@ impl<'db> UnionType<'db> { let mut all_unbound = true; let mut possibly_unbound = false; + let mut origin = TypeOrigin::Declared; for ty in self.elements(db) { let ty_member = transform_fn(ty); match ty_member { - Place::Unbound => { + Place::Undefined => { possibly_unbound = true; } - Place::Type(ty_member, member_boundness) => { - if member_boundness == Boundness::PossiblyUnbound { + Place::Defined(ty_member, member_origin, member_boundness) => { + origin = origin.merge(member_origin); + if member_boundness == Definedness::PossiblyUndefined { possibly_unbound = true; } @@ -11137,14 +11145,15 @@ impl<'db> UnionType<'db> { } if all_unbound { - Place::Unbound + Place::Undefined } else { - Place::Type( + Place::Defined( builder.build(), + origin, if possibly_unbound { - Boundness::PossiblyUnbound + Definedness::PossiblyUndefined } else { - Boundness::Bound + Definedness::AlwaysDefined }, ) } @@ -11160,6 +11169,7 @@ impl<'db> UnionType<'db> { let mut all_unbound = true; let mut possibly_unbound = false; + let mut origin = TypeOrigin::Declared; for ty in self.elements(db) { let PlaceAndQualifiers { place: ty_member, @@ -11167,11 +11177,12 @@ impl<'db> UnionType<'db> { } = transform_fn(ty); qualifiers |= new_qualifiers; match ty_member { - Place::Unbound => { + Place::Undefined => { possibly_unbound = true; } - Place::Type(ty_member, member_boundness) => { - if member_boundness == Boundness::PossiblyUnbound { + Place::Defined(ty_member, member_origin, member_boundness) => { + origin = origin.merge(member_origin); + if member_boundness == Definedness::PossiblyUndefined { possibly_unbound = true; } @@ -11182,14 +11193,15 @@ impl<'db> UnionType<'db> { } PlaceAndQualifiers { place: if all_unbound { - Place::Unbound + Place::Undefined } else { - Place::Type( + Place::Defined( builder.build(), + origin, if possibly_unbound { - Boundness::PossiblyUnbound + Definedness::PossiblyUndefined } else { - Boundness::Bound + Definedness::AlwaysDefined }, ) }, @@ -11394,13 +11406,15 @@ impl<'db> IntersectionType<'db> { let mut all_unbound = true; let mut any_definitely_bound = false; + let mut origin = TypeOrigin::Declared; for ty in self.positive_elements_or_object(db) { let ty_member = transform_fn(&ty); match ty_member { - Place::Unbound => {} - Place::Type(ty_member, member_boundness) => { + Place::Undefined => {} + Place::Defined(ty_member, member_origin, member_boundness) => { + origin = origin.merge(member_origin); all_unbound = false; - if member_boundness == Boundness::Bound { + if member_boundness == Definedness::AlwaysDefined { any_definitely_bound = true; } @@ -11410,14 +11424,15 @@ impl<'db> IntersectionType<'db> { } if all_unbound { - Place::Unbound + Place::Undefined } else { - Place::Type( + Place::Defined( builder.build(), + origin, if any_definitely_bound { - Boundness::Bound + Definedness::AlwaysDefined } else { - Boundness::PossiblyUnbound + Definedness::PossiblyUndefined }, ) } @@ -11433,6 +11448,7 @@ impl<'db> IntersectionType<'db> { let mut all_unbound = true; let mut any_definitely_bound = false; + let mut origin = TypeOrigin::Declared; for ty in self.positive_elements_or_object(db) { let PlaceAndQualifiers { place: member, @@ -11440,10 +11456,11 @@ impl<'db> IntersectionType<'db> { } = transform_fn(&ty); qualifiers |= new_qualifiers; match member { - Place::Unbound => {} - Place::Type(ty_member, member_boundness) => { + Place::Undefined => {} + Place::Defined(ty_member, member_origin, member_boundness) => { + origin = origin.merge(member_origin); all_unbound = false; - if member_boundness == Boundness::Bound { + if member_boundness == Definedness::AlwaysDefined { any_definitely_bound = true; } @@ -11454,14 +11471,15 @@ impl<'db> IntersectionType<'db> { PlaceAndQualifiers { place: if all_unbound { - Place::Unbound + Place::Undefined } else { - Place::Type( + Place::Defined( builder.build(), + origin, if any_definitely_bound { - Boundness::Bound + Definedness::AlwaysDefined } else { - Boundness::PossiblyUnbound + Definedness::PossiblyUndefined }, ) }, diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 28d589b5ae..944516c6e8 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -1255,7 +1255,7 @@ mod tests { let safe_uuid_class = known_module_symbol(&db, KnownModule::Uuid, "SafeUUID") .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .unwrap(); let literals = enum_member_literals(&db, safe_uuid_class.expect_class_literal(), None) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 41a392a88b..e7b8758271 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -15,7 +15,7 @@ use super::{Argument, CallArguments, CallError, CallErrorKind, InferContext, Sig use crate::Program; use crate::db::Db; use crate::dunder_all::dunder_all_names; -use crate::place::{Boundness, Place}; +use crate::place::{Definedness, Place}; use crate::types::call::arguments::{Expansion, is_expandable_type}; use crate::types::diagnostic::{ CALL_NON_CALLABLE, CONFLICTING_ARGUMENT_FORMS, INVALID_ARGUMENT_TYPE, MISSING_ARGUMENT, @@ -839,7 +839,7 @@ impl<'db> Bindings<'db> { // TODO: we could emit a diagnostic here (if default is not set) overload.set_return_type( match instance_ty.static_member(db, attr_name.value(db)) { - Place::Type(ty, Boundness::Bound) => { + Place::Defined(ty, _, Definedness::AlwaysDefined) => { if ty.is_dynamic() { // Here, we attempt to model the fact that an attribute lookup on // a dynamic type could fail @@ -849,10 +849,10 @@ impl<'db> Bindings<'db> { ty } } - Place::Type(ty, Boundness::PossiblyUnbound) => { + Place::Defined(ty, _, Definedness::PossiblyUndefined) => { union_with_default(ty) } - Place::Unbound => default, + Place::Undefined => default, }, ); } @@ -2399,7 +2399,7 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { ) .place }) { - Some(Place::Type(keys_method, Boundness::Bound)) => keys_method + Some(Place::Defined(keys_method, _, Definedness::AlwaysDefined)) => keys_method .try_call(db, &CallArguments::positional([Type::unknown()])) .ok() .map_or_else(Type::unknown, |bindings| bindings.return_type(db)), @@ -2717,7 +2717,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { ) .place { - Place::Type(keys_method, Boundness::Bound) => keys_method + Place::Defined(keys_method, _, Definedness::AlwaysDefined) => keys_method .try_call(self.db, &CallArguments::none()) .ok() .and_then(|bindings| { @@ -2762,7 +2762,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { ) .place { - Place::Type(keys_method, Boundness::Bound) => keys_method + Place::Defined(keys_method, _, Definedness::AlwaysDefined) => keys_method .try_call(self.db, &CallArguments::positional([Type::unknown()])) .ok() .map_or_else(Type::unknown, |bindings| bindings.return_type(self.db)), diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 876f6d3930..64ba611a46 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -9,6 +9,7 @@ use super::{ }; use crate::FxOrderMap; use crate::module_resolver::KnownModule; +use crate::place::TypeOrigin; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::scope::{NodeWithScopeKind, Scope}; use crate::semantic_index::symbol::Symbol; @@ -42,7 +43,7 @@ use crate::{ Db, FxIndexMap, FxIndexSet, FxOrderSet, Program, module_resolver::file_to_module, place::{ - Boundness, LookupError, LookupResult, Place, PlaceAndQualifiers, known_module_symbol, + Definedness, LookupError, LookupResult, Place, PlaceAndQualifiers, known_module_symbol, place_from_bindings, place_from_declarations, }, semantic_index::{ @@ -749,7 +750,7 @@ impl<'db> ClassType<'db> { /// class that the lookup is being performed on, and not the class containing the (possibly /// inherited) member. /// - /// Returns [`Place::Unbound`] if `name` cannot be found in this class's scope + /// Returns [`Place::Undefined`] if `name` cannot be found in this class's scope /// directly. Use [`ClassType::class_member`] if you require a method that will /// traverse through the MRO until it finds the member. pub(super) fn own_class_member( @@ -1055,7 +1056,7 @@ impl<'db> ClassType<'db> { let (class_literal, specialization) = self.class_literal(db); if class_literal.is_typed_dict(db) { - return Place::Unbound.into(); + return Place::Undefined.into(); } class_literal @@ -1086,7 +1087,7 @@ impl<'db> ClassType<'db> { ) .place; - if let Place::Type(Type::BoundMethod(metaclass_dunder_call_function), _) = + if let Place::Defined(Type::BoundMethod(metaclass_dunder_call_function), _, _) = metaclass_dunder_call_function_symbol { // TODO: this intentionally diverges from step 1 in @@ -1105,7 +1106,7 @@ impl<'db> ClassType<'db> { .place; let dunder_new_signature = dunder_new_function_symbol - .ignore_possibly_unbound() + .ignore_possibly_undefined() .and_then(|ty| match ty { Type::FunctionLiteral(function) => Some(function.signature(db)), Type::Callable(callable) => Some(callable.signatures(db)), @@ -1156,7 +1157,7 @@ impl<'db> ClassType<'db> { // same parameters as the `__init__` method after it is bound, and with the return type of // the concrete type of `Self`. let synthesized_dunder_init_callable = - if let Place::Type(ty, _) = dunder_init_function_symbol { + if let Place::Defined(ty, _, _) = dunder_init_function_symbol { let signature = match ty { Type::FunctionLiteral(dunder_init_function) => { Some(dunder_init_function.signature(db)) @@ -1208,7 +1209,9 @@ impl<'db> ClassType<'db> { ) .place; - if let Place::Type(Type::FunctionLiteral(new_function), _) = new_function_symbol { + if let Place::Defined(Type::FunctionLiteral(new_function), _, _) = + new_function_symbol + { Type::Callable( new_function .into_bound_method_type(db, correct_return_type) @@ -2077,7 +2080,7 @@ impl<'db> ClassLiteral<'db> { let mut dynamic_type_to_intersect_with: Option> = None; let mut lookup_result: LookupResult<'db> = - Err(LookupError::Unbound(TypeQualifiers::empty())); + Err(LookupError::Undefined(TypeQualifiers::empty())); for superclass in mro_iter { match superclass { @@ -2153,7 +2156,7 @@ impl<'db> ClassLiteral<'db> { ( PlaceAndQualifiers { - place: Place::Type(ty, _), + place: Place::Defined(ty, _, _), qualifiers, }, Some(dynamic_type), @@ -2167,7 +2170,7 @@ impl<'db> ClassLiteral<'db> { ( PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, qualifiers, }, Some(dynamic_type), @@ -2178,7 +2181,7 @@ impl<'db> ClassLiteral<'db> { /// Returns the inferred type of the class member named `name`. Only bound members /// or those marked as `ClassVars` are considered. /// - /// Returns [`Place::Unbound`] if `name` cannot be found in this class's scope + /// Returns [`Place::Undefined`] if `name` cannot be found in this class's scope /// directly. Use [`ClassLiteral::class_member`] if you require a method that will /// traverse through the MRO until it finds the member. pub(super) fn own_class_member( @@ -2190,8 +2193,8 @@ impl<'db> ClassLiteral<'db> { ) -> Member<'db> { if name == "__dataclass_fields__" && self.dataclass_params(db).is_some() { // Make this class look like a subclass of the `DataClassInstance` protocol - return Member::declared( - Place::bound(KnownClass::Dict.to_specialized_instance( + return Member { + inner: Place::declared(KnownClass::Dict.to_specialized_instance( db, [ KnownClass::Str.to_instance(db), @@ -2199,7 +2202,7 @@ impl<'db> ClassLiteral<'db> { ], )) .with_qualifiers(TypeQualifiers::CLASS_VAR), - ); + }; } if CodeGeneratorKind::NamedTuple.matches(db, self) { @@ -2243,7 +2246,7 @@ impl<'db> ClassLiteral<'db> { } }); - if member.is_unbound() { + if member.is_undefined() { if let Some(synthesized_member) = self.own_synthesized_member(db, specialization, name) { return Member::definitely_declared(synthesized_member); @@ -2308,7 +2311,8 @@ impl<'db> ClassLiteral<'db> { } let dunder_set = field_ty.class_member(db, "__set__".into()); - if let Place::Type(dunder_set, Boundness::Bound) = dunder_set.place { + if let Place::Defined(dunder_set, _, Definedness::AlwaysDefined) = dunder_set.place + { // The descriptor handling below is guarded by this not-dynamic check, because // dynamic types like `Any` are valid (data) descriptors: since they have all // possible attributes, they also have a (callable) `__set__` method. The @@ -2429,7 +2433,7 @@ impl<'db> ClassLiteral<'db> { .to_class_literal(db) .as_class_literal()? .own_class_member(db, self.inherited_generic_context(db), None, name) - .ignore_possibly_unbound() + .ignore_possibly_undefined() .map(|ty| { ty.apply_type_mapping( db, @@ -2884,9 +2888,9 @@ impl<'db> ClassLiteral<'db> { continue; } - if let Some(attr_ty) = attr.place.ignore_possibly_unbound() { + if let Some(attr_ty) = attr.place.ignore_possibly_undefined() { let bindings = use_def.end_of_scope_symbol_bindings(symbol_id); - let mut default_ty = place_from_bindings(db, bindings).ignore_possibly_unbound(); + let mut default_ty = place_from_bindings(db, bindings).ignore_possibly_undefined(); default_ty = default_ty.map(|ty| ty.apply_optional_specialization(db, specialization)); @@ -2977,7 +2981,7 @@ impl<'db> ClassLiteral<'db> { name: &str, ) -> PlaceAndQualifiers<'db> { if self.is_typed_dict(db) { - return Place::Unbound.into(); + return Place::Undefined.into(); } let mut union = UnionBuilder::new(db); @@ -2995,17 +2999,13 @@ impl<'db> ClassLiteral<'db> { ); } ClassBase::Class(class) => { - if let Member { - inner: - member @ PlaceAndQualifiers { - place: Place::Type(ty, boundness), - qualifiers, - }, - is_declared, - } = class.own_instance_member(db, name) + if let member @ PlaceAndQualifiers { + place: Place::Defined(ty, origin, boundness), + qualifiers, + } = class.own_instance_member(db, name).inner { - if boundness == Boundness::Bound { - if is_declared { + if boundness == Definedness::AlwaysDefined { + if origin.is_declared() { // We found a definitely-declared attribute. Discard possibly collected // inferred types from subclasses and return the declared type. return member; @@ -3044,15 +3044,16 @@ impl<'db> ClassLiteral<'db> { } if union.is_empty() { - Place::Unbound.with_qualifiers(TypeQualifiers::empty()) + Place::Undefined.with_qualifiers(TypeQualifiers::empty()) } else { let boundness = if is_definitely_bound { - Boundness::Bound + Definedness::AlwaysDefined } else { - Boundness::PossiblyUnbound + Definedness::PossiblyUndefined }; - Place::Type(union.build(), boundness).with_qualifiers(union_qualifiers) + Place::Defined(union.build(), TypeOrigin::Inferred, boundness) + .with_qualifiers(union_qualifiers) } } @@ -3104,7 +3105,10 @@ impl<'db> ClassLiteral<'db> { if let Some(method_def) = method_scope.node().as_function() { let method_name = method_def.node(&module).name.as_str(); if let Some(Type::FunctionLiteral(method_type)) = - class_member(db, class_body_scope, method_name).ignore_possibly_unbound() + class_member(db, class_body_scope, method_name) + .inner + .place + .ignore_possibly_undefined() { let method_decorator = MethodDecorator::try_from_fn_type(db, method_type); if method_decorator != Ok(target_method_decorator) { @@ -3141,7 +3145,7 @@ impl<'db> ClassLiteral<'db> { // self.name: = … let annotation = declaration_type(db, declaration); - let annotation = Place::bound(annotation.inner).with_qualifiers( + let annotation = Place::declared(annotation.inner).with_qualifiers( annotation.qualifiers | TypeQualifiers::IMPLICIT_INSTANCE_ATTRIBUTE, ); @@ -3156,9 +3160,9 @@ impl<'db> ClassLiteral<'db> { index.expression(value), TypeContext::default(), ); - return Member::inferred( - Place::bound(inferred_ty).with_qualifiers(all_qualifiers), - ); + return Member { + inner: Place::bound(inferred_ty).with_qualifiers(all_qualifiers), + }; } // If there is no right-hand side, just record that we saw a `Final` qualifier @@ -3166,7 +3170,7 @@ impl<'db> ClassLiteral<'db> { continue; } - return Member::declared(annotation); + return Member { inner: annotation }; } } @@ -3358,11 +3362,13 @@ impl<'db> ClassLiteral<'db> { } } - Member::inferred(if is_attribute_bound { - Place::bound(union_of_inferred_types.build()).with_qualifiers(qualifiers) - } else { - Place::Unbound.with_qualifiers(qualifiers) - }) + Member { + inner: if is_attribute_bound { + Place::bound(union_of_inferred_types.build()).with_qualifiers(qualifiers) + } else { + Place::Undefined.with_qualifiers(qualifiers) + }, + } } /// A helper function for `instance_member` that looks up the `name` attribute only on @@ -3384,20 +3390,20 @@ impl<'db> ClassLiteral<'db> { match declared_and_qualifiers { PlaceAndQualifiers { - place: mut declared @ Place::Type(declared_ty, declaredness), + place: mut declared @ Place::Defined(declared_ty, _, declaredness), qualifiers, } => { // For the purpose of finding instance attributes, ignore `ClassVar` // declarations: if qualifiers.contains(TypeQualifiers::CLASS_VAR) { - declared = Place::Unbound; + declared = Place::Undefined; } if qualifiers.contains(TypeQualifiers::INIT_VAR) { // We ignore `InitVar` declarations on the class body, unless that attribute is overwritten // by an implicit assignment in a method if Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) - .is_unbound() + .is_undefined() { return Member::unbound(); } @@ -3407,28 +3413,31 @@ impl<'db> ClassLiteral<'db> { let bindings = use_def.end_of_scope_symbol_bindings(symbol_id); let inferred = place_from_bindings(db, bindings); - let has_binding = !inferred.is_unbound(); + let has_binding = !inferred.is_undefined(); if has_binding { // The attribute is declared and bound in the class body. if let Some(implicit_ty) = Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) - .ignore_possibly_unbound() + .ignore_possibly_undefined() { - if declaredness == Boundness::Bound { + if declaredness == Definedness::AlwaysDefined { // If a symbol is definitely declared, and we see // attribute assignments in methods of the class, // we trust the declared type. - Member::declared(declared.with_qualifiers(qualifiers)) + Member { + inner: declared.with_qualifiers(qualifiers), + } } else { - Member::declared( - Place::Type( + Member { + inner: Place::Defined( UnionType::from_elements(db, [declared_ty, implicit_ty]), + TypeOrigin::Declared, declaredness, ) .with_qualifiers(qualifiers), - ) + } } } else { // The symbol is declared and bound in the class body, @@ -3446,8 +3455,10 @@ impl<'db> ClassLiteral<'db> { // it is possibly-undeclared. In the latter case, we also // union with the inferred type from attribute assignments. - if declaredness == Boundness::Bound { - Member::declared(declared.with_qualifiers(qualifiers)) + if declaredness == Definedness::AlwaysDefined { + Member { + inner: declared.with_qualifiers(qualifiers), + } } else { if let Some(implicit_ty) = Self::implicit_attribute( db, @@ -3457,24 +3468,27 @@ impl<'db> ClassLiteral<'db> { ) .inner .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() { - Member::declared( - Place::Type( + Member { + inner: Place::Defined( UnionType::from_elements(db, [declared_ty, implicit_ty]), + TypeOrigin::Declared, declaredness, ) .with_qualifiers(qualifiers), - ) + } } else { - Member::declared(declared.with_qualifiers(qualifiers)) + Member { + inner: declared.with_qualifiers(qualifiers), + } } } } } PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, qualifiers: _, } => { // The attribute is not *declared* in the class body. It could still be declared/bound @@ -3678,7 +3692,7 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { .chain(attribute_places_and_qualifiers) .dedup() .filter_map(|(name, place_and_qual)| { - place_and_qual.place.ignore_possibly_unbound().map(|ty| { + place_and_qual.ignore_possibly_undefined().map(|ty| { let variance = if place_and_qual .qualifiers // `CLASS_VAR || FINAL` is really `all()`, but @@ -4636,14 +4650,18 @@ impl KnownClass { ) -> Result, KnownClassLookupError<'_>> { let symbol = known_module_symbol(db, self.canonical_module(db), self.name(db)).place; match symbol { - Place::Type(Type::ClassLiteral(class_literal), Boundness::Bound) => Ok(class_literal), - Place::Type(Type::ClassLiteral(class_literal), Boundness::PossiblyUnbound) => { - Err(KnownClassLookupError::ClassPossiblyUnbound { class_literal }) + Place::Defined(Type::ClassLiteral(class_literal), _, Definedness::AlwaysDefined) => { + Ok(class_literal) } - Place::Type(found_type, _) => { + Place::Defined( + Type::ClassLiteral(class_literal), + _, + Definedness::PossiblyUndefined, + ) => Err(KnownClassLookupError::ClassPossiblyUnbound { class_literal }), + Place::Defined(found_type, _, _) => { Err(KnownClassLookupError::SymbolNotAClass { found_type }) } - Place::Unbound => Err(KnownClassLookupError::ClassNotFound), + Place::Undefined => Err(KnownClassLookupError::ClassNotFound), } } @@ -5434,7 +5452,7 @@ enum SlotsKind { impl SlotsKind { fn from(db: &dyn Db, base: ClassLiteral) -> Self { - let Place::Type(slots_ty, bound) = base + let Place::Defined(slots_ty, _, bound) = base .own_class_member(db, base.inherited_generic_context(db), None, "__slots__") .inner .place @@ -5442,7 +5460,7 @@ impl SlotsKind { return Self::NotSpecified; }; - if matches!(bound, Boundness::PossiblyUnbound) { + if matches!(bound, Definedness::PossiblyUndefined) { return Self::Dynamic; } diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index debde1a54c..03e5d738b9 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -1713,7 +1713,7 @@ mod tests { let iterator_synthesized = typing_extensions_symbol(&db, "Iterator") .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .unwrap() .to_instance(&db) .unwrap() diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index 6ddb8de185..fc4e4855e3 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -92,7 +92,7 @@ pub(crate) fn enum_metadata<'db>( let ignore_place = place_from_bindings(db, ignore_bindings); match ignore_place { - Place::Type(Type::StringLiteral(ignored_names), _) => { + Place::Defined(Type::StringLiteral(ignored_names), _, _) => { Some(ignored_names.value(db).split_ascii_whitespace().collect()) } // TODO: support the list-variant of `_ignore_`. @@ -126,10 +126,10 @@ pub(crate) fn enum_metadata<'db>( let inferred = place_from_bindings(db, bindings); let value_ty = match inferred { - Place::Unbound => { + Place::Undefined => { return None; } - Place::Type(ty, _) => { + Place::Defined(ty, _, _) => { let special_case = match ty { Type::Callable(_) | Type::FunctionLiteral(_) => { // Some types are specifically disallowed for enum members. @@ -143,7 +143,7 @@ pub(crate) fn enum_metadata<'db>( Some(KnownClass::Member) => Some( ty.member(db, "value") .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .unwrap_or(Type::unknown()), ), @@ -178,9 +178,9 @@ pub(crate) fn enum_metadata<'db>( .place; match dunder_get { - Place::Unbound | Place::Type(Type::Dynamic(_), _) => ty, + Place::Undefined | Place::Defined(Type::Dynamic(_), _, _) => ty, - Place::Type(_, _) => { + Place::Defined(_, _, _) => { // Descriptors are not considered members. return None; } @@ -215,17 +215,17 @@ pub(crate) fn enum_metadata<'db>( match declared { PlaceAndQualifiers { - place: Place::Type(Type::Dynamic(DynamicType::Unknown), _), + place: Place::Defined(Type::Dynamic(DynamicType::Unknown), _, _), qualifiers, } if qualifiers.contains(TypeQualifiers::FINAL) => {} PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, .. } => { // Undeclared attributes are considered members } PlaceAndQualifiers { - place: Place::Type(Type::NominalInstance(instance), _), + place: Place::Defined(Type::NominalInstance(instance), _, _), .. } if instance.has_known_class(db, KnownClass::Member) => { // If the attribute is specifically declared with `enum.member`, it is considered a member diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index a94c222a37..1456f12526 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -59,7 +59,7 @@ use ruff_python_ast::{self as ast, ParameterWithDefault}; use ruff_text_size::Ranged; use crate::module_resolver::{KnownModule, file_to_module}; -use crate::place::{Boundness, Place, place_from_bindings}; +use crate::place::{Definedness, Place, place_from_bindings}; use crate::semantic_index::ast_ids::HasScopedUseId; use crate::semantic_index::definition::Definition; use crate::semantic_index::scope::ScopeId; @@ -314,7 +314,7 @@ impl<'db> OverloadLiteral<'db> { .name .scoped_use_id(db, scope); - let Place::Type(Type::FunctionLiteral(previous_type), Boundness::Bound) = + let Place::Defined(Type::FunctionLiteral(previous_type), _, Definedness::AlwaysDefined) = place_from_bindings(db, use_def.bindings_at_use(use_id)) else { return None; diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index a5fe27877c..0dfa18dbed 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -42,7 +42,7 @@ pub(crate) fn all_declarations_and_bindings<'db>( place_result .ignore_conflicting_declarations() .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .map(|ty| { let symbol = table.symbol(symbol_id); let member = Member { @@ -71,7 +71,7 @@ pub(crate) fn all_declarations_and_bindings<'db>( } } place_from_bindings(db, bindings) - .ignore_possibly_unbound() + .ignore_possibly_undefined() .map(|ty| { let symbol = table.symbol(symbol_id); let member = Member { @@ -239,7 +239,8 @@ impl<'db> AllMembers<'db> { for (symbol_id, _) in use_def_map.all_end_of_scope_symbol_declarations() { let symbol_name = place_table.symbol(symbol_id).name(); - let Place::Type(ty, _) = imported_symbol(db, file, symbol_name, None).place + let Place::Defined(ty, _, _) = + imported_symbol(db, file, symbol_name, None).place else { continue; }; @@ -327,7 +328,7 @@ impl<'db> AllMembers<'db> { let parent_scope = parent.body_scope(db); for memberdef in all_declarations_and_bindings(db, parent_scope) { let result = ty.member(db, memberdef.member.name.as_str()); - let Some(ty) = result.place.ignore_possibly_unbound() else { + let Some(ty) = result.place.ignore_possibly_undefined() else { continue; }; self.members.insert(Member { @@ -358,7 +359,7 @@ impl<'db> AllMembers<'db> { continue; }; let result = ty.member(db, name); - let Some(ty) = result.place.ignore_possibly_unbound() else { + let Some(ty) = result.place.ignore_possibly_undefined() else { continue; }; self.members.insert(Member { @@ -375,7 +376,7 @@ impl<'db> AllMembers<'db> { // method, but `instance_of_SomeClass.__delattr__` is. for memberdef in all_declarations_and_bindings(db, class_body_scope) { let result = ty.member(db, memberdef.member.name.as_str()); - let Some(ty) = result.place.ignore_possibly_unbound() else { + let Some(ty) = result.place.ignore_possibly_undefined() else { continue; }; self.members.insert(Member { diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 171fa42438..52a8119465 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -751,7 +751,7 @@ impl<'db> DefinitionInference<'db> { None } }) - .or_else(|| self.fallback_type().map(Into::into)) + .or_else(|| self.fallback_type().map(TypeAndQualifiers::declared)) .expect( "definition should belong to this TypeInference region and \ TypeInferenceBuilder should have inferred a type for it", diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 16b669e65a..be92acc9ab 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -22,7 +22,7 @@ use crate::module_resolver::{ }; use crate::node_key::NodeKey; use crate::place::{ - Boundness, ConsideredDefinitions, LookupError, Place, PlaceAndQualifiers, + ConsideredDefinitions, Definedness, LookupError, Place, PlaceAndQualifiers, TypeOrigin, builtins_module_scope, builtins_symbol, explicit_global_symbol, global_symbol, module_type_implicit_global_declaration, module_type_implicit_global_symbol, place, place_from_bindings, place_from_declarations, typing_extensions_symbol, @@ -136,7 +136,11 @@ enum DeclaredAndInferredType<'db> { impl<'db> DeclaredAndInferredType<'db> { fn are_the_same_type(ty: Type<'db>) -> Self { - Self::AreTheSame(ty.into()) + Self::AreTheSame(TypeAndQualifiers::new( + ty, + TypeOrigin::Inferred, + TypeQualifiers::empty(), + )) } } @@ -957,7 +961,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut public_functions = FxHashSet::default(); for place in overloaded_function_places { - if let Place::Type(Type::FunctionLiteral(function), Boundness::Bound) = + if let Place::Defined(Type::FunctionLiteral(function), _, Definedness::AlwaysDefined) = place_from_bindings( self.db(), use_def.end_of_scope_symbol_bindings(place.as_symbol().unwrap()), @@ -1465,7 +1469,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // Fall back to implicit module globals for (possibly) unbound names - if !matches!(place_and_quals.place, Place::Type(_, Boundness::Bound)) { + if !place_and_quals.place.is_definitely_bound() { if let PlaceExprRef::Symbol(symbol) = place { let symbol_id = place_id.expect_symbol(); @@ -1486,38 +1490,40 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let unwrap_declared_ty = || { resolved_place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .unwrap_or(Type::unknown()) }; // If the place is unbound and its an attribute or subscript place, fall back to normal // attribute/subscript inference on the root type. - let declared_ty = if resolved_place.is_unbound() && !place_table.place(place_id).is_symbol() - { - if let AnyNodeRef::ExprAttribute(ast::ExprAttribute { value, attr, .. }) = node { - let value_type = - self.infer_maybe_standalone_expression(value, TypeContext::default()); - if let Place::Type(ty, Boundness::Bound) = value_type.member(db, attr).place { - // TODO: also consider qualifiers on the attribute - ty + let declared_ty = + if resolved_place.is_undefined() && !place_table.place(place_id).is_symbol() { + if let AnyNodeRef::ExprAttribute(ast::ExprAttribute { value, attr, .. }) = node { + let value_type = + self.infer_maybe_standalone_expression(value, TypeContext::default()); + if let Place::Defined(ty, _, Definedness::AlwaysDefined) = + value_type.member(db, attr).place + { + // TODO: also consider qualifiers on the attribute + ty + } else { + unwrap_declared_ty() + } + } else if let AnyNodeRef::ExprSubscript( + subscript @ ast::ExprSubscript { + value, slice, ctx, .. + }, + ) = node + { + let value_ty = self.infer_expression(value, TypeContext::default()); + let slice_ty = self.infer_expression(slice, TypeContext::default()); + self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx) } else { unwrap_declared_ty() } - } else if let AnyNodeRef::ExprSubscript( - subscript @ ast::ExprSubscript { - value, slice, ctx, .. - }, - ) = node - { - let value_ty = self.infer_expression(value, TypeContext::default()); - let slice_ty = self.infer_expression(slice, TypeContext::default()); - self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx) } else { unwrap_declared_ty() - } - } else { - unwrap_declared_ty() - }; + }; if qualifiers.contains(TypeQualifiers::FINAL) { let mut previous_bindings = use_def.bindings_at_definition(binding); @@ -1585,7 +1591,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if value_ty .class_member(db, attr.id.clone()) .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .is_some_and(|ty| ty.may_be_data_descriptor(db)) { bound_ty = declared_ty; @@ -1644,14 +1650,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if scope.is_global() { module_type_implicit_global_symbol(self.db(), symbol.name()) } else { - Place::Unbound.into() + Place::Undefined.into() } } else { - Place::Unbound.into() + Place::Undefined.into() } }) .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .unwrap_or(Type::Never); let ty = if inferred_ty.is_assignable_to(self.db(), ty.inner_type()) { ty @@ -1663,7 +1669,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { inferred_ty.display(self.db()) )); } - TypeAndQualifiers::unknown() + TypeAndQualifiers::declared(Type::unknown()) }; self.declarations.insert(declaration, ty); } @@ -1702,7 +1708,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(module_type_implicit_declaration) = place .as_symbol() .map(|symbol| module_type_implicit_global_symbol(self.db(), symbol.name())) - .and_then(|place| place.place.ignore_possibly_unbound()) + .and_then(|place| place.place.ignore_possibly_undefined()) { let declared_type = declared_ty.inner_type(); if !declared_type @@ -2425,7 +2431,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let declared_and_inferred_ty = if let Some(default_ty) = default_ty { if default_ty.is_assignable_to(self.db(), declared_ty) { DeclaredAndInferredType::MightBeDifferent { - declared_ty: declared_ty.into(), + declared_ty: TypeAndQualifiers::declared(declared_ty), inferred_ty: UnionType::from_elements(self.db(), [declared_ty, default_ty]), } } else if (self.in_stub() @@ -3619,14 +3625,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, ) { PlaceAndQualifiers { - place: Place::Type(attr_ty, _), + place: Place::Defined(attr_ty, _, _), qualifiers: _, } => attr_ty.is_callable_type(), _ => false, }; let member_exists = - !object_ty.member(db, attribute).place.is_unbound(); + !object_ty.member(db, attribute).place.is_undefined(); let msg = if !member_exists { format!( @@ -3693,7 +3699,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { false } PlaceAndQualifiers { - place: Place::Type(meta_attr_ty, meta_attr_boundness), + place: Place::Defined(meta_attr_ty, _, meta_attr_boundness), qualifiers, } => { if invalid_assignment_to_final(qualifiers) { @@ -3701,7 +3707,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let assignable_to_meta_attr = - if let Place::Type(meta_dunder_set, _) = + if let Place::Defined(meta_dunder_set, _, _) = meta_attr_ty.class_member(db, "__set__".into()).place { let dunder_set_result = meta_dunder_set.try_call( @@ -3733,11 +3739,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let assignable_to_instance_attribute = if meta_attr_boundness - == Boundness::PossiblyUnbound + == Definedness::PossiblyUndefined { let (assignable, boundness) = if let PlaceAndQualifiers { place: - Place::Type(instance_attr_ty, instance_attr_boundness), + Place::Defined(instance_attr_ty, _, instance_attr_boundness), qualifiers, } = object_ty.instance_member(db, attribute) @@ -3751,10 +3757,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { instance_attr_boundness, ) } else { - (true, Boundness::PossiblyUnbound) + (true, Definedness::PossiblyUndefined) }; - if boundness == Boundness::PossiblyUnbound { + if boundness == Definedness::PossiblyUndefined { report_possibly_missing_attribute( &self.context, target, @@ -3772,11 +3778,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, .. } => { if let PlaceAndQualifiers { - place: Place::Type(instance_attr_ty, instance_attr_boundness), + place: + Place::Defined(instance_attr_ty, _, instance_attr_boundness), qualifiers, } = object_ty.instance_member(db, attribute) { @@ -3784,7 +3791,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return false; } - if instance_attr_boundness == Boundness::PossiblyUnbound { + if instance_attr_boundness == Definedness::PossiblyUndefined { report_possibly_missing_attribute( &self.context, target, @@ -3818,14 +3825,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { match object_ty.class_member(db, attribute.into()) { PlaceAndQualifiers { - place: Place::Type(meta_attr_ty, meta_attr_boundness), + place: Place::Defined(meta_attr_ty, _, meta_attr_boundness), qualifiers, } => { if invalid_assignment_to_final(qualifiers) { return false; } - let assignable_to_meta_attr = if let Place::Type(meta_dunder_set, _) = + let assignable_to_meta_attr = if let Place::Defined(meta_dunder_set, _, _) = meta_attr_ty.class_member(db, "__set__".into()).place { let dunder_set_result = meta_dunder_set.try_call( @@ -3851,20 +3858,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let assignable_to_class_attr = if meta_attr_boundness - == Boundness::PossiblyUnbound + == Definedness::PossiblyUndefined { let (assignable, boundness) = - if let Place::Type(class_attr_ty, class_attr_boundness) = object_ty - .find_name_in_mro(db, attribute) - .expect("called on Type::ClassLiteral or Type::SubclassOf") - .place + if let Place::Defined(class_attr_ty, _, class_attr_boundness) = + object_ty + .find_name_in_mro(db, attribute) + .expect("called on Type::ClassLiteral or Type::SubclassOf") + .place { (ensure_assignable_to(class_attr_ty), class_attr_boundness) } else { - (true, Boundness::PossiblyUnbound) + (true, Definedness::PossiblyUndefined) }; - if boundness == Boundness::PossiblyUnbound { + if boundness == Definedness::PossiblyUndefined { report_possibly_missing_attribute( &self.context, target, @@ -3881,11 +3889,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { assignable_to_meta_attr && assignable_to_class_attr } PlaceAndQualifiers { - place: Place::Unbound, + place: Place::Undefined, .. } => { if let PlaceAndQualifiers { - place: Place::Type(class_attr_ty, class_attr_boundness), + place: Place::Defined(class_attr_ty, _, class_attr_boundness), qualifiers, } = object_ty .find_name_in_mro(db, attribute) @@ -3895,7 +3903,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return false; } - if class_attr_boundness == Boundness::PossiblyUnbound { + if class_attr_boundness == Definedness::PossiblyUndefined { report_possibly_missing_attribute( &self.context, target, @@ -3911,7 +3919,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { !instance .instance_member(self.db(), attribute) .place - .is_unbound() + .is_undefined() }); // Attribute is declared or bound on instance. Forbid access from the class object @@ -3946,7 +3954,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } Type::ModuleLiteral(module) => { - if let Place::Type(attr_ty, _) = module.static_member(db, attribute).place { + if let Place::Defined(attr_ty, _, _) = module.static_member(db, attribute).place { let assignable = value_ty.is_assignable_to(db, attr_ty); if assignable { true @@ -5057,11 +5065,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // First try loading the requested attribute from the module. if !import_is_self_referential { if let PlaceAndQualifiers { - place: Place::Type(ty, boundness), + place: Place::Defined(ty, _, boundness), qualifiers, } = module_ty.member(self.db(), name) { - if &alias.name != "*" && boundness == Boundness::PossiblyUnbound { + if &alias.name != "*" && boundness == Definedness::PossiblyUndefined { // TODO: Consider loading _both_ the attribute and any submodule and unioning them // together if the attribute exists but is possibly-unbound. if let Some(builder) = self @@ -5079,6 +5087,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &DeclaredAndInferredType::MightBeDifferent { declared_ty: TypeAndQualifiers { inner: ty, + origin: TypeOrigin::Declared, qualifiers, }, inferred_ty: ty, @@ -5224,7 +5233,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } if !module_type_implicit_global_symbol(self.db(), name) .place - .is_unbound() + .is_undefined() { // This name is an implicit global like `__file__` (but not a built-in like `int`). continue; @@ -6934,7 +6943,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // (without infinite recursion if we're already in builtins.) .or_fall_back_to(db, || { if Some(self.scope()) == builtins_module_scope(db) { - Place::Unbound.into() + Place::Undefined.into() } else { builtins_symbol(db, symbol_name) } @@ -6951,7 +6960,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } typing_extensions_symbol(db, symbol_name) } else { - Place::Unbound.into() + Place::Undefined.into() } }); @@ -6961,11 +6970,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let ty = resolved_after_fallback.unwrap_with_diagnostic(|lookup_error| match lookup_error { - LookupError::Unbound(qualifiers) => { + LookupError::Undefined(qualifiers) => { self.report_unresolved_reference(name_node); - TypeAndQualifiers::new(Type::unknown(), qualifiers) + TypeAndQualifiers::new(Type::unknown(), TypeOrigin::Inferred, qualifiers) } - LookupError::PossiblyUnbound(type_when_bound) => { + LookupError::PossiblyUndefined(type_when_bound) => { if self.is_reachable(name_node) { report_possibly_unresolved_reference(&self.context, name_node); } @@ -6996,7 +7005,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred_state.in_string_annotation(), "Expected the place table to create a place for every valid PlaceExpr node" ); - Place::Unbound + Place::Undefined }; (place, None) } else { @@ -7004,7 +7013,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .as_name_expr() .is_some_and(|name| name.is_invalid()) { - return (Place::Unbound, None); + return (Place::Undefined, None); } let use_id = expr_ref.scoped_use_id(db, scope); @@ -7064,7 +7073,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // enclosing scopes in this case. The one exception to this rule is the global fallback // in class bodies, which we already handled above. if symbol_resolves_locally { - return Place::Unbound.into(); + return Place::Undefined.into(); } for parent_id in place_table.parents(place_expr) { @@ -7082,8 +7091,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } let (parent_place, _use_id) = self.infer_local_place_load(parent_expr, expr_ref); - if let Place::Type(_, _) = parent_place { - return Place::Unbound.into(); + if let Place::Defined(_, _, _) = parent_place { + return Place::Undefined.into(); } } @@ -7154,13 +7163,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Don't fall back to non-eager place resolution. EnclosingSnapshotResult::NotFound => { if has_root_place_been_reassigned() { - return Place::Unbound.into(); + return Place::Undefined.into(); } continue; } EnclosingSnapshotResult::NoLongerInEagerContext => { if has_root_place_been_reassigned() { - return Place::Unbound.into(); + return Place::Undefined.into(); } } } @@ -7209,12 +7218,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &constraint_keys, ) }); - // We could have Place::Unbound here, despite the checks above, for example if + // We could have `Place::Undefined` here, despite the checks above, for example if // this scope contains a `del` statement but no binding or declaration. - if let Place::Type(type_, boundness) = local_place_and_qualifiers.place { + if let Place::Defined(type_, _, boundness) = local_place_and_qualifiers.place { nonlocal_union_builder.add_in_place(type_); // `ConsideredDefinitions::AllReachable` never returns PossiblyUnbound - debug_assert_eq!(boundness, Boundness::Bound); + debug_assert_eq!(boundness, Definedness::AlwaysDefined); found_some_definition = true; } @@ -7223,20 +7232,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // declared but doesn't mark it `nonlocal`. The name is therefore resolved, // and we won't consider any scopes outside of this one. return if found_some_definition { - Place::Type(nonlocal_union_builder.build(), Boundness::Bound).into() + Place::bound(nonlocal_union_builder.build()).into() } else { - Place::Unbound.into() + Place::Undefined.into() }; } } } - PlaceAndQualifiers::from(Place::Unbound) + PlaceAndQualifiers::from(Place::Undefined) // No nonlocal binding? Check the module's explicit globals. // Avoid infinite recursion if `self.scope` already is the module's global scope. .or_fall_back_to(db, || { if file_scope_id.is_global() { - return Place::Unbound.into(); + return Place::Undefined.into(); } if !self.is_deferred() { @@ -7267,14 +7276,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // There are no visible bindings / constraint here. EnclosingSnapshotResult::NotFound => { - return Place::Unbound.into(); + return Place::Undefined.into(); } EnclosingSnapshotResult::NoLongerInEagerContext => {} } } let Some(symbol) = place_expr.as_symbol() else { - return Place::Unbound.into(); + return Place::Undefined.into(); }; explicit_global_symbol(db, self.file(), symbol.name()).map_type(|ty| { @@ -7287,7 +7296,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }) }); - if let Some(ty) = place.place.ignore_possibly_unbound() { + if let Some(ty) = place.place.ignore_possibly_undefined() { self.check_deprecated(expr_ref, ty); } @@ -7360,11 +7369,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Ok(MethodDecorator::ClassMethod) => !Type::instance(self.db(), class) .class_member(self.db(), id.clone()) .place - .is_unbound(), + .is_undefined(), Ok(MethodDecorator::None) => !Type::instance(self.db(), class) .member(self.db(), id) .place - .is_unbound(), + .is_undefined(), Ok(MethodDecorator::StaticMethod) | Err(()) => false, }; @@ -7421,7 +7430,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ast::ExprRef::Attribute(attribute), ); constraint_keys.extend(keys); - if let Place::Type(ty, Boundness::Bound) = resolved.place { + if let Place::Defined(ty, _, Definedness::AlwaysDefined) = resolved.place { assigned_type = Some(ty); } } @@ -7441,18 +7450,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { fallback_place.map_type(|ty| { self.narrow_expr_with_applicable_constraints(attribute, ty, &constraint_keys) }).unwrap_with_diagnostic(|lookup_error| match lookup_error { - LookupError::Unbound(_) => { + LookupError::Undefined(_) => { let report_unresolved_attribute = self.is_reachable(attribute); if report_unresolved_attribute { let bound_on_instance = match value_type { Type::ClassLiteral(class) => { - !class.instance_member(db, None, attr).place.is_unbound() + !class.instance_member(db, None, attr).is_undefined() } Type::SubclassOf(subclass_of @ SubclassOfType { .. }) => { match subclass_of.subclass_of() { SubclassOfInner::Class(class) => { - !class.instance_member(db, attr).place.is_unbound() + !class.instance_member(db, attr).is_undefined() } SubclassOfInner::Dynamic(_) => unreachable!( "Attribute lookup on a dynamic `SubclassOf` type should always return a bound symbol" @@ -7487,9 +7496,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - Type::unknown().into() + TypeAndQualifiers::new(Type::unknown(), TypeOrigin::Inferred, TypeQualifiers::empty()) } - LookupError::PossiblyUnbound(type_when_bound) => { + LookupError::PossiblyUndefined(type_when_bound) => { report_possibly_missing_attribute( &self.context, attribute, @@ -8064,7 +8073,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let rhs_reflected = right_class.member(self.db(), reflected_dunder).place; // TODO: if `rhs_reflected` is possibly unbound, we should union the two possible // Bindings together - if !rhs_reflected.is_unbound() + if !rhs_reflected.is_undefined() && rhs_reflected != left_class.member(self.db(), reflected_dunder).place { return right_ty @@ -8911,7 +8920,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let contains_dunder = right.class_member(db, "__contains__".into()).place; let compare_result_opt = match contains_dunder { - Place::Type(contains_dunder, Boundness::Bound) => { + Place::Defined(contains_dunder, _, Definedness::AlwaysDefined) => { // If `__contains__` is available, it is used directly for the membership test. contains_dunder .try_call(db, &CallArguments::positional([right, left])) @@ -9092,7 +9101,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ast::ExprRef::Subscript(subscript), ); constraint_keys.extend(keys); - if let Place::Type(ty, Boundness::Bound) = place.place { + if let Place::Defined(ty, _, Definedness::AlwaysDefined) = place.place { // Even if we can obtain the subscript type based on the assignments, we still perform default type inference // (to store the expression type and to report errors). let slice_ty = self.infer_expression(slice, TypeContext::default()); @@ -9548,9 +9557,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let dunder_class_getitem_method = value_ty.member(db, "__class_getitem__").place; match dunder_class_getitem_method { - Place::Unbound => {} - Place::Type(ty, boundness) => { - if boundness == Boundness::PossiblyUnbound { + Place::Undefined => {} + Place::Defined(ty, _, boundness) => { + if boundness == Definedness::PossiblyUndefined { if let Some(builder) = context.report_lint(&POSSIBLY_MISSING_IMPLICIT_CALL, value_node) { diff --git a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs index e03f5ef8be..1e954d461e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs @@ -1,6 +1,7 @@ use ruff_python_ast as ast; use super::{DeferredExpressionState, TypeInferenceBuilder}; +use crate::place::TypeOrigin; use crate::types::diagnostic::{INVALID_TYPE_FORM, report_invalid_arguments_to_annotated}; use crate::types::string_annotation::{ BYTE_STRING_TYPE_ANNOTATION, FSTRING_TYPE_ANNOTATION, parse_string_annotation, @@ -48,21 +49,31 @@ impl<'db> TypeInferenceBuilder<'db, '_> { builder: &TypeInferenceBuilder<'db, '_>, ) -> TypeAndQualifiers<'db> { match ty { - Type::SpecialForm(SpecialFormType::ClassVar) => { - TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::CLASS_VAR) - } - Type::SpecialForm(SpecialFormType::Final) => { - TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::FINAL) - } - Type::SpecialForm(SpecialFormType::Required) => { - TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::REQUIRED) - } - Type::SpecialForm(SpecialFormType::NotRequired) => { - TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::NOT_REQUIRED) - } - Type::SpecialForm(SpecialFormType::ReadOnly) => { - TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::READ_ONLY) - } + Type::SpecialForm(SpecialFormType::ClassVar) => TypeAndQualifiers::new( + Type::unknown(), + TypeOrigin::Declared, + TypeQualifiers::CLASS_VAR, + ), + Type::SpecialForm(SpecialFormType::Final) => TypeAndQualifiers::new( + Type::unknown(), + TypeOrigin::Declared, + TypeQualifiers::FINAL, + ), + Type::SpecialForm(SpecialFormType::Required) => TypeAndQualifiers::new( + Type::unknown(), + TypeOrigin::Declared, + TypeQualifiers::REQUIRED, + ), + Type::SpecialForm(SpecialFormType::NotRequired) => TypeAndQualifiers::new( + Type::unknown(), + TypeOrigin::Declared, + TypeQualifiers::NOT_REQUIRED, + ), + Type::SpecialForm(SpecialFormType::ReadOnly) => TypeAndQualifiers::new( + Type::unknown(), + TypeOrigin::Declared, + TypeQualifiers::READ_ONLY, + ), Type::ClassLiteral(class) if class.is_known(builder.db(), KnownClass::InitVar) => { if let Some(builder) = builder.context.report_lint(&INVALID_TYPE_FORM, annotation) @@ -70,10 +81,14 @@ impl<'db> TypeInferenceBuilder<'db, '_> { builder .into_diagnostic("`InitVar` may not be used without a type argument"); } - TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::INIT_VAR) + TypeAndQualifiers::new( + Type::unknown(), + TypeOrigin::Declared, + TypeQualifiers::INIT_VAR, + ) } - _ => ty - .in_type_expression( + _ => TypeAndQualifiers::declared( + ty.in_type_expression( builder.db(), builder.scope(), builder.typevar_binding_context, @@ -84,8 +99,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { annotation, builder.is_reachable(annotation), ) - }) - .into(), + }), + ), } } @@ -95,7 +110,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ast::Expr::StringLiteral(string) => self.infer_string_annotation_expression(string), // Annotation expressions also get special handling for `*args` and `**kwargs`. - ast::Expr::Starred(starred) => self.infer_starred_expression(starred).into(), + ast::Expr::Starred(starred) => { + TypeAndQualifiers::declared(self.infer_starred_expression(starred)) + } ast::Expr::BytesLiteral(bytes) => { if let Some(builder) = self @@ -104,7 +121,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { { builder.into_diagnostic("Type expressions cannot use bytes literal"); } - TypeAndQualifiers::unknown() + TypeAndQualifiers::declared(Type::unknown()) } ast::Expr::FString(fstring) => { @@ -112,7 +129,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { builder.into_diagnostic("Type expressions cannot use f-strings"); } self.infer_fstring_expression(fstring); - TypeAndQualifiers::unknown() + TypeAndQualifiers::declared(Type::unknown()) } ast::Expr::Attribute(attribute) => match attribute.ctx { @@ -121,20 +138,20 @@ impl<'db> TypeInferenceBuilder<'db, '_> { annotation, self, ), - ast::ExprContext::Invalid => TypeAndQualifiers::unknown(), - ast::ExprContext::Store | ast::ExprContext::Del => { - todo_type!("Attribute expression annotation in Store/Del context").into() - } + ast::ExprContext::Invalid => TypeAndQualifiers::declared(Type::unknown()), + ast::ExprContext::Store | ast::ExprContext::Del => TypeAndQualifiers::declared( + todo_type!("Attribute expression annotation in Store/Del context"), + ), }, ast::Expr::Name(name) => match name.ctx { ast::ExprContext::Load => { infer_name_or_attribute(self.infer_name_expression(name), annotation, self) } - ast::ExprContext::Invalid => TypeAndQualifiers::unknown(), - ast::ExprContext::Store | ast::ExprContext::Del => { - todo_type!("Name expression annotation in Store/Del context").into() - } + ast::ExprContext::Invalid => TypeAndQualifiers::declared(Type::unknown()), + ast::ExprContext::Store | ast::ExprContext::Del => TypeAndQualifiers::declared( + todo_type!("Name expression annotation in Store/Del context"), + ), }, ast::Expr::Subscript(subscript @ ast::ExprSubscript { value, slice, .. }) => { @@ -170,7 +187,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.infer_expression(argument, TypeContext::default()); } self.store_expression_type(slice, Type::unknown()); - TypeAndQualifiers::unknown() + TypeAndQualifiers::declared(Type::unknown()) } } else { report_invalid_arguments_to_annotated(&self.context, subscript); @@ -225,7 +242,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { got {num_arguments}", )); } - Type::unknown().into() + TypeAndQualifiers::declared(Type::unknown()) }; if slice.is_tuple_expr() { self.store_expression_type(slice, type_and_qualifiers.inner_type()); @@ -256,22 +273,24 @@ impl<'db> TypeInferenceBuilder<'db, '_> { got {num_arguments}", )); } - Type::unknown().into() + TypeAndQualifiers::declared(Type::unknown()) }; if slice.is_tuple_expr() { self.store_expression_type(slice, type_and_qualifiers.inner_type()); } type_and_qualifiers } - _ => self - .infer_subscript_type_expression_no_store(subscript, slice, value_ty) - .into(), + _ => TypeAndQualifiers::declared( + self.infer_subscript_type_expression_no_store(subscript, slice, value_ty), + ), } } // All other annotation expressions are (possibly) valid type expressions, so handle // them there instead. - type_expr => self.infer_type_expression_no_store(type_expr).into(), + type_expr => { + TypeAndQualifiers::declared(self.infer_type_expression_no_store(type_expr)) + } }; self.store_expression_type(annotation, annotation_ty.inner_type()); @@ -294,7 +313,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ), ) } - None => TypeAndQualifiers::unknown(), + None => TypeAndQualifiers::declared(Type::unknown()), } } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index e4a4948b62..69c6b8a165 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1429,7 +1429,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let ty = value_ty .member(self.db(), &attr.id) .place - .ignore_possibly_unbound() + .ignore_possibly_undefined() .unwrap_or(Type::unknown()); self.store_expression_type(parameters, ty); ty diff --git a/crates/ty_python_semantic/src/types/member.rs b/crates/ty_python_semantic/src/types/member.rs index 3c541b1035..4090533221 100644 --- a/crates/ty_python_semantic/src/types/member.rs +++ b/crates/ty_python_semantic/src/types/member.rs @@ -1,68 +1,40 @@ -use super::Type; use crate::Db; use crate::place::{ ConsideredDefinitions, Place, PlaceAndQualifiers, RequiresExplicitReExport, place_by_id, place_from_bindings, }; use crate::semantic_index::{place_table, scope::ScopeId, use_def_map}; +use crate::types::Type; /// The return type of certain member-lookup operations. Contains information -/// about the type, type qualifiers, boundness/declaredness, and additional -/// metadata (e.g. whether or not the member was declared) -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +/// about the type, type qualifiers, boundness/declaredness. +#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize, Default)] pub(super) struct Member<'db> { /// Type, qualifiers, and boundness information of this member pub(super) inner: PlaceAndQualifiers<'db>, - - /// Whether or not this member was explicitly declared (e.g. `attr: int = 1` - /// on the class body or `self.attr: int = 1` in a class method), or if the - /// type was inferred (e.g. `attr = 1` on the class body or `self.attr = 1` - /// in a class method). - pub(super) is_declared: bool, -} - -impl Default for Member<'_> { - fn default() -> Self { - Member::inferred(PlaceAndQualifiers::default()) - } } impl<'db> Member<'db> { - /// Create a new [`Member`] whose type was inferred (rather than explicitly declared). - pub(super) fn inferred(inner: PlaceAndQualifiers<'db>) -> Self { - Self { - inner, - is_declared: false, - } - } - - /// Create a new [`Member`] whose type was explicitly declared (rather than inferred). - pub(super) fn declared(inner: PlaceAndQualifiers<'db>) -> Self { - Self { - inner, - is_declared: true, - } - } - - /// Create a new [`Member`] whose type was explicitly and definitively declared, i.e. - /// there is no control flow path in which it might be possibly undeclared. - pub(super) fn definitely_declared(ty: Type<'db>) -> Self { - Self::declared(Place::bound(ty).into()) - } - - /// Represents the absence of a member. pub(super) fn unbound() -> Self { - Self::inferred(PlaceAndQualifiers::default()) + Self { + inner: PlaceAndQualifiers::unbound(), + } } - /// Returns `true` if the inner place is unbound (i.e. there is no such member). - pub(super) fn is_unbound(&self) -> bool { - self.inner.place.is_unbound() + pub(super) fn definitely_declared(ty: Type<'db>) -> Self { + Self { + inner: Place::declared(ty).into(), + } } - /// Returns the inner type, unless it is definitely unbound. - pub(super) fn ignore_possibly_unbound(&self) -> Option> { - self.inner.place.ignore_possibly_unbound() + /// Returns `true` if the inner place is undefined (i.e. there is no such member). + pub(super) fn is_undefined(&self) -> bool { + self.inner.place.is_undefined() + } + + /// Returns the inner type, unless it is definitely undefined. + pub(super) fn ignore_possibly_undefined(&self) -> Option> { + self.inner.place.ignore_possibly_undefined() } /// Map a type transformation function over the type of this member. @@ -70,7 +42,6 @@ impl<'db> Member<'db> { pub(super) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { Self { inner: self.inner.map_type(f), - is_declared: self.is_declared, } } } @@ -89,13 +60,15 @@ pub(super) fn class_member<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str ConsideredDefinitions::EndOfScope, ); - if !place_and_quals.place.is_unbound() && !place_and_quals.is_init_var() { + if !place_and_quals.is_undefined() && !place_and_quals.is_init_var() { // Trust the declared type if we see a class-level declaration - return Member::declared(place_and_quals); + return Member { + inner: place_and_quals, + }; } if let PlaceAndQualifiers { - place: Place::Type(ty, _), + place: Place::Defined(ty, _, _), qualifiers, } = place_and_quals { @@ -106,12 +79,14 @@ pub(super) fn class_member<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str // TODO: we should not need to calculate inferred type second time. This is a temporary // solution until the notion of Boundness and Declaredness is split. See #16036, #16264 - Member::inferred(match inferred { - Place::Unbound => Place::Unbound.with_qualifiers(qualifiers), - Place::Type(_, boundness) => { - Place::Type(ty, boundness).with_qualifiers(qualifiers) - } - }) + Member { + inner: match inferred { + Place::Undefined => Place::Undefined.with_qualifiers(qualifiers), + Place::Defined(_, origin, boundness) => { + Place::Defined(ty, origin, boundness).with_qualifiers(qualifiers) + } + }, + } } else { Member::unbound() } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index fdcb693508..eaa88bc793 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -9,7 +9,7 @@ use rustc_hash::FxHashMap; use crate::types::TypeContext; use crate::{ Db, FxOrderSet, - place::{Boundness, Place, PlaceAndQualifiers, place_from_bindings, place_from_declarations}, + place::{Definedness, Place, PlaceAndQualifiers, place_from_bindings, place_from_declarations}, semantic_index::{ SemanticIndex, definition::Definition, place::ScopedPlaceId, place_table, use_def_map, }, @@ -111,7 +111,7 @@ impl<'db> ProtocolClass<'db> { .into_place_and_conflicting_declarations() .0 .place - .is_unbound() + .is_undefined() }); if has_declaration { @@ -645,7 +645,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { ProtocolMemberKind::Method(method) => { // `__call__` members must be special cased for several reasons: // - // 1. Looking up `__call__` on the meta-type of a `Callable` type returns `Place::Unbound` currently + // 1. Looking up `__call__` on the meta-type of a `Callable` type returns `Place::Undefined` currently // 2. Looking up `__call__` on the meta-type of a function-literal type currently returns a type that // has an extremely vague signature (`(*args, **kwargs) -> Any`), which is not useful for protocol // checking. @@ -658,11 +658,11 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { }; attribute_type } else { - let Place::Type(attribute_type, Boundness::Bound) = other + let Place::Defined(attribute_type, _, Definedness::AlwaysDefined) = other .invoke_descriptor_protocol( db, self.name, - Place::Unbound.into(), + Place::Undefined.into(), InstanceFallbackShadowsNonDataDescriptor::No, MemberLookupPolicy::default(), ) @@ -685,10 +685,10 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { // TODO: consider the types of the attribute on `other` for property members ProtocolMemberKind::Property(_) => ConstraintSet::from(matches!( other.member(db, self.name).place, - Place::Type(_, Boundness::Bound) + Place::Defined(_, _, Definedness::AlwaysDefined) )), ProtocolMemberKind::Other(member_type) => { - let Place::Type(attribute_type, Boundness::Bound) = + let Place::Defined(attribute_type, _, Definedness::AlwaysDefined) = other.member(db, self.name).place else { return ConstraintSet::from(false); @@ -798,7 +798,7 @@ fn cached_protocol_interface<'db>( // type narrowing that uses `isinstance()` or `issubclass()` with // runtime-checkable protocols. for (symbol_id, bindings) in use_def_map.all_end_of_scope_symbol_bindings() { - let Some(ty) = place_from_bindings(db, bindings).ignore_possibly_unbound() else { + let Some(ty) = place_from_bindings(db, bindings).ignore_possibly_undefined() else { continue; }; direct_members.insert( @@ -809,7 +809,7 @@ fn cached_protocol_interface<'db>( for (symbol_id, declarations) in use_def_map.all_end_of_scope_symbol_declarations() { let place = place_from_declarations(db, declarations).ignore_conflicting_declarations(); - if let Some(new_type) = place.place.ignore_possibly_unbound() { + if let Some(new_type) = place.place.ignore_possibly_undefined() { direct_members .entry(symbol_id) .and_modify(|(ty, quals, _)| { From fd568f02213e83a6f4b531b8494812ed2917b75e Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 15 Oct 2025 19:30:03 +0100 Subject: [PATCH 057/113] [ty] Heterogeneous unpacking support for unions (#20377) --- .../resources/mdtest/call/function.md | 45 ++++ .../resources/mdtest/loops/for.md | 18 +- .../resources/mdtest/type_compendium/tuple.md | 4 + crates/ty_python_semantic/src/types.rs | 210 ++++++++++-------- .../ty_python_semantic/src/types/call/bind.rs | 18 +- crates/ty_python_semantic/src/types/tuple.rs | 53 +++++ .../ty_python_semantic/src/types/unpacker.rs | 5 + 7 files changed, 250 insertions(+), 103 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index 069558ad77..eca645c21d 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -694,6 +694,51 @@ def _( f1(*args10) # error: [invalid-argument-type] ``` +A union of heterogeneous tuples provided to a variadic parameter: + +```py +# Test inspired by ecosystem code at: +# - +# - + +def f2(a: str, b: bool): ... +def f3(coinflip: bool): + if coinflip: + args = "foo", True + else: + args = "bar", False + + # revealed: tuple[Literal["foo"], Literal[True]] | tuple[Literal["bar"], Literal[False]] + reveal_type(args) + f2(*args) # fine + + if coinflip: + other_args = "foo", True + else: + other_args = "bar", (True,) + + # revealed: tuple[Literal["foo"], Literal[True]] | tuple[Literal["bar"], tuple[Literal[True]]] + reveal_type(other_args) + # error: [invalid-argument-type] "Argument to function `f2` is incorrect: Expected `bool`, found `Literal[True] | tuple[Literal[True]]`" + f2(*other_args) + +def f4(a=None, b=None, c=None, d=None, e=None): ... + +my_args = ((1, 2), (3, 4), (5, 6)) + +for tup in my_args: + f4(*tup, e=None) # fine + +my_other_args = ( + (1, 2, 3, 4, 5), + (6, 7, 8, 9, 10), +) + +for tup in my_other_args: + # error: [parameter-already-assigned] "Multiple values provided for parameter `e` of function `f4`" + f4(*tup, e=None) +``` + ### Mixed argument and parameter containing variadic ```toml diff --git a/crates/ty_python_semantic/resources/mdtest/loops/for.md b/crates/ty_python_semantic/resources/mdtest/loops/for.md index 4066c8e2e3..cbec5378fc 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/for.md @@ -260,8 +260,22 @@ def g( reveal_type(x) # revealed: int | str for y in b: reveal_type(y) # revealed: str | int - for z in c: - reveal_type(z) # revealed: LiteralString | int +``` + +## Union type as iterable where some elements in the union have precise tuple specs + +If all elements in a union can be iterated over, we "union together" their "tuple specs" and are +able to infer the iterable element precisely when iterating over the union, in the same way that we +infer a precise type for the iterable element when iterating over a `Literal` string or bytes type: + +```py +from typing import Literal + +def f(x: Literal["foo", b"bar"], y: Literal["foo"] | range): + for item in x: + reveal_type(item) # revealed: Literal["f", "o", 98, 97, 114] + for item in y: + reveal_type(item) # revealed: Literal["f", "o"] | int ``` ## Union type as iterable where one union element has no `__iter__` method diff --git a/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md b/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md index 07bfa4066b..0142bfa4df 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/type_compendium/tuple.md @@ -68,6 +68,10 @@ reveal_type((1,).__class__()) # revealed: tuple[Literal[1]] # error: [missing-argument] "No argument provided for required parameter `iterable`" reveal_type((1, 2).__class__()) # revealed: tuple[Literal[1], Literal[2]] + +def g(x: tuple[int, str] | tuple[bytes, bool], y: tuple[int, str] | tuple[bytes, bool, bytes]): + reveal_type(tuple(x)) # revealed: tuple[int, str] | tuple[bytes, bool] + reveal_type(tuple(y)) # revealed: tuple[int, str] | tuple[bytes, bool, bytes] ``` ## Instantiating tuple subclasses diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index e603f66bc2..3cb6d43e5c 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -60,7 +60,7 @@ use crate::types::infer::infer_unpack_types; use crate::types::mro::{Mro, MroError, MroIterator}; pub(crate) use crate::types::narrow::infer_narrowing_constraint; use crate::types::signatures::{ParameterForm, walk_signature}; -use crate::types::tuple::TupleSpec; +use crate::types::tuple::{TupleSpec, TupleSpecBuilder}; pub(crate) use crate::types::typed_dict::{TypedDictParams, TypedDictType, walk_typed_dict_type}; use crate::types::variance::{TypeVarVariance, VarianceInferable}; use crate::types::visitor::any_over_type; @@ -5534,11 +5534,117 @@ impl<'db> Type<'db> { db: &'db dyn Db, mode: EvaluationMode, ) -> Result>, IterationError<'db>> { - // We will not infer precise heterogeneous tuple specs for literals with lengths above this threshold. - // The threshold here is somewhat arbitrary and conservative; it could be increased if needed. - // However, it's probably very rare to need heterogeneous unpacking inference for long string literals - // or bytes literals, and creating long heterogeneous tuple specs has a performance cost. - const MAX_TUPLE_LENGTH: usize = 128; + fn non_async_special_case<'db>( + db: &'db dyn Db, + ty: Type<'db>, + ) -> Option>> { + // We will not infer precise heterogeneous tuple specs for literals with lengths above this threshold. + // The threshold here is somewhat arbitrary and conservative; it could be increased if needed. + // However, it's probably very rare to need heterogeneous unpacking inference for long string literals + // or bytes literals, and creating long heterogeneous tuple specs has a performance cost. + const MAX_TUPLE_LENGTH: usize = 128; + + match ty { + Type::NominalInstance(nominal) => nominal.tuple_spec(db), + Type::GenericAlias(alias) if alias.origin(db).is_tuple(db) => { + Some(Cow::Owned(TupleSpec::homogeneous(todo_type!( + "*tuple[] annotations" + )))) + } + Type::StringLiteral(string_literal_ty) => { + let string_literal = string_literal_ty.value(db); + let spec = if string_literal.len() < MAX_TUPLE_LENGTH { + TupleSpec::heterogeneous( + string_literal + .chars() + .map(|c| Type::string_literal(db, &c.to_string())), + ) + } else { + TupleSpec::homogeneous(Type::LiteralString) + }; + Some(Cow::Owned(spec)) + } + Type::BytesLiteral(bytes) => { + let bytes_literal = bytes.value(db); + let spec = if bytes_literal.len() < MAX_TUPLE_LENGTH { + TupleSpec::heterogeneous( + bytes_literal + .iter() + .map(|b| Type::IntLiteral(i64::from(*b))), + ) + } else { + TupleSpec::homogeneous(KnownClass::Int.to_instance(db)) + }; + Some(Cow::Owned(spec)) + } + Type::Never => { + // The dunder logic below would have us return `tuple[Never, ...]`, which eagerly + // simplifies to `tuple[()]`. That will will cause us to emit false positives if we + // index into the tuple. Using `tuple[Unknown, ...]` avoids these false positives. + // TODO: Consider removing this special case, and instead hide the indexing + // diagnostic in unreachable code. + Some(Cow::Owned(TupleSpec::homogeneous(Type::unknown()))) + } + Type::TypeAlias(alias) => { + non_async_special_case(db, alias.value_type(db)) + } + Type::NonInferableTypeVar(tvar) => match tvar.typevar(db).bound_or_constraints(db)? { + TypeVarBoundOrConstraints::UpperBound(bound) => { + non_async_special_case(db, bound) + } + TypeVarBoundOrConstraints::Constraints(union) => non_async_special_case(db, Type::Union(union)), + }, + Type::TypeVar(_) => unreachable!( + "should not be able to iterate over type variable {} in inferable position", + ty.display(db) + ), + Type::Union(union) => { + let elements = union.elements(db); + if elements.len() < MAX_TUPLE_LENGTH { + let mut elements_iter = elements.iter(); + let first_element_spec = elements_iter.next()?.try_iterate_with_mode(db, EvaluationMode::Sync).ok()?; + let mut builder = TupleSpecBuilder::from(&*first_element_spec); + for element in elements_iter { + builder = builder.union(db, &*element.try_iterate_with_mode(db, EvaluationMode::Sync).ok()?); + } + Some(Cow::Owned(builder.build())) + } else { + None + } + } + // N.B. These special cases aren't strictly necessary, they're just obvious optimizations + Type::LiteralString | Type::Dynamic(_) => Some(Cow::Owned(TupleSpec::homogeneous(ty))), + + Type::FunctionLiteral(_) + | Type::GenericAlias(_) + | Type::BoundMethod(_) + | Type::KnownBoundMethod(_) + | Type::WrapperDescriptor(_) + | Type::DataclassDecorator(_) + | Type::DataclassTransformer(_) + | Type::Callable(_) + | Type::ModuleLiteral(_) + // We could infer a precise tuple spec for enum classes with members, + // but it's not clear whether that's worth the added complexity: + // you'd have to check that `EnumMeta.__iter__` is not overridden for it to be sound + // (enums can have `EnumMeta` subclasses as their metaclasses). + | Type::ClassLiteral(_) + | Type::SubclassOf(_) + | Type::ProtocolInstance(_) + | Type::SpecialForm(_) + | Type::KnownInstance(_) + | Type::PropertyInstance(_) + | Type::Intersection(_) + | Type::AlwaysTruthy + | Type::AlwaysFalsy + | Type::IntLiteral(_) + | Type::BooleanLiteral(_) + | Type::EnumLiteral(_) + | Type::BoundSuper(_) + | Type::TypeIs(_) + | Type::TypedDict(_) => None + } + } if mode.is_async() { let try_call_dunder_anext_on_iterator = |iterator: Type<'db>| -> Result< @@ -5605,97 +5711,7 @@ impl<'db> Type<'db> { }; } - let special_case = match self { - Type::NominalInstance(nominal) => nominal.tuple_spec(db), - Type::GenericAlias(alias) if alias.origin(db).is_tuple(db) => { - Some(Cow::Owned(TupleSpec::homogeneous(todo_type!( - "*tuple[] annotations" - )))) - } - Type::StringLiteral(string_literal_ty) => { - let string_literal = string_literal_ty.value(db); - let spec = if string_literal.len() < MAX_TUPLE_LENGTH { - TupleSpec::heterogeneous( - string_literal - .chars() - .map(|c| Type::string_literal(db, &c.to_string())), - ) - } else { - TupleSpec::homogeneous(Type::LiteralString) - }; - Some(Cow::Owned(spec)) - } - Type::BytesLiteral(bytes) => { - let bytes_literal = bytes.value(db); - let spec = if bytes_literal.len() < MAX_TUPLE_LENGTH { - TupleSpec::heterogeneous( - bytes_literal - .iter() - .map(|b| Type::IntLiteral(i64::from(*b))), - ) - } else { - TupleSpec::homogeneous(KnownClass::Int.to_instance(db)) - }; - Some(Cow::Owned(spec)) - } - Type::Never => { - // The dunder logic below would have us return `tuple[Never, ...]`, which eagerly - // simplifies to `tuple[()]`. That will will cause us to emit false positives if we - // index into the tuple. Using `tuple[Unknown, ...]` avoids these false positives. - // TODO: Consider removing this special case, and instead hide the indexing - // diagnostic in unreachable code. - Some(Cow::Owned(TupleSpec::homogeneous(Type::unknown()))) - } - Type::TypeAlias(alias) => { - Some(alias.value_type(db).try_iterate_with_mode(db, mode)?) - } - Type::NonInferableTypeVar(tvar) => match tvar.typevar(db).bound_or_constraints(db) { - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { - Some(bound.try_iterate_with_mode(db, mode)?) - } - // TODO: could we create a "union of tuple specs"...? - // (Same question applies to the `Type::Union()` branch lower down) - Some(TypeVarBoundOrConstraints::Constraints(_)) | None => None - }, - Type::TypeVar(_) => unreachable!( - "should not be able to iterate over type variable {} in inferable position", - self.display(db) - ), - // N.B. These special cases aren't strictly necessary, they're just obvious optimizations - Type::LiteralString | Type::Dynamic(_) => Some(Cow::Owned(TupleSpec::homogeneous(self))), - - Type::FunctionLiteral(_) - | Type::GenericAlias(_) - | Type::BoundMethod(_) - | Type::KnownBoundMethod(_) - | Type::WrapperDescriptor(_) - | Type::DataclassDecorator(_) - | Type::DataclassTransformer(_) - | Type::Callable(_) - | Type::ModuleLiteral(_) - // We could infer a precise tuple spec for enum classes with members, - // but it's not clear whether that's worth the added complexity: - // you'd have to check that `EnumMeta.__iter__` is not overridden for it to be sound - // (enums can have `EnumMeta` subclasses as their metaclasses). - | Type::ClassLiteral(_) - | Type::SubclassOf(_) - | Type::ProtocolInstance(_) - | Type::SpecialForm(_) - | Type::KnownInstance(_) - | Type::PropertyInstance(_) - | Type::Union(_) - | Type::Intersection(_) - | Type::AlwaysTruthy - | Type::AlwaysFalsy - | Type::IntLiteral(_) - | Type::BooleanLiteral(_) - | Type::EnumLiteral(_) - | Type::BoundSuper(_) - | Type::TypeIs(_) - | Type::TypedDict(_) => None - }; - - if let Some(special_case) = special_case { + if let Some(special_case) = non_async_special_case(db, self) { return Ok(special_case); } diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index e7b8758271..128d61e1d5 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -1106,10 +1106,14 @@ impl<'db> Bindings<'db> { // iterable (it could be a Liskov-uncompliant subtype of the `Iterable` class that sets // `__iter__ = None`, for example). That would be badly written Python code, but we still // need to be able to handle it without crashing. - overload.set_return_type(Type::tuple(TupleType::new( - db, - &argument.iterate(db), - ))); + let return_type = if let Type::Union(union) = argument { + union.map(db, |element| { + Type::tuple(TupleType::new(db, &element.iterate(db))) + }) + } else { + Type::tuple(TupleType::new(db, &argument.iterate(db))) + }; + overload.set_return_type(return_type); } } @@ -2309,6 +2313,12 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { argument: Argument<'a>, argument_type: Option>, ) -> Result<(), ()> { + // TODO: `Type::iterate` internally handles unions, but in a lossy way. + // It might be superior here to manually map over the union and call `try_iterate` + // on each element, similar to the way that `unpacker.rs` does in the `unpack_inner` method. + // It might be a bit of a refactor, though. + // See + // for more details. --Alex let tuple = argument_type.map(|ty| ty.iterate(db)); let (mut argument_types, length, variable_element) = match tuple.as_ref() { Some(tuple) => ( diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index e70c7708a2..dcb3df675d 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -1583,6 +1583,59 @@ impl<'db> TupleSpecBuilder<'db> { } } + fn all_elements(&self) -> impl Iterator> { + match self { + TupleSpecBuilder::Fixed(elements) => Either::Left(elements.iter()), + TupleSpecBuilder::Variable { + prefix, + variable, + suffix, + } => Either::Right(prefix.iter().chain(std::iter::once(variable)).chain(suffix)), + } + } + + /// Return a new tuple-spec builder that reflects the union of this tuple and another tuple. + /// + /// For example, if `self` is a tuple-spec builder for `tuple[Literal[42], str]` and `other` is a + /// tuple-spec for `tuple[Literal[56], str]`, the result will be a tuple-spec builder for + /// `tuple[Literal[42, 56], str]`. + /// + /// To keep things simple, we currently only attempt to preserve the "fixed-length-ness" of + /// a tuple spec if both `self` and `other` have the exact same length. For example, + /// if `self` is a tuple-spec builder for `tuple[int, str]` and `other` is a tuple-spec for + /// `tuple[int, str, bytes]`, the result will be a tuple-spec builder for + /// `tuple[int | str | bytes, ...]`. We could consider improving this in the future if real-world + /// use cases arise. + pub(crate) fn union(mut self, db: &'db dyn Db, other: &TupleSpec<'db>) -> Self { + match (&mut self, other) { + (TupleSpecBuilder::Fixed(our_elements), TupleSpec::Fixed(new_elements)) + if our_elements.len() == new_elements.len() => + { + for (existing, new) in our_elements.iter_mut().zip(new_elements.elements()) { + *existing = UnionType::from_elements(db, [*existing, *new]); + } + self + } + + // We *could* have a branch here where both `self` and `other` are mixed tuples + // with same-length prefixes and same-length suffixes. We *could* zip the two + // `prefix` vecs together, unioning each pair of elements to create a new `prefix` + // vec, and do the same for the `suffix` vecs. This would preserve the tuple specs + // of the union elements more closely. But it's hard to think of a test where this + // would actually lead to more precise inference, so it's probably not worth the + // complexity. + _ => { + let unioned = + UnionType::from_elements(db, self.all_elements().chain(other.all_elements())); + TupleSpecBuilder::Variable { + prefix: vec![], + variable: unioned, + suffix: vec![], + } + } + } + } + pub(super) fn build(self) -> TupleSpec<'db> { match self { TupleSpecBuilder::Fixed(elements) => { diff --git a/crates/ty_python_semantic/src/types/unpacker.rs b/crates/ty_python_semantic/src/types/unpacker.rs index b448053295..9af330d8e0 100644 --- a/crates/ty_python_semantic/src/types/unpacker.rs +++ b/crates/ty_python_semantic/src/types/unpacker.rs @@ -118,6 +118,11 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { }; let mut unpacker = TupleUnpacker::new(self.db(), target_len); + // N.B. `Type::try_iterate` internally handles unions, but in a lossy way. + // For our purposes here, we get better error messages and more precise inference + // if we manually map over the union and call `try_iterate` on each union element. + // See + // for more discussion. let unpack_types = match value_ty { Type::Union(union_ty) => union_ty.elements(self.db()), _ => std::slice::from_ref(&value_ty), From 73520e4acd9cb259f41a60b0b6396c66ea32950a Mon Sep 17 00:00:00 2001 From: Bhuminjay Soni Date: Thu, 16 Oct 2025 00:57:15 +0530 Subject: [PATCH 058/113] [syntax-errors]: implement F702 as semantic syntax error (#20869) ## Summary This PR implements `F702` https://docs.astral.sh/ruff/rules/continue-outside-loop/ as semantic syntax error. ## Test Plan Tests are already previously written in F702 --------- Signed-off-by: 11happy --- .../src/checkers/ast/analyze/statement.rs | 9 ------ crates/ruff_linter/src/checkers/ast/mod.rs | 6 ++++ .../pyflakes/rules/continue_outside_loop.rs | 30 +------------------ .../ruff_python_parser/src/semantic_errors.rs | 9 ++++++ 4 files changed, 16 insertions(+), 38 deletions(-) diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 89873737a6..cded9e44e6 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -50,15 +50,6 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { pylint::rules::nonlocal_and_global(checker, nonlocal); } } - Stmt::Continue(_) => { - if checker.is_rule_enabled(Rule::ContinueOutsideLoop) { - pyflakes::rules::continue_outside_loop( - checker, - stmt, - &mut checker.semantic.current_statements().skip(1), - ); - } - } Stmt::FunctionDef( function_def @ ast::StmtFunctionDef { is_async, diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index d6f5b81199..cdae2bb37d 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -711,6 +711,12 @@ impl SemanticSyntaxContext for Checker<'_> { self.report_diagnostic(pyflakes::rules::BreakOutsideLoop, error.range); } } + SemanticSyntaxErrorKind::ContinueOutsideLoop => { + // F702 + if self.is_rule_enabled(Rule::ContinueOutsideLoop) { + self.report_diagnostic(pyflakes::rules::ContinueOutsideLoop, error.range); + } + } SemanticSyntaxErrorKind::ReboundComprehensionVariable | SemanticSyntaxErrorKind::DuplicateTypeParameter | SemanticSyntaxErrorKind::MultipleCaseAssignment(_) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs b/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs index a6e426b9d5..334c64c2a7 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/continue_outside_loop.rs @@ -1,9 +1,6 @@ -use ruff_python_ast::{self as ast, Stmt}; - use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_text_size::Ranged; -use crate::{Violation, checkers::ast::Checker}; +use crate::Violation; /// ## What it does /// Checks for `continue` statements outside of loops. @@ -29,28 +26,3 @@ impl Violation for ContinueOutsideLoop { "`continue` not properly in loop".to_string() } } - -/// F702 -pub(crate) fn continue_outside_loop<'a>( - checker: &Checker, - stmt: &'a Stmt, - parents: &mut impl Iterator, -) { - let mut child = stmt; - for parent in parents { - match parent { - Stmt::For(ast::StmtFor { orelse, .. }) | Stmt::While(ast::StmtWhile { orelse, .. }) => { - if !orelse.contains(child) { - return; - } - } - Stmt::FunctionDef(_) | Stmt::ClassDef(_) => { - break; - } - _ => {} - } - child = parent; - } - - checker.report_diagnostic(ContinueOutsideLoop, stmt.range()); -} diff --git a/crates/ruff_python_parser/src/semantic_errors.rs b/crates/ruff_python_parser/src/semantic_errors.rs index 51949f8bb4..59c5b30b29 100644 --- a/crates/ruff_python_parser/src/semantic_errors.rs +++ b/crates/ruff_python_parser/src/semantic_errors.rs @@ -229,6 +229,11 @@ impl SemanticSyntaxChecker { Self::add_error(ctx, SemanticSyntaxErrorKind::BreakOutsideLoop, *range); } } + Stmt::Continue(ast::StmtContinue { range, .. }) => { + if !ctx.in_loop_context() { + Self::add_error(ctx, SemanticSyntaxErrorKind::ContinueOutsideLoop, *range); + } + } _ => {} } @@ -1131,6 +1136,7 @@ impl Display for SemanticSyntaxError { write!(f, "Future feature `{name}` is not defined") } SemanticSyntaxErrorKind::BreakOutsideLoop => f.write_str("`break` outside loop"), + SemanticSyntaxErrorKind::ContinueOutsideLoop => f.write_str("`continue` outside loop"), } } } @@ -1507,6 +1513,9 @@ pub enum SemanticSyntaxErrorKind { /// Represents the use of a `break` statement outside of a loop. BreakOutsideLoop, + + /// Represents the use of a `continue` statement outside of a loop. + ContinueOutsideLoop, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] From cb98933c5095ababb354759560dd319d9b4fd6d1 Mon Sep 17 00:00:00 2001 From: Emil Sadek Date: Thu, 16 Oct 2025 00:35:49 -0700 Subject: [PATCH 059/113] Move TOML indent size config (#20905) Co-authored-by: Emil Sadek --- .editorconfig | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index 23a805b49f..125f7f5de9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ indent_style = space insert_final_newline = true indent_size = 2 -[*.{rs,py,pyi}] +[*.{rs,py,pyi,toml}] indent_size = 4 [*.snap] @@ -18,6 +18,3 @@ trim_trailing_whitespace = false [*.md] max_line_length = 100 - -[*.toml] -indent_size = 4 \ No newline at end of file From fe4e3e2e7508598ea9dba947bb7eb8867de6e5e8 Mon Sep 17 00:00:00 2001 From: Justin Su Date: Thu, 16 Oct 2025 03:39:48 -0400 Subject: [PATCH 060/113] Update setup instructions for Zed 0.208.0+ (#20902) Co-authored-by: Micha Reiser --- docs/editors/setup.md | 111 +++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 67 deletions(-) diff --git a/docs/editors/setup.md b/docs/editors/setup.md index f50f3abbd3..3d81935465 100644 --- a/docs/editors/setup.md +++ b/docs/editors/setup.md @@ -464,59 +464,46 @@ under the [`lsp.ruff.initialization_options.settings`](https://zed.dev/docs/conf } ``` -!!! note - - Support for multiple formatters for a given language is only available in Zed version - `0.146.0` and later. - You can configure Ruff to format Python code on-save by registering the Ruff formatter and enabling the [`format_on_save`](https://zed.dev/docs/configuring-zed#format-on-save) setting: -=== "Zed 0.146.0+" - - ```json - { - "languages": { - "Python": { - "language_servers": ["ruff"], - "format_on_save": "on", - "formatter": [ - { - "language_server": { - "name": "ruff" - } - } - ] +```json +{ + "languages": { + "Python": { + "language_servers": ["ruff"], + "format_on_save": "on", + "formatter": [ + { + "language_server": { + "name": "ruff" + } } - } + ] } - ``` + } +} +``` You can configure Ruff to fix lint violations and/or organize imports on-save by enabling the `source.fixAll.ruff` and `source.organizeImports.ruff` code actions respectively: -=== "Zed 0.146.0+" - - ```json - { - "languages": { - "Python": { - "language_servers": ["ruff"], - "format_on_save": "on", - "formatter": [ - { - "code_actions": { - // Fix all auto-fixable lint violations - "source.fixAll.ruff": true, - // Organize imports - "source.organizeImports.ruff": true - } - } - ] - } - } +```json +{ + "languages": { + "Python": { + "language_servers": ["ruff"], + "format_on_save": "on", + "formatter": [ + // Fix all auto-fixable lint violations + { "code_action": "source.fixAll.ruff" }, + // Organize imports + { "code_action": "source.organizeImports.ruff" } + ] } - ``` + } +} +``` Taken together, you can configure Ruff to format, fix, and organize imports on-save via the following `settings.json`: @@ -528,28 +515,18 @@ following `settings.json`: ensure that the formatter takes care of any remaining style issues after the code actions have been applied. -=== "Zed 0.146.0+" - - ```json - { - "languages": { - "Python": { - "language_servers": ["ruff"], - "format_on_save": "on", - "formatter": [ - { - "code_actions": { - "source.organizeImports.ruff": true, - "source.fixAll.ruff": true - } - }, - { - "language_server": { - "name": "ruff" - } - } - ] - } - } +```json +{ + "languages": { + "Python": { + "language_servers": ["ruff"], + "format_on_save": "on", + "formatter": [ + { "code_action": "source.fixAll.ruff" }, + { "code_action": "source.organizeImports.ruff" }, + { "language_server": { "name": "ruff" } } + ] } - ``` + } +} +``` From c9dfb51f49a99ecc838dd21ee4ed564eec193740 Mon Sep 17 00:00:00 2001 From: Eric Mark Martin Date: Thu, 16 Oct 2025 03:50:32 -0400 Subject: [PATCH 061/113] [ty] Fix match pattern value narrowing to use equality semantics (#20882) ## Summary Resolves https://github.com/astral-sh/ty/issues/1349. Fix match statement value patterns to use equality comparison semantics instead of incorrectly narrowing to literal types directly. Value patterns use equality for matching, and equality can be overridden, so we can't always narrow to the matched literal. ## Test Plan Updated match.md with corrected expected types and an additional example with explanation --------- Co-authored-by: David Peter --- .../resources/mdtest/narrow/match.md | 176 +++++++++++------- crates/ty_python_semantic/src/types/narrow.rs | 76 +++++--- 2 files changed, 154 insertions(+), 98 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index b6b0ec90e8..55772eab24 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -71,98 +71,140 @@ reveal_type(x) # revealed: object ## Value patterns +Value patterns are evaluated by equality, which is overridable. Therefore successfully matching on +one can only give us information where we know how the subject type implements equality. + +Consider the following example. + ```py -def get_object() -> object: - return object() +from typing import Literal -x = get_object() +def _(x: Literal["foo"] | int): + match x: + case "foo": + reveal_type(x) # revealed: Literal["foo"] | int -reveal_type(x) # revealed: object + match x: + case "bar": + reveal_type(x) # revealed: int +``` -match x: - case "foo": - reveal_type(x) # revealed: Literal["foo"] - case 42: - reveal_type(x) # revealed: Literal[42] - case 6.0: - reveal_type(x) # revealed: float - case 1j: - reveal_type(x) # revealed: complex - case b"foo": - reveal_type(x) # revealed: Literal[b"foo"] +In the first `match`'s `case "foo"` all we know is `x == "foo"`. `x` could be an instance of an +arbitrary `int` subclass with an arbitrary `__eq__`, so we can't actually narrow to +`Literal["foo"]`. -reveal_type(x) # revealed: object +In the second `match`'s `case "bar"` we know `x == "bar"`. As discussed above, this isn't enough to +rule out `int`, but we know that `"foo" == "bar"` is false so we can eliminate `Literal["foo"]`. + +More examples follow. + +```py +from typing import Literal + +class C: + pass + +def _(x: Literal["foo", "bar", 42, b"foo"] | bool | complex): + match x: + case "foo": + reveal_type(x) # revealed: Literal["foo"] | int | float | complex + case 42: + reveal_type(x) # revealed: int | float | complex + case 6.0: + reveal_type(x) # revealed: Literal["bar", b"foo"] | (int & ~Literal[42]) | float | complex + case 1j: + reveal_type(x) # revealed: Literal["bar", b"foo"] | (int & ~Literal[42]) | float | complex + case b"foo": + reveal_type(x) # revealed: (int & ~Literal[42]) | Literal[b"foo"] | float | complex + case _: + reveal_type(x) # revealed: Literal["bar"] | (int & ~Literal[42]) | float | complex ``` ## Value patterns with guard ```py -def get_object() -> object: - return object() +from typing import Literal -x = get_object() +class C: + pass -reveal_type(x) # revealed: object - -match x: - case "foo" if reveal_type(x): # revealed: Literal["foo"] - pass - case 42 if reveal_type(x): # revealed: Literal[42] - pass - case 6.0 if reveal_type(x): # revealed: float - pass - case 1j if reveal_type(x): # revealed: complex - pass - case b"foo" if reveal_type(x): # revealed: Literal[b"foo"] - pass - -reveal_type(x) # revealed: object +def _(x: Literal["foo", b"bar"] | int): + match x: + case "foo" if reveal_type(x): # revealed: Literal["foo"] | int + pass + case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int + pass + case 42 if reveal_type(x): # revealed: int + pass ``` ## Or patterns ```py -def get_object() -> object: - return object() +from typing import Literal +from enum import Enum -x = get_object() +class Color(Enum): + RED = 1 + GREEN = 2 + BLUE = 3 -reveal_type(x) # revealed: object +def _(color: Color): + match color: + case Color.RED | Color.GREEN: + reveal_type(color) # revealed: Literal[Color.RED, Color.GREEN] + case Color.BLUE: + reveal_type(color) # revealed: Literal[Color.BLUE] -match x: - case "foo" | 42 | None: - reveal_type(x) # revealed: Literal["foo", 42] | None - case "foo" | tuple(): - reveal_type(x) # revealed: tuple[Unknown, ...] - case True | False: - reveal_type(x) # revealed: bool - case 3.14 | 2.718 | 1.414: - reveal_type(x) # revealed: float + match color: + case Color.RED | Color.GREEN | Color.BLUE: + reveal_type(color) # revealed: Color -reveal_type(x) # revealed: object + match color: + case Color.RED: + reveal_type(color) # revealed: Literal[Color.RED] + case _: + reveal_type(color) # revealed: Literal[Color.GREEN, Color.BLUE] + +class A: ... +class B: ... +class C: ... + +def _(x: A | B | C): + match x: + case A() | B(): + reveal_type(x) # revealed: A | B + case C(): + reveal_type(x) # revealed: C & ~A & ~B + case _: + reveal_type(x) # revealed: Never + + match x: + case A() | B() | C(): + reveal_type(x) # revealed: A | B | C + case _: + reveal_type(x) # revealed: Never + + match x: + case A(): + reveal_type(x) # revealed: A + case _: + reveal_type(x) # revealed: (B & ~A) | (C & ~A) ``` ## Or patterns with guard ```py -def get_object() -> object: - return object() +from typing import Literal -x = get_object() - -reveal_type(x) # revealed: object - -match x: - case "foo" | 42 | None if reveal_type(x): # revealed: Literal["foo", 42] | None - pass - case "foo" | tuple() if reveal_type(x): # revealed: Literal["foo"] | tuple[Unknown, ...] - pass - case True | False if reveal_type(x): # revealed: bool - pass - case 3.14 | 2.718 | 1.414 if reveal_type(x): # revealed: float - pass - -reveal_type(x) # revealed: object +def _(x: Literal["foo", b"bar"] | int): + match x: + case "foo" | 42 if reveal_type(x): # revealed: Literal["foo"] | int + pass + case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int + pass + case _ if reveal_type(x): # revealed: Literal["foo", b"bar"] | int + pass ``` ## Narrowing due to guard @@ -179,7 +221,7 @@ match x: case str() | float() if type(x) is str: reveal_type(x) # revealed: str case "foo" | 42 | None if isinstance(x, int): - reveal_type(x) # revealed: Literal[42] + reveal_type(x) # revealed: int case False if x: reveal_type(x) # revealed: Never case "foo" if x := "bar": @@ -201,7 +243,7 @@ reveal_type(x) # revealed: object match x: case str() | float() if type(x) is str and reveal_type(x): # revealed: str pass - case "foo" | 42 | None if isinstance(x, int) and reveal_type(x): # revealed: Literal[42] + case "foo" | 42 | None if isinstance(x, int) and reveal_type(x): # revealed: int pass case False if x and reveal_type(x): # revealed: Never pass diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 20d505bcab..f725f32220 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -263,19 +263,19 @@ type NarrowingConstraints<'db> = FxHashMap>; fn merge_constraints_and<'db>( into: &mut NarrowingConstraints<'db>, - from: NarrowingConstraints<'db>, + from: &NarrowingConstraints<'db>, db: &'db dyn Db, ) { for (key, value) in from { - match into.entry(key) { + match into.entry(*key) { Entry::Occupied(mut entry) => { *entry.get_mut() = IntersectionBuilder::new(db) .add_positive(*entry.get()) - .add_positive(value) + .add_positive(*value) .build(); } Entry::Vacant(entry) => { - entry.insert(value); + entry.insert(*value); } } } @@ -303,12 +303,6 @@ fn merge_constraints_or<'db>( } } -fn negate_if<'db>(constraints: &mut NarrowingConstraints<'db>, db: &'db dyn Db, yes: bool) { - for (_place, ty) in constraints.iter_mut() { - *ty = ty.negate_if(db, yes); - } -} - fn place_expr(expr: &ast::Expr) -> Option { match expr { ast::Expr::Named(named) => PlaceExpr::try_from_expr(named.target.as_ref()), @@ -399,12 +393,14 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ) -> Option> { match pattern_predicate_kind { PatternPredicateKind::Singleton(singleton) => { - self.evaluate_match_pattern_singleton(subject, *singleton) + self.evaluate_match_pattern_singleton(subject, *singleton, is_positive) } PatternPredicateKind::Class(cls, kind) => { self.evaluate_match_pattern_class(subject, *cls, *kind, is_positive) } - PatternPredicateKind::Value(expr) => self.evaluate_match_pattern_value(subject, *expr), + PatternPredicateKind::Value(expr) => { + self.evaluate_match_pattern_value(subject, *expr, is_positive) + } PatternPredicateKind::Or(predicates) => { self.evaluate_match_pattern_or(subject, predicates, is_positive) } @@ -420,12 +416,11 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { pattern: PatternPredicate<'db>, is_positive: bool, ) -> Option> { - let subject = pattern.subject(self.db); - self.evaluate_pattern_predicate_kind(pattern.kind(self.db), subject, is_positive) - .map(|mut constraints| { - negate_if(&mut constraints, self.db, !is_positive); - constraints - }) + self.evaluate_pattern_predicate_kind( + pattern.kind(self.db), + pattern.subject(self.db), + is_positive, + ) } fn places(&self) -> &'db PlaceTable { @@ -709,7 +704,10 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { lhs_ty: Type<'db>, rhs_ty: Type<'db>, op: ast::CmpOp, + is_positive: bool, ) -> Option> { + let op = if is_positive { op } else { op.negate() }; + match op { ast::CmpOp::IsNot => { if rhs_ty.is_singleton(self.db) { @@ -792,13 +790,12 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) | ast::Expr::Named(_) => { - if let Some(left) = place_expr(left) { - let op = if is_positive { *op } else { op.negate() }; - - if let Some(ty) = self.evaluate_expr_compare_op(lhs_ty, rhs_ty, op) { - let place = self.expect_place(&left); - constraints.insert(place, ty); - } + if let Some(left) = place_expr(left) + && let Some(ty) = + self.evaluate_expr_compare_op(lhs_ty, rhs_ty, *op, is_positive) + { + let place = self.expect_place(&left); + constraints.insert(place, ty); } } ast::Expr::Call(ast::ExprCall { @@ -954,6 +951,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, singleton: ast::Singleton, + is_positive: bool, ) -> Option> { let subject = place_expr(subject.node_ref(self.db, self.module))?; let place = self.expect_place(&subject); @@ -963,6 +961,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ast::Singleton::True => Type::BooleanLiteral(true), ast::Singleton::False => Type::BooleanLiteral(false), }; + let ty = ty.negate_if(self.db, !is_positive); Some(NarrowingConstraints::from_iter([(place, ty)])) } @@ -986,6 +985,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let ty = infer_same_file_expression_type(self.db, cls, TypeContext::default(), self.module) .to_instance(self.db)?; + let ty = ty.negate_if(self.db, !is_positive); Some(NarrowingConstraints::from_iter([(place, ty)])) } @@ -993,13 +993,20 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, value: Expression<'db>, + is_positive: bool, ) -> Option> { - let subject = place_expr(subject.node_ref(self.db, self.module))?; - let place = self.expect_place(&subject); + let place = { + let subject = place_expr(subject.node_ref(self.db, self.module))?; + self.expect_place(&subject) + }; + let subject_ty = + infer_same_file_expression_type(self.db, subject, TypeContext::default(), self.module); - let ty = + let value_ty = infer_same_file_expression_type(self.db, value, TypeContext::default(), self.module); - Some(NarrowingConstraints::from_iter([(place, ty)])) + + self.evaluate_expr_compare_op(subject_ty, value_ty, ast::CmpOp::Eq, is_positive) + .map(|ty| NarrowingConstraints::from_iter([(place, ty)])) } fn evaluate_match_pattern_or( @@ -1010,13 +1017,20 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ) -> Option> { let db = self.db; + // DeMorgan's law---if the overall `or` is negated, we need to `and` the negated sub-constraints. + let merge_constraints = if is_positive { + merge_constraints_or + } else { + merge_constraints_and + }; + predicates .iter() .filter_map(|predicate| { self.evaluate_pattern_predicate_kind(predicate, subject, is_positive) }) .reduce(|mut constraints, constraints_| { - merge_constraints_or(&mut constraints, &constraints_, db); + merge_constraints(&mut constraints, &constraints_, db); constraints }) } @@ -1048,7 +1062,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let mut aggregation: Option = None; for sub_constraint in sub_constraints.into_iter().flatten() { if let Some(ref mut some_aggregation) = aggregation { - merge_constraints_and(some_aggregation, sub_constraint, self.db); + merge_constraints_and(some_aggregation, &sub_constraint, self.db); } else { aggregation = Some(sub_constraint); } From 0cc663efcd0ff2bc81f34ff3a658840f01f2a697 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 16 Oct 2025 12:49:24 +0200 Subject: [PATCH 062/113] [ty] Do not assume that `field`s have a default value (#20914) ## Summary fixes https://github.com/astral-sh/ty/issues/1366 ## Test Plan Added regression test --- .../mdtest/dataclasses/dataclasses.md | 2 ++ .../resources/mdtest/dataclasses/fields.md | 2 +- crates/ty_python_semantic/src/types.rs | 35 +++++++++++-------- .../ty_python_semantic/src/types/call/bind.rs | 7 ++-- crates/ty_python_semantic/src/types/class.rs | 2 +- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 7eb2ff9d67..34899b10fc 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -497,6 +497,8 @@ class A: a: str = field(kw_only=False) b: int = 0 +reveal_type(A.__init__) # revealed: (self: A, a: str, *, b: int = Literal[0]) -> None + A("hi") ``` diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md index 592190f942..d1ac6dc0be 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md @@ -108,7 +108,7 @@ class A: name: str = field(init=False) # field(init=False) should be ignored for dataclass_transform without explicit field_specifiers -reveal_type(A.__init__) # revealed: (self: A, name: str = Unknown) -> None +reveal_type(A.__init__) # revealed: (self: A, name: str) -> None @dataclass class B: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 3cb6d43e5c..7f0edaa7b6 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1636,14 +1636,16 @@ impl<'db> Type<'db> { (Type::KnownInstance(KnownInstanceType::Field(field)), right) if relation.is_assignability() => { - field.default_type(db).has_relation_to_impl( - db, - right, - inferable, - relation, - relation_visitor, - disjointness_visitor, - ) + field.default_type(db).when_none_or(|default_type| { + default_type.has_relation_to_impl( + db, + right, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) } // Dynamic is only a subtype of `object` and only a supertype of `Never`; both were @@ -7499,7 +7501,9 @@ fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( // Nothing to visit } KnownInstanceType::Field(field) => { - visitor.visit_type(db, field.default_type(db)); + if let Some(default_ty) = field.default_type(db) { + visitor.visit_type(db, default_ty); + } } } } @@ -7599,9 +7603,11 @@ impl<'db> KnownInstanceType<'db> { KnownInstanceType::TypeVar(_) => f.write_str("typing.TypeVar"), KnownInstanceType::Deprecated(_) => f.write_str("warnings.deprecated"), KnownInstanceType::Field(field) => { - f.write_str("dataclasses.Field[")?; - field.default_type(self.db).display(self.db).fmt(f)?; - f.write_str("]") + f.write_str("dataclasses.Field")?; + if let Some(default_ty) = field.default_type(self.db) { + write!(f, "[{}]", default_ty.display(self.db))?; + } + Ok(()) } KnownInstanceType::ConstraintSet(tracked_set) => { let constraints = tracked_set.constraints(self.db); @@ -7988,7 +7994,7 @@ impl get_size2::GetSize for DeprecatedInstance<'_> {} pub struct FieldInstance<'db> { /// The type of the default value for this field. This is derived from the `default` or /// `default_factory` arguments to `dataclasses.field()`. - pub default_type: Type<'db>, + pub default_type: Option>, /// Whether this field is part of the `__init__` signature, or not. pub init: bool, @@ -8004,7 +8010,8 @@ impl<'db> FieldInstance<'db> { pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { FieldInstance::new( db, - self.default_type(db).normalized_impl(db, visitor), + self.default_type(db) + .map(|ty| ty.normalized_impl(db, visitor)), self.init(db), self.kw_only(db), ) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 128d61e1d5..abaa77cb49 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -978,11 +978,12 @@ impl<'db> Bindings<'db> { overload.parameter_type_by_name("kw_only").unwrap_or(None); let default_ty = match (default, default_factory) { - (Some(default_ty), _) => default_ty, + (Some(default_ty), _) => Some(default_ty), (_, Some(default_factory_ty)) => default_factory_ty .try_call(db, &CallArguments::none()) - .map_or(Type::unknown(), |binding| binding.return_type(db)), - _ => Type::unknown(), + .ok() + .map(|binding| binding.return_type(db)), + _ => None, }; let init = init diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 64ba611a46..da30aa3144 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2898,7 +2898,7 @@ impl<'db> ClassLiteral<'db> { let mut init = true; let mut kw_only = None; if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty { - default_ty = Some(field.default_type(db)); + default_ty = field.default_type(db); if self .dataclass_params(db) .map(|params| params.contains(DataclassParams::NO_FIELD_SPECIFIERS)) From c8133104e87305c0a25f11383986e162d10cf55f Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 16 Oct 2025 13:13:45 +0200 Subject: [PATCH 063/113] [ty] Use field-specifier return type as the default type for the field (#20915) ## Summary `dataclasses.field` and field-specifier functions of commonly used libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return the default type for the field (or `Any`) instead of an actual `Field` instance, even if this is not what happens at runtime. Let's make use of this fact and assume that *all* field specifiers return the type of the default value of the field. For standard dataclasses, this leads to more or less the same outcome (see test diff for details), but this change is important for 3rd party dataclass-transformers. ## Test Plan Tested the consequences of this change on the field-specifiers branch as well. --- .../resources/mdtest/dataclasses/fields.md | 10 ++++++---- .../ty_python_semantic/src/types/call/bind.rs | 17 ++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md index d1ac6dc0be..7b6a4369cc 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md @@ -11,7 +11,7 @@ class Member: role: str = field(default="user") tag: str | None = field(default=None, init=False) -# revealed: (self: Member, name: str, role: str = Literal["user"]) -> None +# revealed: (self: Member, name: str, role: str = str) -> None reveal_type(Member.__init__) alice = Member(name="Alice", role="admin") @@ -37,7 +37,7 @@ class Data: content: list[int] = field(default_factory=list) timestamp: datetime = field(default_factory=datetime.now, init=False) -# revealed: (self: Data, content: list[int] = list[Unknown]) -> None +# revealed: (self: Data, content: list[int] = list[int]) -> None reveal_type(Data.__init__) data = Data([1, 2, 3]) @@ -63,7 +63,8 @@ class Person: age: int | None = field(default=None, kw_only=True) role: str = field(default="user", kw_only=True) -# revealed: (self: Person, name: str, *, age: int | None = None, role: str = Literal["user"]) -> None +# TODO: this would ideally show a default value of `None` for `age` +# revealed: (self: Person, name: str, *, age: int | None = int | None, role: str = str) -> None reveal_type(Person.__init__) alice = Person(role="admin", name="Alice") @@ -82,7 +83,8 @@ def get_default() -> str: reveal_type(field(default=1)) # revealed: dataclasses.Field[Literal[1]] reveal_type(field(default=None)) # revealed: dataclasses.Field[None] -reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[str] +# TODO: this could ideally be `dataclasses.Field[str]` with a better generics solver +reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[Unknown] ``` ## dataclass_transform field_specifiers diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index abaa77cb49..bcac0c636c 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -977,13 +977,16 @@ impl<'db> Bindings<'db> { let kw_only = overload.parameter_type_by_name("kw_only").unwrap_or(None); - let default_ty = match (default, default_factory) { - (Some(default_ty), _) => Some(default_ty), - (_, Some(default_factory_ty)) => default_factory_ty - .try_call(db, &CallArguments::none()) - .ok() - .map(|binding| binding.return_type(db)), - _ => None, + // `dataclasses.field` and field-specifier functions of commonly used + // libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return + // the default type for the field (or `Any`) instead of an actual `Field` + // instance, even if this is not what happens at runtime (see also below). + // We still make use of this fact and pretend that all field specifiers + // return the type of the default value: + let default_ty = if default.is_some() || default_factory.is_some() { + Some(overload.return_ty) + } else { + None }; let init = init From 9393279f65f13a416e565056043317d61ba7b42f Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Oct 2025 13:18:09 +0200 Subject: [PATCH 064/113] [ty] Limit shown import paths to at most 5 unless ty runs with `-v` (#20912) --- crates/ruff_graph/src/db.rs | 4 + crates/ty/src/lib.rs | 4 +- crates/ty/tests/cli/main.rs | 2 + crates/ty/tests/cli/python_environment.rs | 88 +++++++++++++++++++ crates/ty_project/src/db.rs | 8 ++ crates/ty_project/src/lib.rs | 13 +++ crates/ty_python_semantic/src/db.rs | 7 ++ .../src/types/infer/builder.rs | 29 +++--- crates/ty_python_semantic/tests/corpus.rs | 4 + crates/ty_test/src/db.rs | 4 + fuzz/fuzz_targets/ty_check_invalid_syntax.rs | 4 + 11 files changed, 155 insertions(+), 12 deletions(-) diff --git a/crates/ruff_graph/src/db.rs b/crates/ruff_graph/src/db.rs index cbca766de6..6c5a6e8121 100644 --- a/crates/ruff_graph/src/db.rs +++ b/crates/ruff_graph/src/db.rs @@ -98,6 +98,10 @@ impl Db for ModuleDb { fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } + + fn verbose(&self) -> bool { + false + } } #[salsa::db] diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 6fb1b05232..e1bb4e1f10 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -14,7 +14,7 @@ use anyhow::Result; use std::sync::Mutex; use crate::args::{CheckCommand, Command, TerminalColor}; -use crate::logging::setup_tracing; +use crate::logging::{VerbosityLevel, setup_tracing}; use crate::printer::Printer; use anyhow::{Context, anyhow}; use clap::{CommandFactory, Parser}; @@ -128,6 +128,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result { let mut db = ProjectDatabase::new(project_metadata, system)?; + db.project() + .set_verbose(&mut db, verbosity >= VerbosityLevel::Verbose); if !check_paths.is_empty() { db.project().set_included_paths(&mut db, check_paths); } diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 5270c1212c..45d7304be5 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -782,6 +782,8 @@ impl CliTest { let mut settings = insta::Settings::clone_current(); settings.add_filter(&tempdir_filter(&project_dir), "/"); settings.add_filter(r#"\\(\w\w|\s|\.|")"#, "/$1"); + // 0.003s + settings.add_filter(r"\d.\d\d\ds", "0.000s"); settings.add_filter( r#"The system cannot find the file specified."#, "No such file or directory", diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index a8a9f0038d..0a200c3c26 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -421,6 +421,94 @@ fn lib64_site_packages_directory_on_unix() -> anyhow::Result<()> { Ok(()) } +#[test] +fn many_search_paths() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("extra1/foo1.py", ""), + ("extra2/foo2.py", ""), + ("extra3/foo3.py", ""), + ("extra4/foo4.py", ""), + ("extra5/foo5.py", ""), + ("extra6/foo6.py", ""), + ("test.py", "import foo1, baz"), + ])?; + + assert_cmd_snapshot!( + case.command() + .arg("--python-platform").arg("linux") + .arg("--extra-search-path").arg("extra1") + .arg("--extra-search-path").arg("extra2") + .arg("--extra-search-path").arg("extra3") + .arg("--extra-search-path").arg("extra4") + .arg("--extra-search-path").arg("extra5") + .arg("--extra-search-path").arg("extra6"), + @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `baz` + --> test.py:1:14 + | + 1 | import foo1, baz + | ^^^ + | + info: Searched in the following paths during module resolution: + info: 1. /extra1 (extra search path specified on the CLI or in your config file) + info: 2. /extra2 (extra search path specified on the CLI or in your config file) + info: 3. /extra3 (extra search path specified on the CLI or in your config file) + info: 4. /extra4 (extra search path specified on the CLI or in your config file) + info: 5. /extra5 (extra search path specified on the CLI or in your config file) + info: ... and 3 more paths. Run with `-v` to see all paths. + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + "); + + // Shows all with `-v` + assert_cmd_snapshot!( + case.command() + .arg("--python-platform").arg("linux") + .arg("--extra-search-path").arg("extra1") + .arg("--extra-search-path").arg("extra2") + .arg("--extra-search-path").arg("extra3") + .arg("--extra-search-path").arg("extra4") + .arg("--extra-search-path").arg("extra5") + .arg("--extra-search-path").arg("extra6") + .arg("-v"), + @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `baz` + --> test.py:1:14 + | + 1 | import foo1, baz + | ^^^ + | + info: Searched in the following paths during module resolution: + info: 1. /extra1 (extra search path specified on the CLI or in your config file) + info: 2. /extra2 (extra search path specified on the CLI or in your config file) + info: 3. /extra3 (extra search path specified on the CLI or in your config file) + info: 4. /extra4 (extra search path specified on the CLI or in your config file) + info: 5. /extra5 (extra search path specified on the CLI or in your config file) + info: 6. /extra6 (extra search path specified on the CLI or in your config file) + info: 7. / (first-party code) + info: 8. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + INFO Python version: Python 3.14, platform: linux + INFO Indexed 7 file(s) in 0.000s + "); + Ok(()) +} + #[test] fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Result<()> { let case = CliTest::with_files([ diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index a312586fb6..5e2105839e 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -457,6 +457,10 @@ impl SemanticDb for ProjectDatabase { fn lint_registry(&self) -> &LintRegistry { ty_python_semantic::default_lint_registry() } + + fn verbose(&self) -> bool { + self.project().verbose(self) + } } #[salsa::db] @@ -609,6 +613,10 @@ pub(crate) mod tests { fn lint_registry(&self) -> &LintRegistry { ty_python_semantic::default_lint_registry() } + + fn verbose(&self) -> bool { + false + } } #[salsa::db] diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index f5ef2201da..d47476c7dc 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -113,6 +113,9 @@ pub struct Project { /// the project including the virtual files that might exists in the editor. #[default] check_mode: CheckMode, + + #[default] + verbose_flag: bool, } /// A progress reporter. @@ -368,6 +371,16 @@ impl Project { self.reload_files(db); } + pub fn set_verbose(self, db: &mut dyn Db, verbose: bool) { + if self.verbose_flag(db) != verbose { + self.set_verbose_flag(db).to(verbose); + } + } + + pub fn verbose(self, db: &dyn Db) -> bool { + self.verbose_flag(db) + } + /// Returns the paths that should be checked. /// /// The default is to check the entire project in which case this method returns diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index 645929235a..d03b001199 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -12,6 +12,9 @@ pub trait Db: SourceDb { fn rule_selection(&self, file: File) -> &RuleSelection; fn lint_registry(&self) -> &LintRegistry; + + /// Whether ty is running with logging verbosity INFO or higher (`-v` or more). + fn verbose(&self) -> bool; } #[cfg(test)] @@ -126,6 +129,10 @@ pub(crate) mod tests { fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } + + fn verbose(&self) -> bool { + false + } } #[salsa::db] diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index be92acc9ab..7155e1f7a1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4811,22 +4811,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Add search paths information to the diagnostic // Use the same search paths function that is used in actual module resolution - let mut search_paths = - search_paths(self.db(), ModuleResolveMode::StubsAllowed).peekable(); + let verbose = self.db().verbose(); + let search_paths = search_paths(self.db(), ModuleResolveMode::StubsAllowed); - if search_paths.peek().is_some() { - diagnostic.info(format_args!( - "Searched in the following paths during module resolution:" - )); + diagnostic.info(format_args!( + "Searched in the following paths during module resolution:" + )); - for (index, path) in search_paths.enumerate() { + let mut search_paths = search_paths.enumerate(); + + while let Some((index, path)) = search_paths.next() { + if index > 4 && !verbose { + let more = search_paths.count() + 1; diagnostic.info(format_args!( - " {}. {} ({})", - index + 1, - path, - path.describe_kind() + " ... and {more} more paths. Run with `-v` to see all paths." )); + break; } + diagnostic.info(format_args!( + " {}. {} ({})", + index + 1, + path, + path.describe_kind() + )); } diagnostic.info( diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index 83ad7ae1ff..db2b1f3342 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -255,6 +255,10 @@ impl ty_python_semantic::Db for CorpusDb { fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } + + fn verbose(&self) -> bool { + false + } } #[salsa::db] diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 100b26b5fb..cb05fa0bd8 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -88,6 +88,10 @@ impl SemanticDb for Db { fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } + + fn verbose(&self) -> bool { + false + } } #[salsa::db] diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index 9a6c6eb1f6..8bf19e88f7 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -93,6 +93,10 @@ impl SemanticDb for TestDb { fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } + + fn verbose(&self) -> bool { + false + } } #[salsa::db] From 5fb142374daec49d53bcc00126bf485e82c015a6 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Oct 2025 13:24:41 +0200 Subject: [PATCH 065/113] Fix run-away for mutually referential instance attributes (#20645) --- Cargo.lock | 12 ++--- Cargo.toml | 2 +- .../resources/mdtest/attributes.md | 49 +++++++++++++++++++ .../resources/primer/bad.txt | 4 +- fuzz/Cargo.toml | 2 +- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df05a29f95..c056606c29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3540,8 +3540,8 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "salsa" -version = "0.23.0" -source = "git+https://github.com/salsa-rs/salsa.git?rev=29ab321b45d00daa4315fa2a06f7207759a8c87e#29ab321b45d00daa4315fa2a06f7207759a8c87e" +version = "0.24.0" +source = "git+https://github.com/salsa-rs/salsa.git?rev=ef9f9329be6923acd050c8dddd172e3bc93e8051#ef9f9329be6923acd050c8dddd172e3bc93e8051" dependencies = [ "boxcar", "compact_str", @@ -3564,13 +3564,13 @@ dependencies = [ [[package]] name = "salsa-macro-rules" -version = "0.23.0" -source = "git+https://github.com/salsa-rs/salsa.git?rev=29ab321b45d00daa4315fa2a06f7207759a8c87e#29ab321b45d00daa4315fa2a06f7207759a8c87e" +version = "0.24.0" +source = "git+https://github.com/salsa-rs/salsa.git?rev=ef9f9329be6923acd050c8dddd172e3bc93e8051#ef9f9329be6923acd050c8dddd172e3bc93e8051" [[package]] name = "salsa-macros" -version = "0.23.0" -source = "git+https://github.com/salsa-rs/salsa.git?rev=29ab321b45d00daa4315fa2a06f7207759a8c87e#29ab321b45d00daa4315fa2a06f7207759a8c87e" +version = "0.24.0" +source = "git+https://github.com/salsa-rs/salsa.git?rev=ef9f9329be6923acd050c8dddd172e3bc93e8051#ef9f9329be6923acd050c8dddd172e3bc93e8051" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 40afa8c5a5..ad61751a7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,7 +146,7 @@ regex-automata = { version = "0.4.9" } rustc-hash = { version = "2.0.0" } rustc-stable-hash = { version = "0.1.2" } # When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml` -salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "29ab321b45d00daa4315fa2a06f7207759a8c87e", default-features = false, features = [ +salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "ef9f9329be6923acd050c8dddd172e3bc93e8051", default-features = false, features = [ "compact_str", "macros", "salsa_unstable", diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 8ae31e9265..496f796c7a 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2363,6 +2363,55 @@ reveal_type(B().x) # revealed: Unknown | Literal[1] reveal_type(A().x) # revealed: Unknown | Literal[1] ``` +And cycles between many attributes: + +```py +class ManyCycles: + def __init__(self: "ManyCycles"): + self.x1 = 0 + self.x2 = 0 + self.x3 = 0 + self.x4 = 0 + self.x5 = 0 + self.x6 = 0 + self.x7 = 1 + + def f1(self: "ManyCycles"): + self.x1 = self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7 + self.x2 = self.x1 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7 + self.x3 = self.x1 + self.x2 + self.x4 + self.x5 + self.x6 + self.x7 + self.x4 = self.x1 + self.x2 + self.x3 + self.x5 + self.x6 + self.x7 + self.x5 = self.x1 + self.x2 + self.x3 + self.x4 + self.x6 + self.x7 + self.x6 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x7 + self.x7 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + + def f2(self: "ManyCycles"): + self.x1 = self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7 + self.x2 = self.x1 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7 + self.x3 = self.x1 + self.x2 + self.x4 + self.x5 + self.x6 + self.x7 + self.x4 = self.x1 + self.x2 + self.x3 + self.x5 + self.x6 + self.x7 + self.x5 = self.x1 + self.x2 + self.x3 + self.x4 + self.x6 + self.x7 + self.x6 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x7 + self.x7 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + + def f3(self: "ManyCycles"): + self.x1 = self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7 + self.x2 = self.x1 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7 + self.x3 = self.x1 + self.x2 + self.x4 + self.x5 + self.x6 + self.x7 + self.x4 = self.x1 + self.x2 + self.x3 + self.x5 + self.x6 + self.x7 + self.x5 = self.x1 + self.x2 + self.x3 + self.x4 + self.x6 + self.x7 + self.x6 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x7 + self.x7 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + + reveal_type(self.x1) # revealed: Unknown | int + reveal_type(self.x2) # revealed: Unknown | int + reveal_type(self.x3) # revealed: Unknown | int + reveal_type(self.x4) # revealed: Unknown | int + reveal_type(self.x5) # revealed: Unknown | int + reveal_type(self.x6) # revealed: Unknown | int + reveal_type(self.x7) # revealed: Unknown | int +``` + This case additionally tests our union/intersection simplification logic: ```py diff --git a/crates/ty_python_semantic/resources/primer/bad.txt b/crates/ty_python_semantic/resources/primer/bad.txt index bfff92bd22..1a4f3eed3e 100644 --- a/crates/ty_python_semantic/resources/primer/bad.txt +++ b/crates/ty_python_semantic/resources/primer/bad.txt @@ -1,2 +1,2 @@ -spark # too many iterations (in `exported_names` query) -steam.py # hangs (single threaded) +spark # too many iterations (in `exported_names` query), `should not be able to access instance member `spark` of type variable IndexOpsLike@astype in inferable position` +steam.py # dependency graph cycle when querying TypeVarInstance < 'db >::lazy_default_(Id(2e007)), set cycle_fn/cycle_initial to fixpoint iterate. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ec34fdd71e..016c7ea6b3 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -30,7 +30,7 @@ ty_python_semantic = { path = "../crates/ty_python_semantic" } ty_vendored = { path = "../crates/ty_vendored" } libfuzzer-sys = { git = "https://github.com/rust-fuzz/libfuzzer", default-features = false } -salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "29ab321b45d00daa4315fa2a06f7207759a8c87e", default-features = false, features = [ +salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "ef9f9329be6923acd050c8dddd172e3bc93e8051", default-features = false, features = [ "compact_str", "macros", "salsa_unstable", From d23826ce4676811f74379af87b8178c2ab71a805 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 16 Oct 2025 04:46:56 -0700 Subject: [PATCH 066/113] [ty] cache Type::is_redundant_with (#20477) Co-authored-by: Micha Reiser Co-authored-by: Alex Waygood --- .../resources/mdtest/pep695_type_aliases.md | 11 ++++++++++ crates/ty_python_semantic/src/types.rs | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md index b6c712f94b..a0f1c3fd8b 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md @@ -360,3 +360,14 @@ type X = tuple[X, int] def _(x: X): reveal_type(x is x) # revealed: bool ``` + +### Recursive invariant + +```py +type X = dict[str, X] +type Y = X | str | dict[str, Y] + +def _(y: Y): + if isinstance(y, dict): + reveal_type(y) # revealed: dict[str, X] | dict[str, Y] +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 7f0edaa7b6..97e86737f8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1543,6 +1543,7 @@ impl<'db> Type<'db> { /// Return `true` if it would be redundant to add `self` to a union that already contains `other`. /// /// See [`TypeRelation::Redundancy`] for more details. + #[salsa::tracked(cycle_fn=is_redundant_with_cycle_recover, cycle_initial=is_redundant_with_cycle_initial, heap_size=ruff_memory_usage::heap_size)] pub(crate) fn is_redundant_with(self, db: &'db dyn Db, other: Type<'db>) -> bool { self.has_relation_to(db, other, InferableTypeVars::None, TypeRelation::Redundancy) .is_always_satisfied() @@ -7326,6 +7327,25 @@ impl<'db> VarianceInferable<'db> for Type<'db> { } } +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_redundant_with_cycle_recover<'db>( + _db: &'db dyn Db, + _value: &bool, + _count: u32, + _subtype: Type<'db>, + _supertype: Type<'db>, +) -> salsa::CycleRecoveryAction { + salsa::CycleRecoveryAction::Iterate +} + +fn is_redundant_with_cycle_initial<'db>( + _db: &'db dyn Db, + _subtype: Type<'db>, + _supertype: Type<'db>, +) -> bool { + true +} + fn apply_specialization_cycle_recover<'db>( _db: &'db dyn Db, _value: &Type<'db>, From 3db5d5906eacdd93816858f875d67c4644add6d5 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 16 Oct 2025 13:16:18 +0100 Subject: [PATCH 067/113] Don't use codspeed or depot runners in CI jobs on forks (#20894) --- .github/workflows/ci.yaml | 31 ++++++++++++-------- .github/workflows/mypy_primer.yaml | 4 +-- .github/workflows/ty-ecosystem-analyzer.yaml | 2 +- .github/workflows/ty-ecosystem-report.yaml | 2 +- .github/workflows/typing_conformance.yaml | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4b0a223a8f..7f65e66d45 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -237,7 +237,7 @@ jobs: cargo-test-linux: name: "cargo test (linux)" - runs-on: depot-ubuntu-22.04-16 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }} needs: determine_changes if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 @@ -299,7 +299,7 @@ jobs: cargo-test-linux-release: name: "cargo test (linux, release)" - runs-on: depot-ubuntu-22.04-16 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }} needs: determine_changes if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 @@ -332,7 +332,7 @@ jobs: cargo-test-windows: name: "cargo test (windows)" - runs-on: depot-windows-2022-16 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-windows-2022-16' || 'windows-latest' }} needs: determine_changes if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 @@ -424,7 +424,7 @@ jobs: cargo-build-msrv: name: "cargo build (msrv)" - runs-on: depot-ubuntu-latest-8 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-latest-8' || 'ubuntu-latest' }} needs: determine_changes if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 @@ -538,7 +538,7 @@ jobs: ecosystem: name: "ecosystem" - runs-on: depot-ubuntu-latest-8 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-latest-8' || 'ubuntu-latest' }} needs: - cargo-test-linux - determine_changes @@ -663,7 +663,7 @@ jobs: fuzz-ty: name: "Fuzz for new ty panics" - runs-on: depot-ubuntu-22.04-16 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }} needs: - cargo-test-linux - determine_changes @@ -723,7 +723,7 @@ jobs: ty-completion-evaluation: name: "ty completion evaluation" - runs-on: depot-ubuntu-22.04-16 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }} needs: determine_changes if: ${{ needs.determine_changes.outputs.ty == 'true' || github.ref == 'refs/heads/main' }} steps: @@ -769,7 +769,7 @@ jobs: pre-commit: name: "pre-commit" - runs-on: depot-ubuntu-22.04-16 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }} timeout-minutes: 10 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -943,8 +943,12 @@ jobs: runs-on: ubuntu-24.04 needs: determine_changes if: | - github.ref == 'refs/heads/main' || - (needs.determine_changes.outputs.formatter == 'true' || needs.determine_changes.outputs.linter == 'true') + github.repository == 'astral-sh/ruff' && + ( + github.ref == 'refs/heads/main' || + needs.determine_changes.outputs.formatter == 'true' || + needs.determine_changes.outputs.linter == 'true' + ) timeout-minutes: 20 steps: - name: "Checkout Branch" @@ -978,8 +982,11 @@ jobs: runs-on: ubuntu-24.04 needs: determine_changes if: | - github.ref == 'refs/heads/main' || - needs.determine_changes.outputs.ty == 'true' + github.repository == 'astral-sh/ruff' && + ( + github.ref == 'refs/heads/main' || + needs.determine_changes.outputs.ty == 'true' + ) timeout-minutes: 20 steps: - name: "Checkout Branch" diff --git a/.github/workflows/mypy_primer.yaml b/.github/workflows/mypy_primer.yaml index f3f6596066..820087cfed 100644 --- a/.github/workflows/mypy_primer.yaml +++ b/.github/workflows/mypy_primer.yaml @@ -29,7 +29,7 @@ env: jobs: mypy_primer: name: Run mypy_primer - runs-on: depot-ubuntu-22.04-32 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-32' || 'ubuntu-latest' }} timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -72,7 +72,7 @@ jobs: memory_usage: name: Run memory statistics - runs-on: depot-ubuntu-22.04-32 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-32' || 'ubuntu-latest' }} timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/.github/workflows/ty-ecosystem-analyzer.yaml b/.github/workflows/ty-ecosystem-analyzer.yaml index 11000041ad..4d85f7e78b 100644 --- a/.github/workflows/ty-ecosystem-analyzer.yaml +++ b/.github/workflows/ty-ecosystem-analyzer.yaml @@ -22,7 +22,7 @@ env: jobs: ty-ecosystem-analyzer: name: Compute diagnostic diff - runs-on: depot-ubuntu-22.04-32 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-32' || 'ubuntu-latest' }} timeout-minutes: 20 if: contains(github.event.label.name, 'ecosystem-analyzer') steps: diff --git a/.github/workflows/ty-ecosystem-report.yaml b/.github/workflows/ty-ecosystem-report.yaml index e3348b749b..3c5e0f7797 100644 --- a/.github/workflows/ty-ecosystem-report.yaml +++ b/.github/workflows/ty-ecosystem-report.yaml @@ -19,7 +19,7 @@ env: jobs: ty-ecosystem-report: name: Create ecosystem report - runs-on: depot-ubuntu-22.04-32 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-32' || 'ubuntu-latest' }} timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/.github/workflows/typing_conformance.yaml b/.github/workflows/typing_conformance.yaml index 5280236b4b..ed23e6c084 100644 --- a/.github/workflows/typing_conformance.yaml +++ b/.github/workflows/typing_conformance.yaml @@ -29,7 +29,7 @@ env: jobs: typing_conformance: name: Compute diagnostic diff - runs-on: depot-ubuntu-22.04-32 + runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-32' || 'ubuntu-latest' }} timeout-minutes: 10 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 From 6a1e91ce9767a2752fbda77eeacc0516dbf7b087 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 16 Oct 2025 09:25:08 -0400 Subject: [PATCH 068/113] [ty] Check typeshed VERSIONS for parent modules when reporting failed stdlib imports (#20908) This is a drive-by improvement that I stumbled backwards into while looking into * https://github.com/astral-sh/ty/issues/296 I was writing some simple tests for "thing not in old version of stdlib" diagnostics and checked what was added in 3.14, and saw `compression.zstd` and to my surprise discovered that `import compression.zstd` and `from compression import zstd` had completely different quality diagnostics. This is because `compression` and `compression.zstd` were *both* introduced in 3.14, and so per VERSIONS policy only an entry for `compression` was added, and so we don't actually have any definite info on `compression.zstd` and give up on producing a diagnostic. However the `from compression import zstd` form fails on looking up `compression` and we *do* have an exact match for that, so it gets a better diagnostic! (aside: I have now learned about the VERSIONS format and I *really* wish they would just enumerate all the submodules but, oh well!) The fix is, when handling an import failure, if we fail to find an exact match *we requery with the parent module*. In cases like `compression.zstd` this lets us at least identify that, hey, not even `compression` exists, and luckily that fixes the whole issue. In cases where the parent module and submodule were introduced at different times then we may discover that the parent module is in-range and that's fine, we don't produce the richer stdlib diagnostic. --- .../resources/mdtest/import/basic.md | 20 +++++ ...empting_to_import…_(dba22bd97137ee38).snap | 83 +++++++++++++++++++ .../src/types/infer/builder.rs | 35 ++++---- 3 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(dba22bd97137ee38).snap diff --git a/crates/ty_python_semantic/resources/mdtest/import/basic.md b/crates/ty_python_semantic/resources/mdtest/import/basic.md index 45abcf5018..1220ddc770 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/import/basic.md @@ -192,6 +192,26 @@ from string.templatelib import Template # error: [unresolved-import] from importlib.resources import abc # error: [unresolved-import] ``` +## Attempting to import a stdlib submodule when both parts haven't yet been added + +`compression` and `compression.zstd` were both added in 3.14 so there is a typeshed `VERSIONS` entry +for `compression` but not `compression.zstd`. We can't be confident `compression.zstd` exists but we +do know `compression` does and can still give good diagnostics about it. + + + +```toml +[environment] +python-version = "3.10" +``` + +```py +import compression.zstd # error: [unresolved-import] +from compression import zstd # error: [unresolved-import] +import compression.fakebutwhocansay # error: [unresolved-import] +from compression import fakebutwhocansay # error: [unresolved-import] +``` + ## Attempting to import a stdlib module that was previously removed diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(dba22bd97137ee38).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(dba22bd97137ee38).snap new file mode 100644 index 0000000000..cc712bb06e --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(dba22bd97137ee38).snap @@ -0,0 +1,83 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: basic.md - Structures - Attempting to import a stdlib submodule when both parts haven't yet been added +mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md +--- + +# Python source files + +## mdtest_snippet.py + +``` +1 | import compression.zstd # error: [unresolved-import] +2 | from compression import zstd # error: [unresolved-import] +3 | import compression.fakebutwhocansay # error: [unresolved-import] +4 | from compression import fakebutwhocansay # error: [unresolved-import] +``` + +# Diagnostics + +``` +error[unresolved-import]: Cannot resolve imported module `compression.zstd` + --> src/mdtest_snippet.py:1:8 + | +1 | import compression.zstd # error: [unresolved-import] + | ^^^^^^^^^^^^^^^^ +2 | from compression import zstd # error: [unresolved-import] +3 | import compression.fakebutwhocansay # error: [unresolved-import] + | +info: The stdlib module `compression` is only available on Python 3.14+ +info: Python 3.10 was assumed when resolving modules because it was specified on the command line +info: rule `unresolved-import` is enabled by default + +``` + +``` +error[unresolved-import]: Cannot resolve imported module `compression` + --> src/mdtest_snippet.py:2:6 + | +1 | import compression.zstd # error: [unresolved-import] +2 | from compression import zstd # error: [unresolved-import] + | ^^^^^^^^^^^ +3 | import compression.fakebutwhocansay # error: [unresolved-import] +4 | from compression import fakebutwhocansay # error: [unresolved-import] + | +info: The stdlib module `compression` is only available on Python 3.14+ +info: Python 3.10 was assumed when resolving modules because it was specified on the command line +info: rule `unresolved-import` is enabled by default + +``` + +``` +error[unresolved-import]: Cannot resolve imported module `compression.fakebutwhocansay` + --> src/mdtest_snippet.py:3:8 + | +1 | import compression.zstd # error: [unresolved-import] +2 | from compression import zstd # error: [unresolved-import] +3 | import compression.fakebutwhocansay # error: [unresolved-import] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +4 | from compression import fakebutwhocansay # error: [unresolved-import] + | +info: The stdlib module `compression` is only available on Python 3.14+ +info: Python 3.10 was assumed when resolving modules because it was specified on the command line +info: rule `unresolved-import` is enabled by default + +``` + +``` +error[unresolved-import]: Cannot resolve imported module `compression` + --> src/mdtest_snippet.py:4:6 + | +2 | from compression import zstd # error: [unresolved-import] +3 | import compression.fakebutwhocansay # error: [unresolved-import] +4 | from compression import fakebutwhocansay # error: [unresolved-import] + | ^^^^^^^^^^^ + | +info: The stdlib module `compression` is only available on Python 3.14+ +info: Python 3.10 was assumed when resolving modules because it was specified on the command line +info: rule `unresolved-import` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 7155e1f7a1..4b2c7cdc44 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4790,21 +4790,26 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let program = Program::get(self.db()); let typeshed_versions = program.search_paths(self.db()).typeshed_versions(); - if let Some(version_range) = typeshed_versions.exact(&module_name) { - // We know it is a stdlib module on *some* Python versions... - let python_version = program.python_version(self.db()); - if !version_range.contains(python_version) { - // ...But not on *this* Python version. - diagnostic.info(format_args!( - "The stdlib module `{module_name}` is only available on Python {version_range}", - version_range = version_range.diagnostic_display(), - )); - add_inferred_python_version_hint_to_diagnostic( - self.db(), - &mut diagnostic, - "resolving modules", - ); - return; + // Loop over ancestors in case we have info on the parent module but not submodule + for module_name in module_name.ancestors() { + if let Some(version_range) = typeshed_versions.exact(&module_name) { + // We know it is a stdlib module on *some* Python versions... + let python_version = program.python_version(self.db()); + if !version_range.contains(python_version) { + // ...But not on *this* Python version. + diagnostic.info(format_args!( + "The stdlib module `{module_name}` is only available on Python {version_range}", + version_range = version_range.diagnostic_display(), + )); + add_inferred_python_version_hint_to_diagnostic( + self.db(), + &mut diagnostic, + "resolving modules", + ); + return; + } + // We found the most precise answer we could, stop searching + break; } } } From a67e0690f256e71925488aa29151cebf9fbef5ba Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 16 Oct 2025 14:25:37 +0100 Subject: [PATCH 069/113] More CI improvements (#20920) --- .github/workflows/ci.yaml | 25 +++++++++++-------------- .github/workflows/mypy_primer.yaml | 6 ++++-- .github/workflows/sync_typeshed.yaml | 6 +++++- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7f65e66d45..56221fc48d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,6 +12,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +defaults: + run: + shell: bash + env: CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 @@ -19,6 +23,7 @@ env: RUSTUP_MAX_RETRIES: 10 PACKAGE_NAME: ruff PYTHON_VERSION: "3.13" + NEXTEST_PROFILE: ci jobs: determine_changes: @@ -271,9 +276,6 @@ jobs: # This step is just to get nice GitHub annotations on the PR diff in the files-changed tab. run: cargo test -p ty_python_semantic --test mdtest || true - name: "Run tests" - shell: bash - env: - NEXTEST_PROFILE: "ci" run: cargo insta test --all-features --unreferenced reject --test-runner nextest # Check for broken links in the documentation. @@ -299,9 +301,13 @@ jobs: cargo-test-linux-release: name: "cargo test (linux, release)" - runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }} + # release builds timeout on GitHub runners, so this job is just skipped on forks in the `if` check + runs-on: depot-ubuntu-22.04-16 needs: determine_changes - if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} + if: | + github.repository == 'astral-sh/ruff' && + !contains(github.event.pull_request.labels.*.name, 'no-test') && + (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -325,9 +331,6 @@ jobs: with: enable-cache: "true" - name: "Run tests" - shell: bash - env: - NEXTEST_PROFILE: "ci" run: cargo insta test --release --all-features --unreferenced reject --test-runner nextest cargo-test-windows: @@ -352,9 +355,7 @@ jobs: with: enable-cache: "true" - name: "Run tests" - shell: bash env: - NEXTEST_PROFILE: "ci" # Workaround for . RUSTUP_WINDOWS_PATH_ADD_BIN: 1 run: | @@ -385,9 +386,6 @@ jobs: with: enable-cache: "true" - name: "Run tests" - shell: bash - env: - NEXTEST_PROFILE: "ci" run: | cargo nextest run --all-features --profile ci cargo test --all-features --doc @@ -445,7 +443,6 @@ jobs: - name: "Install mold" uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: "Build tests" - shell: bash env: MSRV: ${{ steps.msrv.outputs.value }} run: cargo "+${MSRV}" test --no-run --all-features diff --git a/.github/workflows/mypy_primer.yaml b/.github/workflows/mypy_primer.yaml index 820087cfed..672a038537 100644 --- a/.github/workflows/mypy_primer.yaml +++ b/.github/workflows/mypy_primer.yaml @@ -19,6 +19,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +defaults: + run: + shell: bash + env: CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 @@ -49,7 +53,6 @@ jobs: run: rustup show - name: Run mypy_primer - shell: bash env: PRIMER_SELECTOR: crates/ty_python_semantic/resources/primer/good.txt DIFF_FILE: mypy_primer.diff @@ -92,7 +95,6 @@ jobs: run: rustup show - name: Run mypy_primer - shell: bash env: TY_MAX_PARALLELISM: 1 # for deterministic memory numbers TY_MEMORY_REPORT: mypy_primer diff --git a/.github/workflows/sync_typeshed.yaml b/.github/workflows/sync_typeshed.yaml index 5d3b8a7fe4..f7bb4c5426 100644 --- a/.github/workflows/sync_typeshed.yaml +++ b/.github/workflows/sync_typeshed.yaml @@ -28,6 +28,10 @@ on: # Run on the 1st and the 15th of every month: - cron: "0 0 1,15 * *" +defaults: + run: + shell: bash + env: # Don't set this flag globally for the workflow: it does strange things # to the snapshots in the `cargo insta test --accept` step in the MacOS job. @@ -35,6 +39,7 @@ env: # FORCE_COLOR: 1 CARGO_TERM_COLOR: always + NEXTEST_PROFILE: "ci" GH_TOKEN: ${{ github.token }} # The name of the upstream branch that the first worker creates, @@ -133,7 +138,6 @@ jobs: git config --global user.email '<>' - name: Sync Windows docstrings id: docstrings - shell: bash env: FORCE_COLOR: 1 run: ./scripts/codemod_docstrings.sh From 7155a62e5c3e465d926f36835352dd48cdbdf5d2 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 16 Oct 2025 10:07:33 -0400 Subject: [PATCH 070/113] [ty] Add version hint for failed stdlib attribute accesses (#20909) This is the ultra-minimal implementation of * https://github.com/astral-sh/ty/issues/296 that was previously discussed as a good starting point. In particular we don't actually bother trying to figure out the exact python versions, but we still mention "hey btw for No Reason At All... you're on python 3.10" when you try to access something that has a definition rooted in the stdlib that we believe exists sometimes. --- crates/ty/docs/rules.md | 132 +++++++++--------- crates/ty/tests/cli/python_environment.rs | 23 ++- .../resources/mdtest/attributes.md | 22 +++ ...ributes_of_standa…_(49ba2c9016d64653).snap | 51 +++++++ .../src/semantic_index/place.rs | 3 +- .../src/types/diagnostic.rs | 53 ++++++- .../src/types/infer/builder.rs | 4 +- 7 files changed, 215 insertions(+), 73 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 8ae4ba1da5..7687ea2a7c 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -39,7 +39,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -95,7 +95,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -126,7 +126,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -158,7 +158,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -190,7 +190,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -217,7 +217,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -329,7 +329,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -359,7 +359,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -385,7 +385,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -474,7 +474,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -501,7 +501,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -563,7 +563,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -599,7 +599,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -623,7 +623,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -650,7 +650,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -679,7 +679,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -723,7 +723,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -756,7 +756,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -787,7 +787,7 @@ alice["height"] # KeyError: 'height' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -822,7 +822,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -856,7 +856,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -888,7 +888,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -938,7 +938,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -964,7 +964,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -998,7 +998,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1047,7 +1047,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1072,7 +1072,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1130,7 +1130,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1157,7 +1157,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1187,7 +1187,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1217,7 +1217,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1251,7 +1251,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1285,7 +1285,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1320,7 +1320,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1345,7 +1345,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1378,7 +1378,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1407,7 +1407,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1431,7 +1431,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1457,7 +1457,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1484,7 +1484,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1542,7 +1542,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1572,7 +1572,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1601,7 +1601,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1628,7 +1628,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1656,7 +1656,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1702,7 +1702,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1729,7 +1729,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1757,7 +1757,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1782,7 +1782,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1807,7 +1807,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1844,7 +1844,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1872,7 +1872,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1897,7 +1897,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1938,7 +1938,7 @@ class SubProto(BaseProto, Protocol): Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -1995,7 +1995,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2023,7 +2023,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2055,7 +2055,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2087,7 +2087,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2114,7 +2114,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2169,7 +2169,7 @@ a = 20 / 0 # ty: ignore[division-by-zero] Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2227,7 +2227,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2266,7 +2266,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2329,7 +2329,7 @@ def foo(x: int | str) -> int | str: Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -2353,7 +2353,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 0a200c3c26..4168da8e4c 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -26,7 +26,7 @@ fn config_override_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- @@ -37,12 +37,19 @@ fn config_override_python_version() -> anyhow::Result<()> { 5 | print(sys.last_exc) | ^^^^^^^^^^^^ | + info: Python 3.11 was assumed when accessing `last_exc` + --> pyproject.toml:3:18 + | + 2 | [tool.ty.environment] + 3 | python-version = "3.11" + | ^^^^^^ Python 3.11 assumed due to this configuration setting + | info: rule `unresolved-attribute` is enabled by default Found 1 diagnostic ----- stderr ----- - "###); + "#); assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r###" success: true @@ -951,7 +958,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- @@ -963,12 +970,20 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { 4 | os.grantpt(1) # only available on unix, Python 3.13 or newer | ^^^^^^^^^^ | + info: Python 3.10 was assumed when accessing `grantpt` + --> ty.toml:3:18 + | + 2 | [environment] + 3 | python-version = "3.10" + | ^^^^^^ Python 3.10 assumed due to this configuration setting + 4 | python-platform = "linux" + | info: rule `unresolved-attribute` is enabled by default Found 1 diagnostic ----- stderr ----- - "###); + "#); // Use default (which should be latest supported) let case = CliTest::with_files([ diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 496f796c7a..3c910ee77e 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2558,6 +2558,28 @@ class C: reveal_type(C().x) # revealed: Unknown | tuple[Divergent, Literal[1]] ``` +## Attributes of standard library modules that aren't yet defined + +For attributes of stdlib modules that exist in future versions, we can give better diagnostics. + + + +```toml +[environment] +python-version = "3.10" +``` + +`main.py`: + +```py +import datetime + +# error: [unresolved-attribute] +reveal_type(datetime.UTC) # revealed: Unknown +# error: [unresolved-attribute] +reveal_type(datetime.fakenotreal) # revealed: Unknown +``` + ## References Some of the tests in the *Class and instance variables* section draw inspiration from diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap new file mode 100644 index 0000000000..b5171a701c --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap @@ -0,0 +1,51 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: attributes.md - Attributes - Attributes of standard library modules that aren't yet defined +mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md +--- + +# Python source files + +## main.py + +``` +1 | import datetime +2 | +3 | # error: [unresolved-attribute] +4 | reveal_type(datetime.UTC) # revealed: Unknown +5 | # error: [unresolved-attribute] +6 | reveal_type(datetime.fakenotreal) # revealed: Unknown +``` + +# Diagnostics + +``` +error[unresolved-attribute]: Type `` has no attribute `UTC` + --> src/main.py:4:13 + | +3 | # error: [unresolved-attribute] +4 | reveal_type(datetime.UTC) # revealed: Unknown + | ^^^^^^^^^^^^ +5 | # error: [unresolved-attribute] +6 | reveal_type(datetime.fakenotreal) # revealed: Unknown + | +info: Python 3.10 was assumed when accessing `UTC` because it was specified on the command line +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `` has no attribute `fakenotreal` + --> src/main.py:6:13 + | +4 | reveal_type(datetime.UTC) # revealed: Unknown +5 | # error: [unresolved-attribute] +6 | reveal_type(datetime.fakenotreal) # revealed: Unknown + | ^^^^^^^^^^^^^^^^^^^^ + | +info: rule `unresolved-attribute` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/semantic_index/place.rs b/crates/ty_python_semantic/src/semantic_index/place.rs index 38e3d92816..04bea97626 100644 --- a/crates/ty_python_semantic/src/semantic_index/place.rs +++ b/crates/ty_python_semantic/src/semantic_index/place.rs @@ -181,7 +181,8 @@ impl PlaceTable { } /// Looks up a symbol by its name and returns a reference to it, if it exists. - #[cfg(test)] + /// + /// This should only be used in diagnostics and tests. pub(crate) fn symbol_by_name(&self, name: &str) -> Option<&Symbol> { self.symbols.symbol_id(name).map(|id| self.symbol(id)) } diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index cb1b5b6a0d..8c836c0038 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -8,6 +8,7 @@ use super::{ use crate::lint::{Level, LintRegistryBuilder, LintStatus}; use crate::semantic_index::definition::{Definition, DefinitionKind}; use crate::semantic_index::place::{PlaceTable, ScopedPlaceId}; +use crate::semantic_index::{global_scope, place_table}; use crate::suppression::FileSuppressionId; use crate::types::call::CallError; use crate::types::class::{DisjointBase, DisjointBaseKind, Field}; @@ -29,7 +30,7 @@ use crate::{ use itertools::Itertools; use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; use ruff_python_ast::name::Name; -use ruff_python_ast::{self as ast, AnyNodeRef}; +use ruff_python_ast::{self as ast, AnyNodeRef, Identifier}; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use std::fmt::Formatter; @@ -3140,6 +3141,56 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions( add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules"); } +/// This function receives an unresolved `foo.bar` attribute access, +/// where `foo` can be resolved to have a type but that type does not +/// have a `bar` attribute. +/// +/// If the type of `foo` has a definition that originates in the +/// standard library and `foo.bar` *does* exist as an attribute on *other* +/// Python versions, we add a hint to the diagnostic that the user may have +/// misconfigured their Python version. +pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions( + db: &dyn Db, + mut diagnostic: LintDiagnosticGuard, + value_type: &Type, + attr: &Identifier, +) { + // Currently we limit this analysis to attributes of stdlib modules, + // as this covers the most important cases while not being too noisy + // about basic typos or special types like `super(C, self)` + let Type::ModuleLiteral(module_ty) = value_type else { + return; + }; + let module = module_ty.module(db); + let Some(file) = module.file(db) else { + return; + }; + let Some(search_path) = module.search_path(db) else { + return; + }; + if !search_path.is_standard_library() { + return; + } + + // We populate place_table entries for stdlib items across all known versions and platforms, + // so if this lookup succeeds then we know that this lookup *could* succeed with possible + // configuration changes. + let symbol_table = place_table(db, global_scope(db, file)); + if symbol_table.symbol_by_name(attr).is_none() { + return; + } + + // For now, we just mention the current version they're on, and hope that's enough of a nudge. + // TODO: determine what version they need to be on + // TODO: also mention the platform we're assuming + // TODO: determine what platform they need to be on + add_inferred_python_version_hint_to_diagnostic( + db, + &mut diagnostic, + &format!("accessing `{}`", attr.id), + ); +} + /// Suggest a name from `existing_names` that is similar to `wrong_name`. fn did_you_mean, T: AsRef>( existing_names: impl Iterator, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 4b2c7cdc44..a38c9e4462 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -59,6 +59,7 @@ use crate::types::diagnostic::{ IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, + hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_cannot_pop_required_field_on_typed_dict, report_duplicate_bases, report_implicit_return_type, report_index_out_of_bounds, @@ -7497,13 +7498,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ), ); } else { - builder.into_diagnostic( + let diagnostic = builder.into_diagnostic( format_args!( "Type `{}` has no attribute `{}`", value_type.display(db), attr.id ), ); + hint_if_stdlib_attribute_exists_on_other_versions(db, diagnostic, &value_type, attr); } } } From ec9faa34be69e6e48b45a6154e3e8687a046c091 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Oct 2025 16:08:37 +0200 Subject: [PATCH 071/113] [ty] Run file watching tests serial when using nextest (#20918) --- .config/nextest.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 2ab53e2239..f2537ce580 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,12 @@ +# Define serial test group for running tests sequentially. +[test-groups] +serial = { max-threads = 1 } + +# Run ty file watching tests sequentially to avoid race conditions. +[[profile.default.overrides]] +filter = 'binary(file_watching)' +test-group = 'serial' + [profile.ci] # Print out output for failing tests as soon as they fail, and also at the end # of the run (for easy scrollability). From 058fc37542549e8992d7c2c60446efa8dd713547 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Oct 2025 16:23:02 +0200 Subject: [PATCH 072/113] [ty] Fix panic 'missing root' when handling completion request (#20917) --- crates/ruff_db/src/files.rs | 11 +++ crates/ruff_db/src/files/file_root.rs | 2 + .../src/module_resolver/list.rs | 67 ++++++++++++++----- .../src/module_resolver/module.rs | 5 +- .../src/module_resolver/resolver.rs | 29 ++++++-- 5 files changed, 90 insertions(+), 24 deletions(-) diff --git a/crates/ruff_db/src/files.rs b/crates/ruff_db/src/files.rs index ff8c9c8fc3..754b65642a 100644 --- a/crates/ruff_db/src/files.rs +++ b/crates/ruff_db/src/files.rs @@ -193,6 +193,17 @@ impl Files { roots.at(&absolute) } + /// The same as [`Self::root`] but panics if no root is found. + #[track_caller] + pub fn expect_root(&self, db: &dyn Db, path: &SystemPath) -> FileRoot { + if let Some(root) = self.root(db, path) { + return root; + } + + let roots = self.inner.roots.read().unwrap(); + panic!("No root found for path '{path}'. Known roots: {roots:#?}"); + } + /// Adds a new root for `path` and returns the root. /// /// The root isn't added nor is the file root's kind updated if a root for `path` already exists. diff --git a/crates/ruff_db/src/files/file_root.rs b/crates/ruff_db/src/files/file_root.rs index b5ffedd3e1..8a1ed08f43 100644 --- a/crates/ruff_db/src/files/file_root.rs +++ b/crates/ruff_db/src/files/file_root.rs @@ -81,6 +81,8 @@ impl FileRoots { } } + tracing::debug!("Adding new file root '{path}' of kind {kind:?}"); + // normalize the path to use `/` separators and escape the '{' and '}' characters, // which matchit uses for routing parameters let mut route = normalized_path.replace('{', "{{").replace('}', "}}"); diff --git a/crates/ty_python_semantic/src/module_resolver/list.rs b/crates/ty_python_semantic/src/module_resolver/list.rs index 320d160237..a7957e3c98 100644 --- a/crates/ty_python_semantic/src/module_resolver/list.rs +++ b/crates/ty_python_semantic/src/module_resolver/list.rs @@ -65,6 +65,7 @@ fn list_modules_in<'db>( db: &'db dyn Db, search_path: SearchPathIngredient<'db>, ) -> Vec> { + tracing::debug!("Listing modules in search path '{}'", search_path.path(db)); let mut lister = Lister::new(db, search_path.path(db)); match search_path.path(db).as_path() { SystemOrVendoredPathRef::System(system_search_path) => { @@ -72,10 +73,7 @@ fn list_modules_in<'db>( // register an explicit dependency on this directory. When // the revision gets bumped, the cache that Salsa creates // for this routine will be invalidated. - let root = db - .files() - .root(db, system_search_path) - .expect("System search path should have a registered root"); + let root = db.files().expect_root(db, system_search_path); let _ = root.revision(db); let Ok(it) = db.system().read_directory(system_search_path) else { @@ -969,10 +967,6 @@ mod tests { std::os::unix::fs::symlink(foo.as_std_path(), bar.as_std_path())?; db.files().try_add_root(&db, &src, FileRootKind::Project); - db.files() - .try_add_root(&db, &site_packages, FileRootKind::LibrarySearchPath); - db.files() - .try_add_root(&db, &custom_typeshed, FileRootKind::LibrarySearchPath); Program::from_settings( &db, @@ -1468,6 +1462,55 @@ not_a_directory ); } + #[test] + fn editable_installs_into_first_party_search_path() { + let mut db = TestDb::new(); + + let src = SystemPath::new("/src"); + let venv_site_packages = SystemPathBuf::from("/venv-site-packages"); + let site_packages_pth = venv_site_packages.join("foo.pth"); + let editable_install_location = src.join("x/y/a.py"); + + db.write_files([ + (&site_packages_pth, "/src/x/y/"), + (&editable_install_location, ""), + ]) + .unwrap(); + + db.files() + .try_add_root(&db, SystemPath::new("/src"), FileRootKind::Project); + + Program::from_settings( + &db, + ProgramSettings { + python_version: PythonVersionWithSource::default(), + python_platform: PythonPlatform::default(), + search_paths: SearchPathSettings { + site_packages_paths: vec![venv_site_packages], + ..SearchPathSettings::new(vec![src.to_path_buf()]) + } + .to_search_paths(db.system(), db.vendored()) + .expect("Valid search path settings"), + }, + ); + + insta::assert_debug_snapshot!( + list_snapshot_filter(&db, |m| m.name(&db).as_str() == "a"), + @r#" + [ + Module::File("a", "editable", "/src/x/y/a.py", Module, None), + ] + "#, + ); + + let editable_root = db + .files() + .root(&db, &editable_install_location) + .expect("file root for editable install"); + + assert_eq!(editable_root.path(&db), src); + } + #[test] fn multiple_site_packages_with_editables() { let mut db = TestDb::new(); @@ -1490,12 +1533,6 @@ not_a_directory db.files() .try_add_root(&db, SystemPath::new("/src"), FileRootKind::Project); - db.files() - .try_add_root(&db, &venv_site_packages, FileRootKind::LibrarySearchPath); - db.files() - .try_add_root(&db, &system_site_packages, FileRootKind::LibrarySearchPath); - db.files() - .try_add_root(&db, SystemPath::new("/x"), FileRootKind::LibrarySearchPath); Program::from_settings( &db, @@ -1625,8 +1662,6 @@ not_a_directory db.files() .try_add_root(&db, &project_directory, FileRootKind::Project); - db.files() - .try_add_root(&db, &site_packages, FileRootKind::LibrarySearchPath); Program::from_settings( &db, diff --git a/crates/ty_python_semantic/src/module_resolver/module.rs b/crates/ty_python_semantic/src/module_resolver/module.rs index 04ce1851aa..1a17ac2d2c 100644 --- a/crates/ty_python_semantic/src/module_resolver/module.rs +++ b/crates/ty_python_semantic/src/module_resolver/module.rs @@ -175,10 +175,7 @@ fn all_submodule_names_for_package<'db>( // tree. When the revision gets bumped, the cache // that Salsa creates does for this routine will be // invalidated. - let root = db - .files() - .root(db, parent_directory) - .expect("System search path should have a registered root"); + let root = db.files().expect_root(db, parent_directory); let _ = root.revision(db); db.system() diff --git a/crates/ty_python_semantic/src/module_resolver/resolver.rs b/crates/ty_python_semantic/src/module_resolver/resolver.rs index f4cb91e28f..2f827f256f 100644 --- a/crates/ty_python_semantic/src/module_resolver/resolver.rs +++ b/crates/ty_python_semantic/src/module_resolver/resolver.rs @@ -348,9 +348,15 @@ impl SearchPaths { }) } + /// Registers the file roots for all non-dynamically discovered search paths that aren't first-party. pub(crate) fn try_register_static_roots(&self, db: &dyn Db) { let files = db.files(); - for path in self.static_paths.iter().chain(self.site_packages.iter()) { + for path in self + .static_paths + .iter() + .chain(self.site_packages.iter()) + .chain(&self.stdlib_path) + { if let Some(system_path) = path.as_system_path() { if !path.is_first_party() { files.try_add_root(db, system_path, FileRootKind::LibrarySearchPath); @@ -451,9 +457,7 @@ pub(crate) fn dynamic_resolution_paths<'db>( continue; } - let site_packages_root = files - .root(db, &site_packages_dir) - .expect("Site-package root to have been created"); + let site_packages_root = files.expect_root(db, &site_packages_dir); // This query needs to be re-executed each time a `.pth` file // is added, modified or removed from the `site-packages` directory. @@ -500,6 +504,23 @@ pub(crate) fn dynamic_resolution_paths<'db>( "Adding editable installation to module resolution path {path}", path = installation ); + + // Register a file root for editable installs that are outside any other root + // (Most importantly, don't register a root for editable installations from the project + // directory as that would change the durability of files within those folders). + // Not having an exact file root for editable installs just means that + // some queries (like `list_modules_in`) will run slightly more frequently + // than they would otherwise. + if let Some(dynamic_path) = search_path.as_system_path() { + if files.root(db, dynamic_path).is_none() { + files.try_add_root( + db, + dynamic_path, + FileRootKind::LibrarySearchPath, + ); + } + } + dynamic_paths.push(search_path); } From 03696687eae71652b3b2e27355a384e3f1e4d819 Mon Sep 17 00:00:00 2001 From: Auguste Lalande Date: Thu, 16 Oct 2025 11:26:51 -0400 Subject: [PATCH 073/113] [`pydoclint`] Implement `docstring-extraneous-parameter` (`DOC102`) (#20376) ## Summary Implement `docstring-extraneous-parameter` (`DOC102`). This rule checks that all parameters present in a functions docstring are also present in its signature. Split from #13280, per this [comment](https://github.com/astral-sh/ruff/pull/13280#issuecomment-3280575506). Part of #12434. ## Test Plan Test cases added. --------- Co-authored-by: Brent Westbrook --- .../test/fixtures/pydoclint/DOC102_google.py | 264 +++++++++++++ .../test/fixtures/pydoclint/DOC102_numpy.py | 372 ++++++++++++++++++ .../src/checkers/ast/analyze/definitions.rs | 1 + crates/ruff_linter/src/codes.rs | 1 + crates/ruff_linter/src/rules/pydoclint/mod.rs | 2 + .../rules/pydoclint/rules/check_docstring.rs | 290 +++++++++++++- ...extraneous-parameter_DOC102_google.py.snap | 180 +++++++++ ...-extraneous-parameter_DOC102_numpy.py.snap | 189 +++++++++ ruff.schema.json | 3 + 9 files changed, 1286 insertions(+), 16 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_google.py create mode 100644 crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_numpy.py create mode 100644 crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_google.py.snap create mode 100644 crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_numpy.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_google.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_google.py new file mode 100644 index 0000000000..f61500305e --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_google.py @@ -0,0 +1,264 @@ +# DOC102 +def add_numbers(b): + """ + Adds two numbers and returns the result. + + Args: + a (int): The first number to add. + b (int): The second number to add. + + Returns: + int: The sum of the two numbers. + """ + return a + b + + +# DOC102 +def multiply_list_elements(lst): + """ + Multiplies each element in a list by a given multiplier. + + Args: + lst (list of int): A list of integers. + multiplier (int): The multiplier for each element in the list. + + Returns: + list of int: A new list with each element multiplied. + """ + return [x * multiplier for x in lst] + + +# DOC102 +def find_max_value(): + """ + Finds the maximum value in a list of numbers. + + Args: + numbers (list of int): A list of integers to search through. + + Returns: + int: The maximum value found in the list. + """ + return max(numbers) + + +# DOC102 +def create_user_profile(location="here"): + """ + Creates a user profile with basic information. + + Args: + name (str): The name of the user. + age (int): The age of the user. + email (str): The user's email address. + location (str): The location of the user. + + Returns: + dict: A dictionary containing the user's profile. + """ + return { + 'name': name, + 'age': age, + 'email': email, + 'location': location + } + + +# DOC102 +def calculate_total_price(item_prices, discount): + """ + Calculates the total price after applying tax and a discount. + + Args: + item_prices (list of float): A list of prices for each item. + tax_rate (float): The tax rate to apply. + discount (float): The discount to subtract from the total. + + Returns: + float: The final total price after tax and discount. + """ + total = sum(item_prices) + total_with_tax = total + (total * tax_rate) + final_total = total_with_tax - discount + return final_total + + +# DOC102 +def send_email(subject, body, bcc_address=None): + """ + Sends an email to the specified recipients. + + Args: + subject (str): The subject of the email. + body (str): The content of the email. + to_address (str): The recipient's email address. + cc_address (str, optional): The email address for CC. Defaults to None. + bcc_address (str, optional): The email address for BCC. Defaults to None. + + Returns: + bool: True if the email was sent successfully, False otherwise. + """ + return True + + +# DOC102 +def concatenate_strings(*args): + """ + Concatenates multiple strings with a specified separator. + + Args: + separator (str): The separator to use between strings. + *args (str): Variable length argument list of strings to concatenate. + + Returns: + str: A single concatenated string. + """ + return separator.join(args) + + +# DOC102 +def process_order(order_id): + """ + Processes an order with a list of items and optional order details. + + Args: + order_id (int): The unique identifier for the order. + *items (str): Variable length argument list of items in the order. + **details (dict): Additional details such as shipping method and address. + + Returns: + dict: A dictionary containing the order summary. + """ + return { + 'order_id': order_id, + 'items': items, + 'details': details + } + + +class Calculator: + """ + A simple calculator class that can perform basic arithmetic operations. + """ + + # DOC102 + def __init__(self): + """ + Initializes the calculator with an initial value. + + Args: + value (int, optional): The initial value of the calculator. Defaults to 0. + """ + self.value = value + + # DOC102 + def add(self, number2): + """ + Adds a number to the current value. + + Args: + number (int or float): The number to add to the current value. + + Returns: + int or float: The updated value after addition. + """ + self.value += number + number2 + return self.value + + # DOC102 + @classmethod + def from_string(cls): + """ + Creates a Calculator instance from a string representation of a number. + + Args: + value_str (str): The string representing the initial value. + + Returns: + Calculator: A new instance of Calculator initialized with the value from the string. + """ + value = float(value_str) + return cls(value) + + # DOC102 + @staticmethod + def is_valid_number(): + """ + Checks if a given number is valid (int or float). + + Args: + number (any): The value to check. + + Returns: + bool: True if the number is valid, False otherwise. + """ + return isinstance(number, (int, float)) + +# OK +def foo(param1, param2, *args, **kwargs): + """Foo. + + Args: + param1 (int): The first parameter. + param2 (:obj:`str`, optional): The second parameter. Defaults to None. + Second line of description: should be indented. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + """ + return + +# OK +def on_server_unloaded(self, server_context: ServerContext) -> None: + ''' Execute ``on_server_unloaded`` from ``server_lifecycle.py`` (if + it is defined) when the server cleanly exits. (Before stopping the + server's ``IOLoop``.) + + Args: + server_context (ServerContext) : + + .. warning:: + In practice this code may not run, since servers are often killed + by a signal. + + + ''' + return self._lifecycle_handler.on_server_unloaded(server_context) + +# OK +def function_with_kwargs(param1, param2, **kwargs): + """Function with **kwargs parameter. + + Args: + param1 (int): The first parameter. + param2 (str): The second parameter. + extra_param (str): An extra parameter that may be passed via **kwargs. + another_extra (int): Another extra parameter. + """ + return + +# OK +def add_numbers(b): + """ + Adds two numbers and returns the result. + + Args: + b: The second number to add. + + Returns: + int: The sum of the two numbers. + """ + return + +# DOC102 +def add_numbers(b): + """ + Adds two numbers and returns the result. + + Args: + a: The first number to add. + b: The second number to add. + + Returns: + int: The sum of the two numbers. + """ + return a + b diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_numpy.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_numpy.py new file mode 100644 index 0000000000..fb7f86b25f --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC102_numpy.py @@ -0,0 +1,372 @@ +# DOC102 +def add_numbers(b): + """ + Adds two numbers and returns the result. + + Parameters + ---------- + a : int + The first number to add. + b : int + The second number to add. + + Returns + ------- + int + The sum of the two numbers. + """ + return a + b + + +# DOC102 +def multiply_list_elements(lst): + """ + Multiplies each element in a list by a given multiplier. + + Parameters + ---------- + lst : list of int + A list of integers. + multiplier : int + The multiplier for each element in the list. + + Returns + ------- + list of int + A new list with each element multiplied. + """ + return [x * multiplier for x in lst] + + +# DOC102 +def find_max_value(): + """ + Finds the maximum value in a list of numbers. + + Parameters + ---------- + numbers : list of int + A list of integers to search through. + + Returns + ------- + int + The maximum value found in the list. + """ + return max(numbers) + + +# DOC102 +def create_user_profile(location="here"): + """ + Creates a user profile with basic information. + + Parameters + ---------- + name : str + The name of the user. + age : int + The age of the user. + email : str + The user's email address. + location : str, optional + The location of the user, by default "here". + + Returns + ------- + dict + A dictionary containing the user's profile. + """ + return { + 'name': name, + 'age': age, + 'email': email, + 'location': location + } + + +# DOC102 +def calculate_total_price(item_prices, discount): + """ + Calculates the total price after applying tax and a discount. + + Parameters + ---------- + item_prices : list of float + A list of prices for each item. + tax_rate : float + The tax rate to apply. + discount : float + The discount to subtract from the total. + + Returns + ------- + float + The final total price after tax and discount. + """ + total = sum(item_prices) + total_with_tax = total + (total * tax_rate) + final_total = total_with_tax - discount + return final_total + + +# DOC102 +def send_email(subject, body, bcc_address=None): + """ + Sends an email to the specified recipients. + + Parameters + ---------- + subject : str + The subject of the email. + body : str + The content of the email. + to_address : str + The recipient's email address. + cc_address : str, optional + The email address for CC, by default None. + bcc_address : str, optional + The email address for BCC, by default None. + + Returns + ------- + bool + True if the email was sent successfully, False otherwise. + """ + return True + + +# DOC102 +def concatenate_strings(*args): + """ + Concatenates multiple strings with a specified separator. + + Parameters + ---------- + separator : str + The separator to use between strings. + *args : str + Variable length argument list of strings to concatenate. + + Returns + ------- + str + A single concatenated string. + """ + return True + + +# DOC102 +def process_order(order_id): + """ + Processes an order with a list of items and optional order details. + + Parameters + ---------- + order_id : int + The unique identifier for the order. + *items : str + Variable length argument list of items in the order. + **details : dict + Additional details such as shipping method and address. + + Returns + ------- + dict + A dictionary containing the order summary. + """ + return { + 'order_id': order_id, + 'items': items, + 'details': details + } + + +class Calculator: + """ + A simple calculator class that can perform basic arithmetic operations. + """ + + # DOC102 + def __init__(self): + """ + Initializes the calculator with an initial value. + + Parameters + ---------- + value : int, optional + The initial value of the calculator, by default 0. + """ + self.value = value + + # DOC102 + def add(self, number2): + """ + Adds two numbers to the current value. + + Parameters + ---------- + number : int or float + The first number to add. + number2 : int or float + The second number to add. + + Returns + ------- + int or float + The updated value after addition. + """ + self.value += number + number2 + return self.value + + # DOC102 + @classmethod + def from_string(cls): + """ + Creates a Calculator instance from a string representation of a number. + + Parameters + ---------- + value_str : str + The string representing the initial value. + + Returns + ------- + Calculator + A new instance of Calculator initialized with the value from the string. + """ + value = float(value_str) + return cls(value) + + # DOC102 + @staticmethod + def is_valid_number(): + """ + Checks if a given number is valid (int or float). + + Parameters + ---------- + number : any + The value to check. + + Returns + ------- + bool + True if the number is valid, False otherwise. + """ + return isinstance(number, (int, float)) + +# OK +def function_with_kwargs(param1, param2, **kwargs): + """Function with **kwargs parameter. + + Parameters + ---------- + param1 : int + The first parameter. + param2 : str + The second parameter. + extra_param : str + An extra parameter that may be passed via **kwargs. + another_extra : int + Another extra parameter. + """ + return True + +# OK +def add_numbers(b): + """ + Adds two numbers and returns the result. + + Parameters + ---------- + b + The second number to add. + + Returns + ------- + int + The sum of the two numbers. + """ + return a + b + +# DOC102 +def add_numbers(b): + """ + Adds two numbers and returns the result. + + Parameters + ---------- + a + The first number to add. + b + The second number to add. + + Returns + ------- + int + The sum of the two numbers. + """ + return a + b + +class Foo: + # OK + def send_help(self, *args: Any) -> Any: + """|coro| + + Shows the help command for the specified entity if given. + The entity can be a command or a cog. + + If no entity is given, then it'll show help for the + entire bot. + + If the entity is a string, then it looks up whether it's a + :class:`Cog` or a :class:`Command`. + + .. note:: + + Due to the way this function works, instead of returning + something similar to :meth:`~.commands.HelpCommand.command_not_found` + this returns :class:`None` on bad input or no help command. + + Parameters + ---------- + entity: Optional[Union[:class:`Command`, :class:`Cog`, :class:`str`]] + The entity to show help for. + + Returns + ------- + Any + The result of the help command, if any. + """ + return + + # OK + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + """|coro| + + The method that actually converters an argument to the flag mapping. + + Parameters + ---------- + cls: Type[:class:`FlagConverter`] + The flag converter class. + ctx: :class:`Context` + The invocation context. + argument: :class:`str` + The argument to convert from. + + Raises + ------ + FlagError + A flag related parsing error. + CommandError + A command related error. + + Returns + ------- + :class:`FlagConverter` + The flag converter instance with all flags parsed. + """ + return diff --git a/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs b/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs index e3a2567bbf..4a3fe560be 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs @@ -81,6 +81,7 @@ pub(crate) fn definitions(checker: &mut Checker) { Rule::UndocumentedPublicPackage, ]); let enforce_pydoclint = checker.any_rule_enabled(&[ + Rule::DocstringExtraneousParameter, Rule::DocstringMissingReturns, Rule::DocstringExtraneousReturns, Rule::DocstringMissingYields, diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 6b2a317bae..86e3bf8ebc 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -988,6 +988,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (FastApi, "003") => (RuleGroup::Stable, rules::fastapi::rules::FastApiUnusedPathParameter), // pydoclint + (Pydoclint, "102") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringExtraneousParameter), (Pydoclint, "201") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringMissingReturns), (Pydoclint, "202") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringExtraneousReturns), (Pydoclint, "402") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringMissingYields), diff --git a/crates/ruff_linter/src/rules/pydoclint/mod.rs b/crates/ruff_linter/src/rules/pydoclint/mod.rs index 9fae2230a6..69326b0c51 100644 --- a/crates/ruff_linter/src/rules/pydoclint/mod.rs +++ b/crates/ruff_linter/src/rules/pydoclint/mod.rs @@ -28,6 +28,7 @@ mod tests { Ok(()) } + #[test_case(Rule::DocstringExtraneousParameter, Path::new("DOC102_google.py"))] #[test_case(Rule::DocstringMissingReturns, Path::new("DOC201_google.py"))] #[test_case(Rule::DocstringExtraneousReturns, Path::new("DOC202_google.py"))] #[test_case(Rule::DocstringMissingYields, Path::new("DOC402_google.py"))] @@ -50,6 +51,7 @@ mod tests { Ok(()) } + #[test_case(Rule::DocstringExtraneousParameter, Path::new("DOC102_numpy.py"))] #[test_case(Rule::DocstringMissingReturns, Path::new("DOC201_numpy.py"))] #[test_case(Rule::DocstringExtraneousReturns, Path::new("DOC202_numpy.py"))] #[test_case(Rule::DocstringMissingYields, Path::new("DOC402_numpy.py"))] diff --git a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs index 8db3c53808..dd4f8ee8a7 100644 --- a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs +++ b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs @@ -1,14 +1,14 @@ use itertools::Itertools; use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_ast::helpers::map_callable; -use ruff_python_ast::helpers::map_subscript; +use ruff_python_ast::helpers::{map_callable, map_subscript}; use ruff_python_ast::name::QualifiedName; use ruff_python_ast::visitor::Visitor; use ruff_python_ast::{self as ast, Expr, Stmt, visitor}; use ruff_python_semantic::analyze::{function_type, visibility}; use ruff_python_semantic::{Definition, SemanticModel}; -use ruff_source_file::NewlineWithTrailingNewline; -use ruff_text_size::{Ranged, TextRange}; +use ruff_python_stdlib::identifiers::is_identifier; +use ruff_source_file::{LineRanges, NewlineWithTrailingNewline}; +use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::Violation; use crate::checkers::ast::Checker; @@ -18,6 +18,62 @@ use crate::docstrings::styles::SectionStyle; use crate::registry::Rule; use crate::rules::pydocstyle::settings::Convention; +/// ## What it does +/// Checks for function docstrings that include parameters which are not +/// in the function signature. +/// +/// ## Why is this bad? +/// If a docstring documents a parameter which is not in the function signature, +/// it can be misleading to users and/or a sign of incomplete documentation or +/// refactors. +/// +/// ## Example +/// ```python +/// def calculate_speed(distance: float, time: float) -> float: +/// """Calculate speed as distance divided by time. +/// +/// Args: +/// distance: Distance traveled. +/// time: Time spent traveling. +/// acceleration: Rate of change of speed. +/// +/// Returns: +/// Speed as distance divided by time. +/// """ +/// return distance / time +/// ``` +/// +/// Use instead: +/// ```python +/// def calculate_speed(distance: float, time: float) -> float: +/// """Calculate speed as distance divided by time. +/// +/// Args: +/// distance: Distance traveled. +/// time: Time spent traveling. +/// +/// Returns: +/// Speed as distance divided by time. +/// """ +/// return distance / time +/// ``` +#[derive(ViolationMetadata)] +pub(crate) struct DocstringExtraneousParameter { + id: String, +} + +impl Violation for DocstringExtraneousParameter { + #[derive_message_formats] + fn message(&self) -> String { + let DocstringExtraneousParameter { id } = self; + format!("Documented parameter `{id}` is not in the function's signature") + } + + fn fix_title(&self) -> Option { + Some("Remove the extraneous parameter from the docstring".to_string()) + } +} + /// ## What it does /// Checks for functions with `return` statements that do not have "Returns" /// sections in their docstrings. @@ -396,6 +452,19 @@ impl GenericSection { } } +/// A parameter in a docstring with its text range. +#[derive(Debug, Clone)] +struct ParameterEntry<'a> { + name: &'a str, + range: TextRange, +} + +impl Ranged for ParameterEntry<'_> { + fn range(&self) -> TextRange { + self.range + } +} + /// A "Raises" section in a docstring. #[derive(Debug)] struct RaisesSection<'a> { @@ -414,17 +483,46 @@ impl<'a> RaisesSection<'a> { /// a "Raises" section. fn from_section(section: &SectionContext<'a>, style: Option) -> Self { Self { - raised_exceptions: parse_entries(section.following_lines_str(), style), + raised_exceptions: parse_raises(section.following_lines_str(), style), range: section.range(), } } } +/// An "Args" or "Parameters" section in a docstring. +#[derive(Debug)] +struct ParametersSection<'a> { + parameters: Vec>, + range: TextRange, +} + +impl Ranged for ParametersSection<'_> { + fn range(&self) -> TextRange { + self.range + } +} + +impl<'a> ParametersSection<'a> { + /// Return the parameters for the docstring, or `None` if the docstring does not contain + /// an "Args" or "Parameters" section. + fn from_section(section: &SectionContext<'a>, style: Option) -> Self { + Self { + parameters: parse_parameters( + section.following_lines_str(), + section.following_range().start(), + style, + ), + range: section.section_name_range(), + } + } +} + #[derive(Debug, Default)] struct DocstringSections<'a> { returns: Option, yields: Option, raises: Option>, + parameters: Option>, } impl<'a> DocstringSections<'a> { @@ -432,6 +530,10 @@ impl<'a> DocstringSections<'a> { let mut docstring_sections = Self::default(); for section in sections { match section.kind() { + SectionKind::Args | SectionKind::Arguments | SectionKind::Parameters => { + docstring_sections.parameters = + Some(ParametersSection::from_section(§ion, style)); + } SectionKind::Raises => { docstring_sections.raises = Some(RaisesSection::from_section(§ion, style)); } @@ -448,18 +550,22 @@ impl<'a> DocstringSections<'a> { } } -/// Parse the entries in a "Raises" section of a docstring. +/// Parse the entries in a "Parameters" section of a docstring. /// /// Attempts to parse using the specified [`SectionStyle`], falling back to the other style if no /// entries are found. -fn parse_entries(content: &str, style: Option) -> Vec> { +fn parse_parameters( + content: &str, + content_start: TextSize, + style: Option, +) -> Vec> { match style { - Some(SectionStyle::Google) => parse_entries_google(content), - Some(SectionStyle::Numpy) => parse_entries_numpy(content), + Some(SectionStyle::Google) => parse_parameters_google(content, content_start), + Some(SectionStyle::Numpy) => parse_parameters_numpy(content, content_start), None => { - let entries = parse_entries_google(content); + let entries = parse_parameters_google(content, content_start); if entries.is_empty() { - parse_entries_numpy(content) + parse_parameters_numpy(content, content_start) } else { entries } @@ -467,14 +573,134 @@ fn parse_entries(content: &str, style: Option) -> Vec Vec> { + let mut entries: Vec = Vec::new(); + // Find first entry to determine indentation + let Some(first_arg) = content.lines().next() else { + return entries; + }; + let indentation = &first_arg[..first_arg.len() - first_arg.trim_start().len()]; + + let mut current_pos = TextSize::ZERO; + for line in content.lines() { + let line_start = current_pos; + current_pos = content.full_line_end(line_start); + + if let Some(entry) = line.strip_prefix(indentation) { + if entry + .chars() + .next() + .is_some_and(|first_char| !first_char.is_whitespace()) + { + let Some((before_colon, _)) = entry.split_once(':') else { + continue; + }; + if let Some(param) = before_colon.split_whitespace().next() { + let param_name = param.trim_start_matches('*'); + if is_identifier(param_name) { + let param_start = line_start + indentation.text_len(); + let param_end = param_start + param.text_len(); + + entries.push(ParameterEntry { + name: param_name, + range: TextRange::new( + content_start + param_start, + content_start + param_end, + ), + }); + } + } + } + } + } + entries +} + +/// Parses NumPy-style "Parameters" sections of the form: +/// +/// ```python +/// Parameters +/// ---------- +/// a : int +/// The first number to add. +/// b : int +/// The second number to add. +/// ``` +fn parse_parameters_numpy(content: &str, content_start: TextSize) -> Vec> { + let mut entries: Vec = Vec::new(); + let mut lines = content.lines(); + let Some(dashes) = lines.next() else { + return entries; + }; + let indentation = &dashes[..dashes.len() - dashes.trim_start().len()]; + + let mut current_pos = content.full_line_end(dashes.text_len()); + for potential in lines { + let line_start = current_pos; + current_pos = content.full_line_end(line_start); + + if let Some(entry) = potential.strip_prefix(indentation) { + if entry + .chars() + .next() + .is_some_and(|first_char| !first_char.is_whitespace()) + { + if let Some(before_colon) = entry.split(':').next() { + let param = before_colon.trim_end(); + let param_name = param.trim_start_matches('*'); + if is_identifier(param_name) { + let param_start = line_start + indentation.text_len(); + let param_end = param_start + param.text_len(); + + entries.push(ParameterEntry { + name: param_name, + range: TextRange::new( + content_start + param_start, + content_start + param_end, + ), + }); + } + } + } + } + } + entries +} + +/// Parse the entries in a "Raises" section of a docstring. +/// +/// Attempts to parse using the specified [`SectionStyle`], falling back to the other style if no +/// entries are found. +fn parse_raises(content: &str, style: Option) -> Vec> { + match style { + Some(SectionStyle::Google) => parse_raises_google(content), + Some(SectionStyle::Numpy) => parse_raises_numpy(content), + None => { + let entries = parse_raises_google(content); + if entries.is_empty() { + parse_raises_numpy(content) + } else { + entries + } + } + } +} + +/// Parses Google-style "Raises" section of the form: /// /// ```python /// Raises: /// FasterThanLightError: If speed is greater than the speed of light. /// DivisionByZero: If attempting to divide by zero. /// ``` -fn parse_entries_google(content: &str) -> Vec> { +fn parse_raises_google(content: &str) -> Vec> { let mut entries: Vec = Vec::new(); for potential in content.lines() { let Some(colon_idx) = potential.find(':') else { @@ -486,7 +712,7 @@ fn parse_entries_google(content: &str) -> Vec> { entries } -/// Parses NumPy-style docstring sections of the form: +/// Parses NumPy-style "Raises" section of the form: /// /// ```python /// Raises @@ -496,7 +722,7 @@ fn parse_entries_google(content: &str) -> Vec> { /// DivisionByZero /// If attempting to divide by zero. /// ``` -fn parse_entries_numpy(content: &str) -> Vec> { +fn parse_raises_numpy(content: &str) -> Vec> { let mut entries: Vec = Vec::new(); let mut lines = content.lines(); let Some(dashes) = lines.next() else { @@ -867,6 +1093,17 @@ fn is_generator_function_annotated_as_returning_none( .is_some_and(GeneratorOrIteratorArguments::indicates_none_returned) } +fn parameters_from_signature<'a>(docstring: &'a Docstring) -> Vec<&'a str> { + let mut parameters = Vec::new(); + let Some(function) = docstring.definition.as_function_def() else { + return parameters; + }; + for param in &function.parameters { + parameters.push(param.name()); + } + parameters +} + fn is_one_line(docstring: &Docstring) -> bool { let mut non_empty_line_count = 0; for line in NewlineWithTrailingNewline::from(docstring.body().as_str()) { @@ -880,7 +1117,7 @@ fn is_one_line(docstring: &Docstring) -> bool { true } -/// DOC201, DOC202, DOC402, DOC403, DOC501, DOC502 +/// DOC102, DOC201, DOC202, DOC402, DOC403, DOC501, DOC502 pub(crate) fn check_docstring( checker: &Checker, definition: &Definition, @@ -920,6 +1157,8 @@ pub(crate) fn check_docstring( visitor.finish() }; + let signature_parameters = parameters_from_signature(docstring); + // DOC201 if checker.is_rule_enabled(Rule::DocstringMissingReturns) { if should_document_returns(function_def) @@ -1008,6 +1247,25 @@ pub(crate) fn check_docstring( } } + // DOC102 + if checker.is_rule_enabled(Rule::DocstringExtraneousParameter) { + // Don't report extraneous parameters if the signature defines *args or **kwargs + if function_def.parameters.vararg.is_none() && function_def.parameters.kwarg.is_none() { + if let Some(docstring_params) = docstring_sections.parameters { + for docstring_param in &docstring_params.parameters { + if !signature_parameters.contains(&docstring_param.name) { + checker.report_diagnostic( + DocstringExtraneousParameter { + id: docstring_param.name.to_string(), + }, + docstring_param.range(), + ); + } + } + } + } + } + // Avoid applying "extraneous" rules to abstract methods. An abstract method's docstring _could_ // document that it raises an exception without including the exception in the implementation. if !visibility::is_abstract(&function_def.decorator_list, semantic) { diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_google.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_google.py.snap new file mode 100644 index 0000000000..1a0dd86341 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_google.py.snap @@ -0,0 +1,180 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC102 Documented parameter `a` is not in the function's signature + --> DOC102_google.py:7:9 + | +6 | Args: +7 | a (int): The first number to add. + | ^ +8 | b (int): The second number to add. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `multiplier` is not in the function's signature + --> DOC102_google.py:23:9 + | +21 | Args: +22 | lst (list of int): A list of integers. +23 | multiplier (int): The multiplier for each element in the list. + | ^^^^^^^^^^ +24 | +25 | Returns: + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `numbers` is not in the function's signature + --> DOC102_google.py:37:9 + | +36 | Args: +37 | numbers (list of int): A list of integers to search through. + | ^^^^^^^ +38 | +39 | Returns: + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `name` is not in the function's signature + --> DOC102_google.py:51:9 + | +50 | Args: +51 | name (str): The name of the user. + | ^^^^ +52 | age (int): The age of the user. +53 | email (str): The user's email address. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `age` is not in the function's signature + --> DOC102_google.py:52:9 + | +50 | Args: +51 | name (str): The name of the user. +52 | age (int): The age of the user. + | ^^^ +53 | email (str): The user's email address. +54 | location (str): The location of the user. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `email` is not in the function's signature + --> DOC102_google.py:53:9 + | +51 | name (str): The name of the user. +52 | age (int): The age of the user. +53 | email (str): The user's email address. + | ^^^^^ +54 | location (str): The location of the user. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `tax_rate` is not in the function's signature + --> DOC102_google.py:74:9 + | +72 | Args: +73 | item_prices (list of float): A list of prices for each item. +74 | tax_rate (float): The tax rate to apply. + | ^^^^^^^^ +75 | discount (float): The discount to subtract from the total. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `to_address` is not in the function's signature + --> DOC102_google.py:94:9 + | +92 | subject (str): The subject of the email. +93 | body (str): The content of the email. +94 | to_address (str): The recipient's email address. + | ^^^^^^^^^^ +95 | cc_address (str, optional): The email address for CC. Defaults to None. +96 | bcc_address (str, optional): The email address for BCC. Defaults to None. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `cc_address` is not in the function's signature + --> DOC102_google.py:95:9 + | +93 | body (str): The content of the email. +94 | to_address (str): The recipient's email address. +95 | cc_address (str, optional): The email address for CC. Defaults to None. + | ^^^^^^^^^^ +96 | bcc_address (str, optional): The email address for BCC. Defaults to None. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `items` is not in the function's signature + --> DOC102_google.py:126:9 + | +124 | Args: +125 | order_id (int): The unique identifier for the order. +126 | *items (str): Variable length argument list of items in the order. + | ^^^^^^ +127 | **details (dict): Additional details such as shipping method and address. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `details` is not in the function's signature + --> DOC102_google.py:127:9 + | +125 | order_id (int): The unique identifier for the order. +126 | *items (str): Variable length argument list of items in the order. +127 | **details (dict): Additional details such as shipping method and address. + | ^^^^^^^^^ +128 | +129 | Returns: + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `value` is not in the function's signature + --> DOC102_google.py:150:13 + | +149 | Args: +150 | value (int, optional): The initial value of the calculator. Defaults to 0. + | ^^^^^ +151 | """ +152 | self.value = value + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `number` is not in the function's signature + --> DOC102_google.py:160:13 + | +159 | Args: +160 | number (int or float): The number to add to the current value. + | ^^^^^^ +161 | +162 | Returns: + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `value_str` is not in the function's signature + --> DOC102_google.py:175:13 + | +174 | Args: +175 | value_str (str): The string representing the initial value. + | ^^^^^^^^^ +176 | +177 | Returns: + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `number` is not in the function's signature + --> DOC102_google.py:190:13 + | +189 | Args: +190 | number (any): The value to check. + | ^^^^^^ +191 | +192 | Returns: + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `a` is not in the function's signature + --> DOC102_google.py:258:9 + | +257 | Args: +258 | a: The first number to add. + | ^ +259 | b: The second number to add. + | +help: Remove the extraneous parameter from the docstring diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_numpy.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_numpy.py.snap new file mode 100644 index 0000000000..c4566953d0 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-parameter_DOC102_numpy.py.snap @@ -0,0 +1,189 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC102 Documented parameter `a` is not in the function's signature + --> DOC102_numpy.py:8:5 + | + 6 | Parameters + 7 | ---------- + 8 | a : int + | ^ + 9 | The first number to add. +10 | b : int + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `multiplier` is not in the function's signature + --> DOC102_numpy.py:30:5 + | +28 | lst : list of int +29 | A list of integers. +30 | multiplier : int + | ^^^^^^^^^^ +31 | The multiplier for each element in the list. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `numbers` is not in the function's signature + --> DOC102_numpy.py:48:5 + | +46 | Parameters +47 | ---------- +48 | numbers : list of int + | ^^^^^^^ +49 | A list of integers to search through. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `name` is not in the function's signature + --> DOC102_numpy.py:66:5 + | +64 | Parameters +65 | ---------- +66 | name : str + | ^^^^ +67 | The name of the user. +68 | age : int + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `age` is not in the function's signature + --> DOC102_numpy.py:68:5 + | +66 | name : str +67 | The name of the user. +68 | age : int + | ^^^ +69 | The age of the user. +70 | email : str + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `email` is not in the function's signature + --> DOC102_numpy.py:70:5 + | +68 | age : int +69 | The age of the user. +70 | email : str + | ^^^^^ +71 | The user's email address. +72 | location : str, optional + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `tax_rate` is not in the function's signature + --> DOC102_numpy.py:97:5 + | +95 | item_prices : list of float +96 | A list of prices for each item. +97 | tax_rate : float + | ^^^^^^^^ +98 | The tax rate to apply. +99 | discount : float + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `to_address` is not in the function's signature + --> DOC102_numpy.py:124:5 + | +122 | body : str +123 | The content of the email. +124 | to_address : str + | ^^^^^^^^^^ +125 | The recipient's email address. +126 | cc_address : str, optional + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `cc_address` is not in the function's signature + --> DOC102_numpy.py:126:5 + | +124 | to_address : str +125 | The recipient's email address. +126 | cc_address : str, optional + | ^^^^^^^^^^ +127 | The email address for CC, by default None. +128 | bcc_address : str, optional + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `items` is not in the function's signature + --> DOC102_numpy.py:168:5 + | +166 | order_id : int +167 | The unique identifier for the order. +168 | *items : str + | ^^^^^^ +169 | Variable length argument list of items in the order. +170 | **details : dict + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `details` is not in the function's signature + --> DOC102_numpy.py:170:5 + | +168 | *items : str +169 | Variable length argument list of items in the order. +170 | **details : dict + | ^^^^^^^^^ +171 | Additional details such as shipping method and address. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `value` is not in the function's signature + --> DOC102_numpy.py:197:9 + | +195 | Parameters +196 | ---------- +197 | value : int, optional + | ^^^^^ +198 | The initial value of the calculator, by default 0. +199 | """ + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `number` is not in the function's signature + --> DOC102_numpy.py:209:9 + | +207 | Parameters +208 | ---------- +209 | number : int or float + | ^^^^^^ +210 | The first number to add. +211 | number2 : int or float + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `value_str` is not in the function's signature + --> DOC102_numpy.py:230:9 + | +228 | Parameters +229 | ---------- +230 | value_str : str + | ^^^^^^^^^ +231 | The string representing the initial value. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `number` is not in the function's signature + --> DOC102_numpy.py:249:9 + | +247 | Parameters +248 | ---------- +249 | number : any + | ^^^^^^ +250 | The value to check. + | +help: Remove the extraneous parameter from the docstring + +DOC102 Documented parameter `a` is not in the function's signature + --> DOC102_numpy.py:300:5 + | +298 | Parameters +299 | ---------- +300 | a + | ^ +301 | The first number to add. +302 | b + | +help: Remove the extraneous parameter from the docstring diff --git a/ruff.schema.json b/ruff.schema.json index 33ccab9364..b44f308d65 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3186,6 +3186,9 @@ "DJ012", "DJ013", "DOC", + "DOC1", + "DOC10", + "DOC102", "DOC2", "DOC20", "DOC201", From e64d77278830954a323d227e8f9f714c1d0e4c57 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:56:32 -0400 Subject: [PATCH 074/113] Standardize syntax error construction (#20903) Summary -- This PR unifies the two different ways Ruff and ty construct syntax errors. Ruff has been storing the primary message in the diagnostic itself, while ty attached the message to the primary annotation: ``` > ruff check try.py invalid-syntax: name capture `x` makes remaining patterns unreachable --> try.py:2:10 | 1 | match 42: 2 | case x: ... | ^ 3 | case y: ... | Found 1 error. > uvx ty check try.py WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. Checking ------------------------------------------------------------ 1/1 files error[invalid-syntax] --> try.py:2:10 | 1 | match 42: 2 | case x: ... | ^ name capture `x` makes remaining patterns unreachable 3 | case y: ... | Found 1 diagnostic ``` I think there are benefits to both approaches, and I do like ty's version, but I feel like we should pick one (and it might help with #20901 eventually). I slightly prefer Ruff's version, so I went with that. Hopefully this isn't too controversial, but I'm happy to close this if it is. Note that this shouldn't change any other diagnostic formats in ty because [`Diagnostic::primary_message`](https://github.com/astral-sh/ruff/blob/98d27c412810e157f8a65ea75726d66676628225/crates/ruff_db/src/diagnostic/mod.rs#L177) was already falling back to the primary annotation message if the diagnostic message was empty. As a result, I think this change will partially resolve the FIXME therein. Test Plan -- Existing tests with updated snapshots --- crates/ruff/src/diagnostics.rs | 7 +--- crates/ruff_db/src/diagnostic/mod.rs | 7 +--- crates/ruff_linter/src/linter.rs | 7 ++-- crates/ruff_linter/src/message/mod.rs | 24 +---------- crates/ruff_linter/src/test.rs | 6 +-- crates/ty/tests/cli/main.rs | 40 +++++++++---------- crates/ty/tests/cli/python_environment.rs | 16 ++++---- ...ehensio…_-_Python_3.10_(96aa8ec77d46553d).snap | 4 +- ...tatement_-_Before_3.10_(2545eaa83b635b8b).snap | 4 +- 9 files changed, 43 insertions(+), 72 deletions(-) diff --git a/crates/ruff/src/diagnostics.rs b/crates/ruff/src/diagnostics.rs index 3376133ed7..ef7623145a 100644 --- a/crates/ruff/src/diagnostics.rs +++ b/crates/ruff/src/diagnostics.rs @@ -13,7 +13,6 @@ use log::{debug, warn}; use ruff_db::diagnostic::Diagnostic; use ruff_linter::codes::Rule; use ruff_linter::linter::{FixTable, FixerResult, LinterResult, ParseSource, lint_fix, lint_only}; -use ruff_linter::message::create_syntax_error_diagnostic; use ruff_linter::package::PackageRoot; use ruff_linter::pyproject_toml::lint_pyproject_toml; use ruff_linter::settings::types::UnsafeFixes; @@ -103,11 +102,7 @@ impl Diagnostics { let name = path.map_or_else(|| "-".into(), Path::to_string_lossy); let dummy = SourceFileBuilder::new(name, "").finish(); Self::new( - vec![create_syntax_error_diagnostic( - dummy, - err, - TextRange::default(), - )], + vec![Diagnostic::invalid_syntax(dummy, err, TextRange::default())], FxHashMap::default(), ) } diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 230cda7a84..9d9ff2dbc3 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -84,17 +84,14 @@ impl Diagnostic { /// at time of writing, `ruff_db` depends on `ruff_python_parser` instead of /// the other way around. And since we want to do this conversion in a couple /// places, it makes sense to centralize it _somewhere_. So it's here for now. - /// - /// Note that `message` is stored in the primary annotation, _not_ in the primary diagnostic - /// message. pub fn invalid_syntax( span: impl Into, message: impl IntoDiagnosticMessage, range: impl Ranged, ) -> Diagnostic { - let mut diag = Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, ""); + let mut diag = Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, message); let span = span.into().with_range(range.range()); - diag.annotate(Annotation::primary(span).message(message)); + diag.annotate(Annotation::primary(span)); diag } diff --git a/crates/ruff_linter/src/linter.rs b/crates/ruff_linter/src/linter.rs index 56e180bfab..1386704920 100644 --- a/crates/ruff_linter/src/linter.rs +++ b/crates/ruff_linter/src/linter.rs @@ -24,7 +24,6 @@ use crate::checkers::tokens::check_tokens; use crate::directives::Directives; use crate::doc_lines::{doc_lines_from_ast, doc_lines_from_tokens}; use crate::fix::{FixResult, fix_file}; -use crate::message::create_syntax_error_diagnostic; use crate::noqa::add_noqa; use crate::package::PackageRoot; use crate::registry::Rule; @@ -496,15 +495,15 @@ fn diagnostics_to_messages( parse_errors .iter() .map(|parse_error| { - create_syntax_error_diagnostic(source_file.clone(), &parse_error.error, parse_error) + Diagnostic::invalid_syntax(source_file.clone(), &parse_error.error, parse_error) }) .chain(unsupported_syntax_errors.iter().map(|syntax_error| { - create_syntax_error_diagnostic(source_file.clone(), syntax_error, syntax_error) + Diagnostic::invalid_syntax(source_file.clone(), syntax_error, syntax_error) })) .chain( semantic_syntax_errors .iter() - .map(|error| create_syntax_error_diagnostic(source_file.clone(), error, error)), + .map(|error| Diagnostic::invalid_syntax(source_file.clone(), error, error)), ) .chain(diagnostics.into_iter().map(|mut diagnostic| { if let Some(range) = diagnostic.range() { diff --git a/crates/ruff_linter/src/message/mod.rs b/crates/ruff_linter/src/message/mod.rs index f552d0df51..2525322fd9 100644 --- a/crates/ruff_linter/src/message/mod.rs +++ b/crates/ruff_linter/src/message/mod.rs @@ -16,7 +16,7 @@ use ruff_db::files::File; pub use grouped::GroupedEmitter; use ruff_notebook::NotebookIndex; use ruff_source_file::{SourceFile, SourceFileBuilder}; -use ruff_text_size::{Ranged, TextRange, TextSize}; +use ruff_text_size::{TextRange, TextSize}; pub use sarif::SarifEmitter; use crate::Fix; @@ -26,24 +26,6 @@ use crate::settings::types::{OutputFormat, RuffOutputFormat}; mod grouped; mod sarif; -/// Creates a `Diagnostic` from a syntax error, with the format expected by Ruff. -/// -/// This is almost identical to `ruff_db::diagnostic::create_syntax_error_diagnostic`, except the -/// `message` is stored as the primary diagnostic message instead of on the primary annotation. -/// -/// TODO(brent) These should be unified at some point, but we keep them separate for now to avoid a -/// ton of snapshot changes while combining ruff's diagnostic type with `Diagnostic`. -pub fn create_syntax_error_diagnostic( - span: impl Into, - message: impl std::fmt::Display, - range: impl Ranged, -) -> Diagnostic { - let mut diag = Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, message); - let span = span.into().with_range(range.range()); - diag.annotate(Annotation::primary(span)); - diag -} - /// Create a `Diagnostic` from a panic. pub fn create_panic_diagnostic(error: &PanicError, path: Option<&Path>) -> Diagnostic { let mut diagnostic = Diagnostic::new( @@ -260,8 +242,6 @@ mod tests { use crate::message::{Emitter, EmitterContext, create_lint_diagnostic}; use crate::{Edit, Fix}; - use super::create_syntax_error_diagnostic; - pub(super) fn create_syntax_error_diagnostics() -> Vec { let source = r"from os import @@ -274,7 +254,7 @@ if call(foo .errors() .iter() .map(|parse_error| { - create_syntax_error_diagnostic(source_file.clone(), &parse_error.error, parse_error) + Diagnostic::invalid_syntax(source_file.clone(), &parse_error.error, parse_error) }) .collect() } diff --git a/crates/ruff_linter/src/test.rs b/crates/ruff_linter/src/test.rs index 01eb02705e..67a6728404 100644 --- a/crates/ruff_linter/src/test.rs +++ b/crates/ruff_linter/src/test.rs @@ -26,7 +26,7 @@ use ruff_source_file::SourceFileBuilder; use crate::codes::Rule; use crate::fix::{FixResult, fix_file}; use crate::linter::check_path; -use crate::message::{EmitterContext, create_syntax_error_diagnostic}; +use crate::message::EmitterContext; use crate::package::PackageRoot; use crate::packaging::detect_package_root; use crate::settings::types::UnsafeFixes; @@ -405,7 +405,7 @@ Either ensure you always emit a fix or change `Violation::FIX_AVAILABILITY` to e diagnostic }) .chain(parsed.errors().iter().map(|parse_error| { - create_syntax_error_diagnostic(source_code.clone(), &parse_error.error, parse_error) + Diagnostic::invalid_syntax(source_code.clone(), &parse_error.error, parse_error) })) .sorted_by(Diagnostic::ruff_start_ordering) .collect(); @@ -419,7 +419,7 @@ fn print_syntax_errors(errors: &[ParseError], path: &Path, source: &SourceKind) let messages: Vec<_> = errors .iter() .map(|parse_error| { - create_syntax_error_diagnostic(source_file.clone(), &parse_error.error, parse_error) + Diagnostic::invalid_syntax(source_file.clone(), &parse_error.error, parse_error) }) .collect(); diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 45d7304be5..e911e300c0 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -92,42 +92,42 @@ fn test_quiet_output() -> anyhow::Result<()> { #[test] fn test_run_in_sub_directory() -> anyhow::Result<()> { let case = CliTest::with_files([("test.py", "~"), ("subdir/nothing", "")])?; - assert_cmd_snapshot!(case.command().current_dir(case.root().join("subdir")).arg(".."), @r###" + assert_cmd_snapshot!(case.command().current_dir(case.root().join("subdir")).arg(".."), @r" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Expected an expression --> /test.py:1:2 | 1 | ~ - | ^ Expected an expression + | ^ | Found 1 diagnostic ----- stderr ----- - "###); + "); Ok(()) } #[test] fn test_include_hidden_files_by_default() -> anyhow::Result<()> { let case = CliTest::with_files([(".test.py", "~")])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Expected an expression --> .test.py:1:2 | 1 | ~ - | ^ Expected an expression + | ^ | Found 1 diagnostic ----- stderr ----- - "###); + "); Ok(()) } @@ -146,57 +146,57 @@ fn test_respect_ignore_files() -> anyhow::Result<()> { "###); // Test that we can set to false via CLI - assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r###" + assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Expected an expression --> test.py:1:2 | 1 | ~ - | ^ Expected an expression + | ^ | Found 1 diagnostic ----- stderr ----- - "###); + "); // Test that we can set to false via config file case.write_file("ty.toml", "src.respect-ignore-files = false")?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Expected an expression --> test.py:1:2 | 1 | ~ - | ^ Expected an expression + | ^ | Found 1 diagnostic ----- stderr ----- - "###); + "); // Ensure CLI takes precedence case.write_file("ty.toml", "src.respect-ignore-files = true")?; - assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r###" + assert_cmd_snapshot!(case.command().arg("--no-respect-ignore-files"), @r" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Expected an expression --> test.py:1:2 | 1 | ~ - | ^ Expected an expression + | ^ | Found 1 diagnostic ----- stderr ----- - "###); + "); Ok(()) } diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 4168da8e4c..4f3e442473 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -655,15 +655,15 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any ), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Cannot use `match` statement on Python 3.8 (syntax was added in Python 3.10) --> test.py:2:1 | 2 | match object(): - | ^^^^^ Cannot use `match` statement on Python 3.8 (syntax was added in Python 3.10) + | ^^^^^ 3 | case int(): 4 | pass | @@ -678,17 +678,17 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any Found 1 diagnostic ----- stderr ----- - "###); + "#); - assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r###" + assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r" success: false exit_code: 1 ----- stdout ----- - error[invalid-syntax] + error[invalid-syntax]: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10) --> test.py:2:1 | 2 | match object(): - | ^^^^^ Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10) + | ^^^^^ 3 | case int(): 4 | pass | @@ -697,7 +697,7 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any Found 1 diagnostic ----- stderr ----- - "###); + "); Ok(()) } diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`async`_comprehensio…_-_Python_3.10_(96aa8ec77d46553d).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`async`_comprehensio…_-_Python_3.10_(96aa8ec77d46553d).snap index f25e7b1bac..975be8520f 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`async`_comprehensio…_-_Python_3.10_(96aa8ec77d46553d).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`async`_comprehensio…_-_Python_3.10_(96aa8ec77d46553d).snap @@ -33,13 +33,13 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syn # Diagnostics ``` -error[invalid-syntax] +error[invalid-syntax]: cannot use an asynchronous comprehension inside of a synchronous comprehension on Python 3.10 (syntax was added in 3.11) --> src/mdtest_snippet.py:6:19 | 4 | async def f(): 5 | # error: 19 [invalid-syntax] "cannot use an asynchronous comprehension inside of a synchronous comprehension on Python 3.10 (syntax… 6 | return {n: [x async for x in elements(n)] for n in range(3)} - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ cannot use an asynchronous comprehension inside of a synchronous comprehension on Python 3.10 (syntax was added in 3.11) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 7 | async def test(): 8 | # error: [not-iterable] "Object of type `range` is not async-iterable" | diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/version_related_synt…_-_Version-related_synt…_-_`match`_statement_-_Before_3.10_(2545eaa83b635b8b).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/version_related_synt…_-_Version-related_synt…_-_`match`_statement_-_Before_3.10_(2545eaa83b635b8b).snap index 2500cc4544..a2cea52ff9 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/version_related_synt…_-_Version-related_synt…_-_`match`_statement_-_Before_3.10_(2545eaa83b635b8b).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/version_related_synt…_-_Version-related_synt…_-_`match`_statement_-_Before_3.10_(2545eaa83b635b8b).snap @@ -20,11 +20,11 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/version_rela # Diagnostics ``` -error[invalid-syntax] +error[invalid-syntax]: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10) --> src/mdtest_snippet.py:1:1 | 1 | match 2: # error: 1 [invalid-syntax] "Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)" - | ^^^^^ Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10) + | ^^^^^ 2 | case 1: 3 | print("it's one") | From 2bffef59665ce7d2630dfd72ee99846663660db8 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 16 Oct 2025 12:44:13 -0500 Subject: [PATCH 075/113] Bump 0.14.1 (#20925) --- CHANGELOG.md | 58 +++++++++++++++++++++++++++++++ Cargo.lock | 6 ++-- README.md | 6 ++-- crates/ruff/Cargo.toml | 2 +- crates/ruff_linter/Cargo.toml | 2 +- crates/ruff_wasm/Cargo.toml | 2 +- docs/integrations.md | 8 ++--- docs/tutorial.md | 2 +- pyproject.toml | 2 +- scripts/benchmarks/pyproject.toml | 2 +- 10 files changed, 74 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96954d38c5..d95fa96277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## 0.14.1 + +Released on 2025-10-16. + +### Preview features + +- [formatter] Remove parentheses around multiple exception types on Python 3.14+ ([#20768](https://github.com/astral-sh/ruff/pull/20768)) +- \[`flake8-bugbear`\] Omit annotation in preview fix for `B006` ([#20877](https://github.com/astral-sh/ruff/pull/20877)) +- \[`flake8-logging-format`\] Avoid dropping implicitly concatenated pieces in the `G004` fix ([#20793](https://github.com/astral-sh/ruff/pull/20793)) +- \[`pydoclint`\] Implement `docstring-extraneous-parameter` (`DOC102`) ([#20376](https://github.com/astral-sh/ruff/pull/20376)) +- \[`pyupgrade`\] Extend `UP019` to detect `typing_extensions.Text` (`UP019`) ([#20825](https://github.com/astral-sh/ruff/pull/20825)) +- \[`pyupgrade`\] Fix false negative for `TypeVar` with default argument in `non-pep695-generic-class` (`UP046`) ([#20660](https://github.com/astral-sh/ruff/pull/20660)) + +### Bug fixes + +- Fix false negatives in `Truthiness::from_expr` for lambdas, generators, and f-strings ([#20704](https://github.com/astral-sh/ruff/pull/20704)) +- Fix syntax error false positives for escapes and quotes in f-strings ([#20867](https://github.com/astral-sh/ruff/pull/20867)) +- Fix syntax error false positives on parenthesized context managers ([#20846](https://github.com/astral-sh/ruff/pull/20846)) +- \[`fastapi`\] Fix false positives for path parameters that FastAPI doesn't recognize (`FAST003`) ([#20687](https://github.com/astral-sh/ruff/pull/20687)) +- \[`flake8-pyi`\] Fix operator precedence by adding parentheses when needed (`PYI061`) ([#20508](https://github.com/astral-sh/ruff/pull/20508)) +- \[`ruff`\] Suppress diagnostic for f-string interpolations with debug text (`RUF010`) ([#20525](https://github.com/astral-sh/ruff/pull/20525)) + +### Rule changes + +- \[`airflow`\] Add warning to `airflow.datasets.DatasetEvent` usage (`AIR301`) ([#20551](https://github.com/astral-sh/ruff/pull/20551)) +- \[`flake8-bugbear`\] Mark `B905` and `B912` fixes as unsafe ([#20695](https://github.com/astral-sh/ruff/pull/20695)) +- Use `DiagnosticTag` for more rules - changes display in editors ([#20758](https://github.com/astral-sh/ruff/pull/20758),[#20734](https://github.com/astral-sh/ruff/pull/20734)) + +### Documentation + +- Update Python compatibility from 3.13 to 3.14 in README.md ([#20852](https://github.com/astral-sh/ruff/pull/20852)) +- Update `lint.flake8-type-checking.quoted-annotations` docs ([#20765](https://github.com/astral-sh/ruff/pull/20765)) +- Update setup instructions for Zed 0.208.0+ ([#20902](https://github.com/astral-sh/ruff/pull/20902)) +- \[`flake8-datetimez`\] Clarify docs for several rules ([#20778](https://github.com/astral-sh/ruff/pull/20778)) +- Fix typo in `RUF015` description ([#20873](https://github.com/astral-sh/ruff/pull/20873)) + +### Other changes + +- Reduce binary size ([#20863](https://github.com/astral-sh/ruff/pull/20863)) +- Improved error recovery for unclosed strings (including f- and t-strings) ([#20848](https://github.com/astral-sh/ruff/pull/20848)) + +### Contributors + +- [@ntBre](https://github.com/ntBre) +- [@Paillat-dev](https://github.com/Paillat-dev) +- [@terror](https://github.com/terror) +- [@pieterh-oai](https://github.com/pieterh-oai) +- [@MichaReiser](https://github.com/MichaReiser) +- [@TaKO8Ki](https://github.com/TaKO8Ki) +- [@ageorgou](https://github.com/ageorgou) +- [@danparizher](https://github.com/danparizher) +- [@mgaitan](https://github.com/mgaitan) +- [@augustelalande](https://github.com/augustelalande) +- [@dylwil3](https://github.com/dylwil3) +- [@Lee-W](https://github.com/Lee-W) +- [@injust](https://github.com/injust) +- [@CarrotManMatt](https://github.com/CarrotManMatt) + ## 0.14.0 Released on 2025-10-07. diff --git a/Cargo.lock b/Cargo.lock index c056606c29..1442583e70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2815,7 +2815,7 @@ dependencies = [ [[package]] name = "ruff" -version = "0.14.0" +version = "0.14.1" dependencies = [ "anyhow", "argfile", @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "ruff_linter" -version = "0.14.0" +version = "0.14.1" dependencies = [ "aho-corasick", "anyhow", @@ -3426,7 +3426,7 @@ dependencies = [ [[package]] name = "ruff_wasm" -version = "0.14.0" +version = "0.14.1" dependencies = [ "console_error_panic_hook", "console_log", diff --git a/README.md b/README.md index 28a4e6b37b..50f539cde7 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh powershell -c "irm https://astral.sh/ruff/install.ps1 | iex" # For a specific version. -curl -LsSf https://astral.sh/ruff/0.14.0/install.sh | sh -powershell -c "irm https://astral.sh/ruff/0.14.0/install.ps1 | iex" +curl -LsSf https://astral.sh/ruff/0.14.1/install.sh | sh +powershell -c "irm https://astral.sh/ruff/0.14.1/install.ps1 | iex" ``` You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff), @@ -182,7 +182,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.1 hooks: # Run the linter. - id: ruff-check diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index 7bbce1f163..8dbd98d449 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff" -version = "0.14.0" +version = "0.14.1" publish = true authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index 5deb8ffb3e..c655f357f5 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_linter" -version = "0.14.0" +version = "0.14.1" publish = false authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_wasm/Cargo.toml b/crates/ruff_wasm/Cargo.toml index ad2ec2c3f5..e5b70f3b80 100644 --- a/crates/ruff_wasm/Cargo.toml +++ b/crates/ruff_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_wasm" -version = "0.14.0" +version = "0.14.1" publish = false authors = { workspace = true } edition = { workspace = true } diff --git a/docs/integrations.md b/docs/integrations.md index 1c561760e0..50cf0c1136 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -80,7 +80,7 @@ You can add the following configuration to `.gitlab-ci.yml` to run a `ruff forma stage: build interruptible: true image: - name: ghcr.io/astral-sh/ruff:0.14.0-alpine + name: ghcr.io/astral-sh/ruff:0.14.1-alpine before_script: - cd $CI_PROJECT_DIR - ruff --version @@ -106,7 +106,7 @@ Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-c ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.1 hooks: # Run the linter. - id: ruff-check @@ -119,7 +119,7 @@ To enable lint fixes, add the `--fix` argument to the lint hook: ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.1 hooks: # Run the linter. - id: ruff-check @@ -133,7 +133,7 @@ To avoid running on Jupyter Notebooks, remove `jupyter` from the list of allowed ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.1 hooks: # Run the linter. - id: ruff-check diff --git a/docs/tutorial.md b/docs/tutorial.md index 7e8f712eb6..7e3d0bd0d1 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -369,7 +369,7 @@ This tutorial has focused on Ruff's command-line interface, but Ruff can also be ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.1 hooks: # Run the linter. - id: ruff diff --git a/pyproject.toml b/pyproject.toml index 87b8bf9ee0..f3727fd847 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ruff" -version = "0.14.0" +version = "0.14.1" description = "An extremely fast Python linter and code formatter, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] readme = "README.md" diff --git a/scripts/benchmarks/pyproject.toml b/scripts/benchmarks/pyproject.toml index 7c491bf099..6bce2307a9 100644 --- a/scripts/benchmarks/pyproject.toml +++ b/scripts/benchmarks/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scripts" -version = "0.14.0" +version = "0.14.1" description = "" authors = ["Charles Marsh "] From 8dad58de375c4802d055ed572671bb1482a27493 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 16 Oct 2025 20:49:11 +0200 Subject: [PATCH 076/113] [ty] Support dataclass-transform `field_specifiers` (#20888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for the `field_specifiers` parameter on `dataclass_transform` decorator calls. closes https://github.com/astral-sh/ty/issues/1068 ## Conformance test results All true positives :heavy_check_mark: ## Ecosystem analysis * `trio`: this is the kind of change that I would expect from this PR. The code makes use of a dataclass `Outcome` with a `_unwrapped: bool = attr.ib(default=False, eq=False, init=False)` field that is excluded from the `__init__` signature, so we now see a bunch of constructor-call-related errors going away. * `home-assistant/core`: They have a `domain: str = attr.ib(init=False, repr=False)` field and then use ```py @domain.default def _domain_default(self) -> str: # … ``` This accesses the `default` attribute on `dataclasses.Field[…]` with a type of `default: _T | Literal[_MISSING_TYPE.MISSING]`, so we get those "Object of type `_MISSING_TYPE` is not callable" errors. I don't really understand how that is supposed to work. Even if `_MISSING_TYPE` would be absent from that union, what does this try to call? pyright also issues an error and it doesn't seem to work at runtime? So this looks like a true positive? * `attrs`: Similar here. There are some new diagnostics on code that tries to access `.validator` on a field. This *does* work at runtime, but I'm not sure how that is supposed to type-check (without a [custom plugin](https://github.com/python/mypy/blob/2c6c3959356674262d9b2c2dc43a33486e807a9c/mypy/plugins/attrs.py#L575-L602)). pyright errors on this as well. * A handful of new false positives because we don't support `alias` yet ## Test Plan Updated tests. --- .../mdtest/dataclasses/dataclass_transform.md | 146 ++++++++++- crates/ty_python_semantic/src/types.rs | 105 +++++--- .../ty_python_semantic/src/types/call/bind.rs | 232 ++++++++++-------- crates/ty_python_semantic/src/types/class.rs | 77 +++--- .../ty_python_semantic/src/types/function.rs | 28 ++- .../src/types/infer/builder.rs | 77 +++++- .../src/types/type_ordering.rs | 8 +- 7 files changed, 475 insertions(+), 198 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md index 79b3e3c49a..b9cd306a35 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md @@ -461,7 +461,7 @@ The [`typing.dataclass_transform`] specification also allows classes (such as `d to be listed in `field_specifiers`, but it is currently unclear how this should work, and other type checkers do not seem to support this either. -### Basic example +### For function-based transformers ```py from typing_extensions import dataclass_transform, Any @@ -478,11 +478,8 @@ class Person: name: str = fancy_field() age: int | None = fancy_field(kw_only=True) -# TODO: Should be `(self: Person, name: str, *, age: int | None) -> None` -reveal_type(Person.__init__) # revealed: (self: Person, id: int = Any, name: str = Any, age: int | None = Any) -> None +reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None -# TODO: No error here -# error: [invalid-argument-type] alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int @@ -490,6 +487,145 @@ reveal_type(alice.name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` +### For metaclass-based transformers + +```py +from typing_extensions import dataclass_transform, Any + +def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(fancy_field,)) +class FancyMeta(type): + def __new__(cls, name, bases, namespace): + ... + return super().__new__(cls, name, bases, namespace) + +class FancyBase(metaclass=FancyMeta): ... + +class Person(FancyBase): + id: int = fancy_field(init=False) + name: str = fancy_field() + age: int | None = fancy_field(kw_only=True) + +reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None + +alice = Person("Alice", age=30) + +reveal_type(alice.id) # revealed: int +reveal_type(alice.name) # revealed: str +reveal_type(alice.age) # revealed: int | None +``` + +### For base-class-based transformers + +```py +from typing_extensions import dataclass_transform, Any + +def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(fancy_field,)) +class FancyBase: + def __init_subclass__(cls): + ... + super().__init_subclass__() + +class Person(FancyBase): + id: int = fancy_field(init=False) + name: str = fancy_field() + age: int | None = fancy_field(kw_only=True) + +# TODO: should be (self: Person, name: str = Unknown, *, age: int | None = Unknown) -> None +reveal_type(Person.__init__) # revealed: def __init__(self) -> None + +# TODO: shouldn't be an error +# error: [too-many-positional-arguments] +# error: [unknown-argument] +alice = Person("Alice", age=30) + +reveal_type(alice.id) # revealed: int +reveal_type(alice.name) # revealed: str +reveal_type(alice.age) # revealed: int | None +``` + +### With default arguments + +Field specifiers can have default arguments that should be respected: + +```py +from typing_extensions import dataclass_transform, Any + +def fancy_field(*, init: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(fancy_field,)) +def fancy_model[T](cls: type[T]) -> type[T]: + ... + return cls + +@fancy_model +class Person: + id: int = fancy_field() + name: str = fancy_field(init=True) + +reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None + +Person(name="Alice") +``` + +### With overloaded field specifiers + +```py +from typing_extensions import dataclass_transform, overload, Any + +@overload +def fancy_field(*, init: bool = True) -> Any: ... +@overload +def fancy_field(*, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(fancy_field,)) +def fancy_model[T](cls: type[T]) -> type[T]: + ... + return cls + +@fancy_model +class Person: + id: int = fancy_field(init=False) + name: str = fancy_field() + age: int | None = fancy_field(kw_only=True) + +reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None +``` + +### Nested dataclass-transformers + +Make sure that models are only affected by the field specifiers of their own transformer: + +```py +from typing_extensions import dataclass_transform, Any +from dataclasses import field + +def outer_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(outer_field,)) +def outer_model[T](cls: type[T]) -> type[T]: + # ... + return cls + +def inner_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(inner_field,)) +def inner_model[T](cls: type[T]) -> type[T]: + # ... + return cls + +@outer_model +class Outer: + @inner_model + class Inner: + inner_a: int = inner_field(init=False) + inner_b: str = outer_field(init=False) + + outer_a: int = outer_field(init=False) + outer_b: str = inner_field(init=False) + +reveal_type(Outer.__init__) # revealed: (self: Outer, outer_b: str = Any) -> None +reveal_type(Outer.Inner.__init__) # revealed: (self: Inner, inner_b: str = Any) -> None +``` + ## Overloaded dataclass-like decorators In the case of an overloaded decorator, the `dataclass_transform` decorator can be applied to the diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 97e86737f8..f45e4127c7 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -32,7 +32,9 @@ pub(crate) use self::signatures::{CallableSignature, Parameter, Parameters, Sign pub(crate) use self::subclass_of::{SubclassOfInner, SubclassOfType}; use crate::module_name::ModuleName; use crate::module_resolver::{KnownModule, resolve_module}; -use crate::place::{Definedness, Place, PlaceAndQualifiers, TypeOrigin, imported_symbol}; +use crate::place::{ + Definedness, Place, PlaceAndQualifiers, TypeOrigin, imported_symbol, known_module_symbol, +}; use crate::semantic_index::definition::{Definition, DefinitionKind}; use crate::semantic_index::place::ScopedPlaceId; use crate::semantic_index::scope::ScopeId; @@ -50,7 +52,8 @@ pub use crate::types::display::DisplaySettings; use crate::types::display::TupleSpecialization; use crate::types::enums::{enum_metadata, is_single_member_enum}; use crate::types::function::{ - DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction, + DataclassTransformerFlags, DataclassTransformerParams, FunctionSpans, FunctionType, + KnownFunction, }; use crate::types::generics::{ GenericContext, InferableTypeVars, PartialSpecialization, Specialization, bind_typevar, @@ -618,67 +621,95 @@ impl<'db> PropertyInstanceType<'db> { } bitflags! { - /// Used for the return type of `dataclass(…)` calls. Keeps track of the arguments - /// that were passed in. For the precise meaning of the fields, see [1]. + /// Used to store metadata about a dataclass or dataclass-like class. + /// For the precise meaning of the fields, see [1]. /// /// [1]: https://docs.python.org/3/library/dataclasses.html - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub struct DataclassParams: u16 { - const INIT = 0b0000_0000_0001; - const REPR = 0b0000_0000_0010; - const EQ = 0b0000_0000_0100; - const ORDER = 0b0000_0000_1000; - const UNSAFE_HASH = 0b0000_0001_0000; - const FROZEN = 0b0000_0010_0000; - const MATCH_ARGS = 0b0000_0100_0000; - const KW_ONLY = 0b0000_1000_0000; - const SLOTS = 0b0001_0000_0000; - const WEAKREF_SLOT = 0b0010_0000_0000; - // This is not an actual argument from `dataclass(...)` but a flag signaling that no - // `field_specifiers` was specified for the `dataclass_transform`, see [1]. - // [1]: https://typing.python.org/en/latest/spec/dataclasses.html#dataclass-transform-parameters - const NO_FIELD_SPECIFIERS = 0b0100_0000_0000; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct DataclassFlags: u16 { + const INIT = 1 << 0; + const REPR = 1 << 1; + const EQ = 1 << 2; + const ORDER = 1 << 3; + const UNSAFE_HASH = 1 << 4; + const FROZEN = 1 << 5; + const MATCH_ARGS = 1 << 6; + const KW_ONLY = 1 << 7; + const SLOTS = 1 << 8 ; + const WEAKREF_SLOT = 1 << 9; } } -impl get_size2::GetSize for DataclassParams {} +impl get_size2::GetSize for DataclassFlags {} -impl Default for DataclassParams { +impl Default for DataclassFlags { fn default() -> Self { Self::INIT | Self::REPR | Self::EQ | Self::MATCH_ARGS } } -impl From for DataclassParams { - fn from(params: DataclassTransformerParams) -> Self { +impl From for DataclassFlags { + fn from(params: DataclassTransformerFlags) -> Self { let mut result = Self::default(); result.set( Self::EQ, - params.contains(DataclassTransformerParams::EQ_DEFAULT), + params.contains(DataclassTransformerFlags::EQ_DEFAULT), ); result.set( Self::ORDER, - params.contains(DataclassTransformerParams::ORDER_DEFAULT), + params.contains(DataclassTransformerFlags::ORDER_DEFAULT), ); result.set( Self::KW_ONLY, - params.contains(DataclassTransformerParams::KW_ONLY_DEFAULT), + params.contains(DataclassTransformerFlags::KW_ONLY_DEFAULT), ); result.set( Self::FROZEN, - params.contains(DataclassTransformerParams::FROZEN_DEFAULT), - ); - - result.set( - Self::NO_FIELD_SPECIFIERS, - !params.contains(DataclassTransformerParams::FIELD_SPECIFIERS), + params.contains(DataclassTransformerFlags::FROZEN_DEFAULT), ); result } } +/// Metadata for a dataclass. Stored inside a `Type::DataclassDecorator(…)` +/// instance that we use as the return type of a `dataclasses.dataclass` and +/// dataclass-transformer decorator calls. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct DataclassParams<'db> { + flags: DataclassFlags, + + #[returns(deref)] + field_specifiers: Box<[Type<'db>]>, +} + +impl get_size2::GetSize for DataclassParams<'_> {} + +impl<'db> DataclassParams<'db> { + fn default_params(db: &'db dyn Db) -> Self { + Self::from_flags(db, DataclassFlags::default()) + } + + fn from_flags(db: &'db dyn Db, flags: DataclassFlags) -> Self { + let dataclasses_field = known_module_symbol(db, KnownModule::Dataclasses, "field") + .place + .ignore_possibly_undefined() + .unwrap_or_else(Type::unknown); + + Self::new(db, flags, vec![dataclasses_field].into_boxed_slice()) + } + + fn from_transformer_params(db: &'db dyn Db, params: DataclassTransformerParams<'db>) -> Self { + Self::new( + db, + DataclassFlags::from(params.flags(db)), + params.field_specifiers(db), + ) + } +} + /// Representation of a type: a set of possible values at runtime. /// #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] @@ -719,9 +750,9 @@ pub enum Type<'db> { /// A special callable that is returned by a `dataclass(…)` call. It is usually /// used as a decorator. Note that this is only used as a return type for actual /// `dataclass` calls, not for the argumentless `@dataclass` decorator. - DataclassDecorator(DataclassParams), + DataclassDecorator(DataclassParams<'db>), /// A special callable that is returned by a `dataclass_transform(…)` call. - DataclassTransformer(DataclassTransformerParams), + DataclassTransformer(DataclassTransformerParams<'db>), /// The type of an arbitrary callable object with a certain specified signature. Callable(CallableType<'db>), /// A specific module object @@ -5449,7 +5480,7 @@ impl<'db> Type<'db> { ) -> Result, CallError<'db>> { self.bindings(db) .match_parameters(db, argument_types) - .check_types(db, argument_types, &TypeContext::default()) + .check_types(db, argument_types, &TypeContext::default(), &[]) } /// Look up a dunder method on the meta-type of `self` and call it. @@ -5501,7 +5532,7 @@ impl<'db> Type<'db> { let bindings = dunder_callable .bindings(db) .match_parameters(db, argument_types) - .check_types(db, argument_types, &tcx)?; + .check_types(db, argument_types, &tcx, &[])?; if boundness == Definedness::PossiblyUndefined { return Err(CallDunderError::PossiblyUnbound(Box::new(bindings))); } diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index bcac0c636c..641fce2ad9 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -24,7 +24,8 @@ use crate::types::diagnostic::{ }; use crate::types::enums::is_enum_class; use crate::types::function::{ - DataclassTransformerParams, FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral, + DataclassTransformerFlags, DataclassTransformerParams, FunctionDecorators, FunctionType, + KnownFunction, OverloadLiteral, }; use crate::types::generics::{ InferableTypeVars, Specialization, SpecializationBuilder, SpecializationError, @@ -32,9 +33,9 @@ use crate::types::generics::{ use crate::types::signatures::{Parameter, ParameterForm, ParameterKind, Parameters}; use crate::types::tuple::{TupleLength, TupleType}; use crate::types::{ - BoundMethodType, ClassLiteral, DataclassParams, FieldInstance, KnownBoundMethodType, - KnownClass, KnownInstanceType, MemberLookupPolicy, PropertyInstanceType, SpecialFormType, - TrackedConstraintSet, TypeAliasType, TypeContext, UnionBuilder, UnionType, + BoundMethodType, ClassLiteral, DataclassFlags, DataclassParams, FieldInstance, + KnownBoundMethodType, KnownClass, KnownInstanceType, MemberLookupPolicy, PropertyInstanceType, + SpecialFormType, TrackedConstraintSet, TypeAliasType, TypeContext, UnionBuilder, UnionType, WrapperDescriptorKind, enums, ide_support, infer_isolated_expression, todo_type, }; use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity}; @@ -135,6 +136,7 @@ impl<'db> Bindings<'db> { db: &'db dyn Db, argument_types: &CallArguments<'_, 'db>, call_expression_tcx: &TypeContext<'db>, + dataclass_field_specifiers: &[Type<'db>], ) -> Result> { for element in &mut self.elements { if let Some(mut updated_argument_forms) = @@ -147,7 +149,7 @@ impl<'db> Bindings<'db> { } } - self.evaluate_known_cases(db); + self.evaluate_known_cases(db, dataclass_field_specifiers); // In order of precedence: // @@ -269,7 +271,7 @@ impl<'db> Bindings<'db> { /// Evaluates the return type of certain known callables, where we have special-case logic to /// determine the return type in a way that isn't directly expressible in the type system. - fn evaluate_known_cases(&mut self, db: &'db dyn Db) { + fn evaluate_known_cases(&mut self, db: &'db dyn Db, dataclass_field_specifiers: &[Type<'db>]) { let to_bool = |ty: &Option>, default: bool| -> bool { if let Some(Type::BooleanLiteral(value)) = ty { *value @@ -596,6 +598,70 @@ impl<'db> Bindings<'db> { } } + function @ Type::FunctionLiteral(function_type) + if dataclass_field_specifiers.contains(&function) + || function_type.is_known(db, KnownFunction::Field) => + { + let has_default_value = overload + .parameter_type_by_name("default", false) + .is_ok_and(|ty| ty.is_some()) + || overload + .parameter_type_by_name("default_factory", false) + .is_ok_and(|ty| ty.is_some()) + || overload + .parameter_type_by_name("factory", false) + .is_ok_and(|ty| ty.is_some()); + + let init = overload + .parameter_type_by_name("init", true) + .unwrap_or(None); + let kw_only = overload + .parameter_type_by_name("kw_only", true) + .unwrap_or(None); + + // `dataclasses.field` and field-specifier functions of commonly used + // libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return + // the default type for the field (or `Any`) instead of an actual `Field` + // instance, even if this is not what happens at runtime (see also below). + // We still make use of this fact and pretend that all field specifiers + // return the type of the default value: + let default_ty = if has_default_value { + Some(overload.return_ty) + } else { + None + }; + + let init = init + .map(|init| !init.bool(db).is_always_false()) + .unwrap_or(true); + + let kw_only = if Program::get(db).python_version(db) >= PythonVersion::PY310 + { + match kw_only { + // We are more conservative here when turning the type for `kw_only` + // into a bool, because a field specifier in a stub might use + // `kw_only: bool = ...` and the truthiness of `...` is always true. + // This is different from `init` above because may need to fall back + // to `kw_only_default`, whereas `init_default` does not exist. + Some(Type::BooleanLiteral(yes)) => Some(yes), + _ => None, + } + } else { + None + }; + + // `typeshed` pretends that `dataclasses.field()` returns the type of the + // default value directly. At runtime, however, this function returns an + // instance of `dataclasses.Field`. We also model it this way and return + // a known-instance type with information about the field. The drawback + // of this approach is that we need to pretend that instances of `Field` + // are assignable to `T` if the default type of the field is assignable + // to `T`. Otherwise, we would error on `name: str = field(default="")`. + overload.set_return_type(Type::KnownInstance(KnownInstanceType::Field( + FieldInstance::new(db, default_ty, init, kw_only), + ))); + } + Type::FunctionLiteral(function_type) => match function_type.known(db) { Some(KnownFunction::IsEquivalentTo) => { if let [Some(ty_a), Some(ty_b)] = overload.parameter_types() { @@ -871,43 +937,45 @@ impl<'db> Bindings<'db> { weakref_slot, ] = overload.parameter_types() { - let mut params = DataclassParams::empty(); + let mut flags = DataclassFlags::empty(); if to_bool(init, true) { - params |= DataclassParams::INIT; + flags |= DataclassFlags::INIT; } if to_bool(repr, true) { - params |= DataclassParams::REPR; + flags |= DataclassFlags::REPR; } if to_bool(eq, true) { - params |= DataclassParams::EQ; + flags |= DataclassFlags::EQ; } if to_bool(order, false) { - params |= DataclassParams::ORDER; + flags |= DataclassFlags::ORDER; } if to_bool(unsafe_hash, false) { - params |= DataclassParams::UNSAFE_HASH; + flags |= DataclassFlags::UNSAFE_HASH; } if to_bool(frozen, false) { - params |= DataclassParams::FROZEN; + flags |= DataclassFlags::FROZEN; } if to_bool(match_args, true) { - params |= DataclassParams::MATCH_ARGS; + flags |= DataclassFlags::MATCH_ARGS; } if to_bool(kw_only, false) { if Program::get(db).python_version(db) >= PythonVersion::PY310 { - params |= DataclassParams::KW_ONLY; + flags |= DataclassFlags::KW_ONLY; } else { // TODO: emit diagnostic } } if to_bool(slots, false) { - params |= DataclassParams::SLOTS; + flags |= DataclassFlags::SLOTS; } if to_bool(weakref_slot, false) { - params |= DataclassParams::WEAKREF_SLOT; + flags |= DataclassFlags::WEAKREF_SLOT; } + let params = DataclassParams::from_flags(db, flags); + overload.set_return_type(Type::DataclassDecorator(params)); } @@ -915,7 +983,7 @@ impl<'db> Bindings<'db> { if let [Some(Type::ClassLiteral(class_literal))] = overload.parameter_types() { - let params = DataclassParams::default(); + let params = DataclassParams::default_params(db); overload.set_return_type(Type::from(ClassLiteral::new( db, class_literal.name(db), @@ -938,82 +1006,39 @@ impl<'db> Bindings<'db> { _kwargs, ] = overload.parameter_types() { - let mut params = DataclassTransformerParams::empty(); + let mut flags = DataclassTransformerFlags::empty(); if to_bool(eq_default, true) { - params |= DataclassTransformerParams::EQ_DEFAULT; + flags |= DataclassTransformerFlags::EQ_DEFAULT; } if to_bool(order_default, false) { - params |= DataclassTransformerParams::ORDER_DEFAULT; + flags |= DataclassTransformerFlags::ORDER_DEFAULT; } if to_bool(kw_only_default, false) { - params |= DataclassTransformerParams::KW_ONLY_DEFAULT; + flags |= DataclassTransformerFlags::KW_ONLY_DEFAULT; } if to_bool(frozen_default, false) { - params |= DataclassTransformerParams::FROZEN_DEFAULT; + flags |= DataclassTransformerFlags::FROZEN_DEFAULT; } - if let Some(field_specifiers_type) = field_specifiers { - // For now, we'll do a simple check: if field_specifiers is not - // None/empty, we assume it might contain dataclasses.field - // TODO: Implement proper parsing to check for - // dataclasses.field/Field specifically - if !field_specifiers_type.is_none(db) { - params |= DataclassTransformerParams::FIELD_SPECIFIERS; - } - } + let field_specifiers: Box<[Type<'db>]> = field_specifiers + .map(|tuple_type| { + tuple_type + .exact_tuple_instance_spec(db) + .iter() + .flat_map(|tuple_spec| tuple_spec.fixed_elements()) + .copied() + .collect() + }) + .unwrap_or_default(); + + let params = + DataclassTransformerParams::new(db, flags, field_specifiers); overload.set_return_type(Type::DataclassTransformer(params)); } } - Some(KnownFunction::Field) => { - let default = - overload.parameter_type_by_name("default").unwrap_or(None); - let default_factory = overload - .parameter_type_by_name("default_factory") - .unwrap_or(None); - let init = overload.parameter_type_by_name("init").unwrap_or(None); - let kw_only = - overload.parameter_type_by_name("kw_only").unwrap_or(None); - - // `dataclasses.field` and field-specifier functions of commonly used - // libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return - // the default type for the field (or `Any`) instead of an actual `Field` - // instance, even if this is not what happens at runtime (see also below). - // We still make use of this fact and pretend that all field specifiers - // return the type of the default value: - let default_ty = if default.is_some() || default_factory.is_some() { - Some(overload.return_ty) - } else { - None - }; - - let init = init - .map(|init| !init.bool(db).is_always_false()) - .unwrap_or(true); - - let kw_only = - if Program::get(db).python_version(db) >= PythonVersion::PY310 { - kw_only.map(|kw_only| !kw_only.bool(db).is_always_false()) - } else { - None - }; - - // `typeshed` pretends that `dataclasses.field()` returns the type of the - // default value directly. At runtime, however, this function returns an - // instance of `dataclasses.Field`. We also model it this way and return - // a known-instance type with information about the field. The drawback - // of this approach is that we need to pretend that instances of `Field` - // are assignable to `T` if the default type of the field is assignable - // to `T`. Otherwise, we would error on `name: str = field(default="")`. - overload.set_return_type(Type::KnownInstance( - KnownInstanceType::Field(FieldInstance::new( - db, default_ty, init, kw_only, - )), - )); - } - _ => { // Ideally, either the implementation, or exactly one of the overloads // of the function can have the dataclass_transform decorator applied. @@ -1030,36 +1055,41 @@ impl<'db> Bindings<'db> { // the argument type and overwrite the corresponding flag in `dataclass_params` after // constructing them from the `dataclass_transformer`-parameter defaults. - let mut dataclass_params = - DataclassParams::from(params); + let dataclass_params = + DataclassParams::from_transformer_params( + db, params, + ); + let mut flags = dataclass_params.flags(db); if let Ok(Some(Type::BooleanLiteral(order))) = - overload.parameter_type_by_name("order") + overload.parameter_type_by_name("order", false) { - dataclass_params.set(DataclassParams::ORDER, order); + flags.set(DataclassFlags::ORDER, order); } if let Ok(Some(Type::BooleanLiteral(eq))) = - overload.parameter_type_by_name("eq") + overload.parameter_type_by_name("eq", false) { - dataclass_params.set(DataclassParams::EQ, eq); + flags.set(DataclassFlags::EQ, eq); } if let Ok(Some(Type::BooleanLiteral(kw_only))) = - overload.parameter_type_by_name("kw_only") + overload.parameter_type_by_name("kw_only", false) { - dataclass_params - .set(DataclassParams::KW_ONLY, kw_only); + flags.set(DataclassFlags::KW_ONLY, kw_only); } if let Ok(Some(Type::BooleanLiteral(frozen))) = - overload.parameter_type_by_name("frozen") + overload.parameter_type_by_name("frozen", false) { - dataclass_params - .set(DataclassParams::FROZEN, frozen); + flags.set(DataclassFlags::FROZEN, frozen); } - Type::DataclassDecorator(dataclass_params) + Type::DataclassDecorator(DataclassParams::new( + db, + flags, + dataclass_params.field_specifiers(db), + )) }, ) }) @@ -2843,6 +2873,7 @@ impl<'db> MatchedArgument<'db> { } /// Indicates that a parameter of the given name was not found. +#[derive(Debug, Clone, Copy)] pub(crate) struct UnknownParameterNameError; /// Binding information for one of the overloads of a callable. @@ -2993,15 +3024,24 @@ impl<'db> Binding<'db> { pub(crate) fn parameter_type_by_name( &self, parameter_name: &str, + fallback_to_default: bool, ) -> Result>, UnknownParameterNameError> { - let index = self - .signature - .parameters() + let parameters = self.signature.parameters(); + + let index = parameters .keyword_by_name(parameter_name) .map(|(i, _)| i) .ok_or(UnknownParameterNameError)?; - Ok(self.parameter_tys[index]) + let parameter_ty = self.parameter_tys[index]; + + if parameter_ty.is_some() { + Ok(parameter_ty) + } else if fallback_to_default { + Ok(parameters[index].default_type()) + } else { + Ok(None) + } } pub(crate) fn arguments_for_parameter<'a>( diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index da30aa3144..15cb368c70 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -32,12 +32,12 @@ use crate::types::tuple::{TupleSpec, TupleType}; use crate::types::typed_dict::typed_dict_params_from_class_def; use crate::types::visitor::{NonAtomicType, TypeKind, TypeVisitor, walk_non_atomic_type}; use crate::types::{ - ApplyTypeMappingVisitor, Binding, BoundSuperType, CallableType, DataclassParams, - DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, - IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, MaterializationKind, - NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, TypeContext, - TypeMapping, TypeRelation, TypedDictParams, UnionBuilder, VarianceInferable, declaration_type, - determine_upper_bound, infer_definition_types, + ApplyTypeMappingVisitor, Binding, BoundSuperType, CallableType, DataclassFlags, + DataclassParams, DeprecatedInstance, FindLegacyTypeVarsVisitor, HasRelationToVisitor, + IsDisjointVisitor, IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, + MaterializationKind, NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, + TypeContext, TypeMapping, TypeRelation, TypedDictParams, UnionBuilder, VarianceInferable, + declaration_type, determine_upper_bound, infer_definition_types, }; use crate::{ Db, FxIndexMap, FxIndexSet, FxOrderSet, Program, @@ -163,7 +163,7 @@ fn try_metaclass_cycle_recover<'db>( _count: u32, _self: ClassLiteral<'db>, ) -> salsa::CycleRecoveryAction< - Result<(Type<'db>, Option), MetaclassError<'db>>, + Result<(Type<'db>, Option>), MetaclassError<'db>>, > { salsa::CycleRecoveryAction::Iterate } @@ -172,7 +172,7 @@ fn try_metaclass_cycle_recover<'db>( fn try_metaclass_cycle_initial<'db>( _db: &'db dyn Db, _self_: ClassLiteral<'db>, -) -> Result<(Type<'db>, Option), MetaclassError<'db>> { +) -> Result<(Type<'db>, Option>), MetaclassError<'db>> { Err(MetaclassError { kind: MetaclassErrorKind::Cycle, }) @@ -180,17 +180,17 @@ fn try_metaclass_cycle_initial<'db>( /// A category of classes with code generation capabilities (with synthesized methods). #[derive(Clone, Copy, Debug, PartialEq, salsa::Update, get_size2::GetSize)] -pub(crate) enum CodeGeneratorKind { +pub(crate) enum CodeGeneratorKind<'db> { /// Classes decorated with `@dataclass` or similar dataclass-like decorators - DataclassLike(Option), + DataclassLike(Option>), /// Classes inheriting from `typing.NamedTuple` NamedTuple, /// Classes inheriting from `typing.TypedDict` TypedDict, } -impl CodeGeneratorKind { - pub(crate) fn from_class(db: &dyn Db, class: ClassLiteral<'_>) -> Option { +impl<'db> CodeGeneratorKind<'db> { + pub(crate) fn from_class(db: &'db dyn Db, class: ClassLiteral<'db>) -> Option { #[salsa::tracked( cycle_fn=code_generator_of_class_recover, cycle_initial=code_generator_of_class_initial, @@ -199,7 +199,7 @@ impl CodeGeneratorKind { fn code_generator_of_class<'db>( db: &'db dyn Db, class: ClassLiteral<'db>, - ) -> Option { + ) -> Option> { if class.dataclass_params(db).is_some() { Some(CodeGeneratorKind::DataclassLike(None)) } else if let Ok((_, Some(transformer_params))) = class.try_metaclass(db) { @@ -216,27 +216,27 @@ impl CodeGeneratorKind { } } - fn code_generator_of_class_initial( - _db: &dyn Db, - _class: ClassLiteral<'_>, - ) -> Option { + fn code_generator_of_class_initial<'db>( + _db: &'db dyn Db, + _class: ClassLiteral<'db>, + ) -> Option> { None } - #[expect(clippy::ref_option, clippy::trivially_copy_pass_by_ref)] - fn code_generator_of_class_recover( - _db: &dyn Db, - _value: &Option, + #[expect(clippy::ref_option)] + fn code_generator_of_class_recover<'db>( + _db: &'db dyn Db, + _value: &Option>, _count: u32, - _class: ClassLiteral<'_>, - ) -> salsa::CycleRecoveryAction> { + _class: ClassLiteral<'db>, + ) -> salsa::CycleRecoveryAction>> { salsa::CycleRecoveryAction::Iterate } code_generator_of_class(db, class) } - pub(super) fn matches(self, db: &dyn Db, class: ClassLiteral<'_>) -> bool { + pub(super) fn matches(self, db: &'db dyn Db, class: ClassLiteral<'db>) -> bool { matches!( (CodeGeneratorKind::from_class(db, class), self), (Some(Self::DataclassLike(_)), Self::DataclassLike(_)) @@ -1387,8 +1387,8 @@ pub struct ClassLiteral<'db> { /// If this class is deprecated, this holds the deprecation message. pub(crate) deprecated: Option>, - pub(crate) dataclass_params: Option, - pub(crate) dataclass_transformer_params: Option, + pub(crate) dataclass_params: Option>, + pub(crate) dataclass_transformer_params: Option>, } // The Salsa heap is tracked separately. @@ -1909,7 +1909,7 @@ impl<'db> ClassLiteral<'db> { pub(super) fn try_metaclass( self, db: &'db dyn Db, - ) -> Result<(Type<'db>, Option), MetaclassError<'db>> { + ) -> Result<(Type<'db>, Option>), MetaclassError<'db>> { tracing::trace!("ClassLiteral::try_metaclass: {}", self.name(db)); // Identify the class's own metaclass (or take the first base class's metaclass). @@ -2271,14 +2271,17 @@ impl<'db> ClassLiteral<'db> { let transformer_params = if let CodeGeneratorKind::DataclassLike(Some(transformer_params)) = field_policy { - Some(DataclassParams::from(transformer_params)) + Some(DataclassParams::from_transformer_params( + db, + transformer_params, + )) } else { None }; let has_dataclass_param = |param| { - dataclass_params.is_some_and(|params| params.contains(param)) - || transformer_params.is_some_and(|params| params.contains(param)) + dataclass_params.is_some_and(|params| params.flags(db).contains(param)) + || transformer_params.is_some_and(|params| params.flags(db).contains(param)) }; let instance_ty = @@ -2357,7 +2360,7 @@ impl<'db> ClassLiteral<'db> { } let is_kw_only = name == "__replace__" - || kw_only.unwrap_or(has_dataclass_param(DataclassParams::KW_ONLY)); + || kw_only.unwrap_or(has_dataclass_param(DataclassFlags::KW_ONLY)); let mut parameter = if is_kw_only { Parameter::keyword_only(field_name) @@ -2395,7 +2398,7 @@ impl<'db> ClassLiteral<'db> { match (field_policy, name) { (CodeGeneratorKind::DataclassLike(_), "__init__") => { - if !has_dataclass_param(DataclassParams::INIT) { + if !has_dataclass_param(DataclassFlags::INIT) { return None; } @@ -2410,7 +2413,7 @@ impl<'db> ClassLiteral<'db> { signature_from_fields(vec![cls_parameter], Some(Type::none(db))) } (CodeGeneratorKind::DataclassLike(_), "__lt__" | "__le__" | "__gt__" | "__ge__") => { - if !has_dataclass_param(DataclassParams::ORDER) { + if !has_dataclass_param(DataclassFlags::ORDER) { return None; } @@ -2461,7 +2464,7 @@ impl<'db> ClassLiteral<'db> { signature_from_fields(vec![self_parameter], Some(instance_ty)) } (CodeGeneratorKind::DataclassLike(_), "__setattr__") => { - if has_dataclass_param(DataclassParams::FROZEN) { + if has_dataclass_param(DataclassFlags::FROZEN) { let signature = Signature::new( Parameters::new([ Parameter::positional_or_keyword(Name::new_static("self")) @@ -2477,7 +2480,7 @@ impl<'db> ClassLiteral<'db> { None } (CodeGeneratorKind::DataclassLike(_), "__slots__") => { - has_dataclass_param(DataclassParams::SLOTS).then(|| { + has_dataclass_param(DataclassFlags::SLOTS).then(|| { let fields = self.fields(db, specialization, field_policy); let slots = fields.keys().map(|name| Type::string_literal(db, name)); Type::heterogeneous_tuple(db, slots) @@ -2901,7 +2904,7 @@ impl<'db> ClassLiteral<'db> { default_ty = field.default_type(db); if self .dataclass_params(db) - .map(|params| params.contains(DataclassParams::NO_FIELD_SPECIFIERS)) + .map(|params| params.field_specifiers(db).is_empty()) .unwrap_or(false) { // This happens when constructing a `dataclass` with a `dataclass_transform` @@ -3635,7 +3638,7 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { let is_frozen_dataclass = Program::get(db).python_version(db) <= PythonVersion::PY312 && self .dataclass_params(db) - .is_some_and(|params| params.contains(DataclassParams::FROZEN)); + .is_some_and(|params| params.flags(db).contains(DataclassFlags::FROZEN)); if is_namedtuple || is_frozen_dataclass { TypeVarVariance::Covariant } else { diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 1456f12526..e691ed1cf8 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -152,24 +152,36 @@ bitflags! { /// arguments that were passed in. For the precise meaning of the fields, see [1]. /// /// [1]: https://docs.python.org/3/library/typing.html#typing.dataclass_transform - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)] - pub struct DataclassTransformerParams: u8 { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, salsa::Update)] + pub struct DataclassTransformerFlags: u8 { const EQ_DEFAULT = 1 << 0; const ORDER_DEFAULT = 1 << 1; const KW_ONLY_DEFAULT = 1 << 2; const FROZEN_DEFAULT = 1 << 3; - const FIELD_SPECIFIERS= 1 << 4; } } -impl get_size2::GetSize for DataclassTransformerParams {} +impl get_size2::GetSize for DataclassTransformerFlags {} -impl Default for DataclassTransformerParams { +impl Default for DataclassTransformerFlags { fn default() -> Self { Self::EQ_DEFAULT } } +/// Metadata for a dataclass-transformer. Stored inside a `Type::DataclassTransformer(…)` +/// instance that we use as the return type for `dataclass_transform(…)` calls. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct DataclassTransformerParams<'db> { + pub flags: DataclassTransformerFlags, + + #[returns(deref)] + pub field_specifiers: Box<[Type<'db>]>, +} + +impl get_size2::GetSize for DataclassTransformerParams<'_> {} + /// Representation of a function definition in the AST: either a non-generic function, or a generic /// function that has not been specialized. /// @@ -201,7 +213,7 @@ pub struct OverloadLiteral<'db> { /// The arguments to `dataclass_transformer`, if this function was annotated /// with `@dataclass_transformer(...)`. - pub(crate) dataclass_transformer_params: Option, + pub(crate) dataclass_transformer_params: Option>, } // The Salsa heap is tracked separately. @@ -212,7 +224,7 @@ impl<'db> OverloadLiteral<'db> { fn with_dataclass_transformer_params( self, db: &'db dyn Db, - params: DataclassTransformerParams, + params: DataclassTransformerParams<'db>, ) -> Self { Self::new( db, @@ -740,7 +752,7 @@ impl<'db> FunctionType<'db> { pub(crate) fn with_dataclass_transformer_params( self, db: &'db dyn Db, - params: DataclassTransformerParams, + params: DataclassTransformerParams<'db>, ) -> Self { // A decorator only applies to the specific overload that it is attached to, not to all // previous overloads. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index a38c9e4462..1053fecef2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9,6 +9,7 @@ use ruff_python_ast::{self as ast, AnyNodeRef, ExprContext, PythonVersion}; use ruff_python_stdlib::builtins::version_builtin_was_added; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; use super::{ CycleRecovery, DefinitionInference, DefinitionInferenceExtra, ExpressionInference, @@ -152,6 +153,12 @@ type BinaryComparisonVisitor<'db> = CycleDetector< Result, CompareUnsupportedError<'db>>, >; +/// We currently store one dataclass field-specifiers inline, because that covers standard +/// dataclasses. attrs uses 2 specifiers, pydantic and strawberry use 3 specifiers. SQLAlchemy +/// uses 7 field specifiers. We could probably store more inline if this turns out to be a +/// performance problem. For now, we optimize for memory usage. +const NUM_FIELD_SPECIFIERS_INLINE: usize = 1; + /// Builder to infer all types in a region. /// /// A builder is used by creating it with [`new()`](TypeInferenceBuilder::new), and then calling @@ -277,6 +284,10 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// `true` if all places in this expression are definitely bound all_definitely_bound: bool, + + /// A list of `dataclass_transform` field specifiers that are "active" (when inferring + /// the right hand side of an annotated assignment in a class that is a dataclass). + dataclass_field_specifiers: SmallVec<[Type<'db>; NUM_FIELD_SPECIFIERS_INLINE]>, } impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { @@ -312,6 +323,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { undecorated_type: None, cycle_recovery: None, all_definitely_bound: true, + dataclass_field_specifiers: SmallVec::new(), } } @@ -2574,7 +2586,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .as_function_literal() .is_some_and(|function| function.is_known(self.db(), KnownFunction::Dataclass)) { - dataclass_params = Some(DataclassParams::default()); + dataclass_params = Some(DataclassParams::default_params(self.db())); continue; } @@ -2595,11 +2607,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // overload, or an overload and the implementation both. Nevertheless, this is not // allowed. We do not try to treat the offenders intelligently -- just use the // params of the last seen usage of `@dataclass_transform` - let params = f + let transformer_params = f .iter_overloads_and_implementation(self.db()) .find_map(|overload| overload.dataclass_transformer_params(self.db())); - if let Some(params) = params { - dataclass_params = Some(params.into()); + if let Some(transformer_params) = transformer_params { + dataclass_params = Some(DataclassParams::from_transformer_params( + self.db(), + transformer_params, + )); continue; } } @@ -4518,10 +4533,42 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { debug_assert!(PlaceExpr::try_from_expr(target).is_some()); if let Some(value) = value { + fn field_specifiers<'db>( + db: &'db dyn Db, + index: &'db SemanticIndex<'db>, + scope: ScopeId<'db>, + ) -> Option; NUM_FIELD_SPECIFIERS_INLINE]>> { + let enclosing_scope = index.scope(scope.file_scope_id(db)); + let class_node = enclosing_scope.node().as_class()?; + let class_definition = index.expect_single_definition(class_node); + let class_literal = infer_definition_types(db, class_definition) + .declaration_type(class_definition) + .inner_type() + .as_class_literal()?; + + class_literal + .dataclass_params(db) + .map(|params| SmallVec::from(params.field_specifiers(db))) + .or_else(|| { + class_literal + .try_metaclass(db) + .ok() + .and_then(|(_, params)| params) + .map(|params| SmallVec::from(params.field_specifiers(db))) + }) + } + + if let Some(specifiers) = field_specifiers(self.db(), self.index, self.scope()) { + self.dataclass_field_specifiers = specifiers; + } + let inferred_ty = self.infer_maybe_standalone_expression( value, TypeContext::new(Some(declared.inner_type())), ); + + self.dataclass_field_specifiers.clear(); + let inferred_ty = if target .as_name_expr() .is_some_and(|name| &name.id == "TYPE_CHECKING") @@ -6650,7 +6697,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - let mut bindings = match bindings.check_types(self.db(), &call_arguments, &tcx) { + let mut bindings = match bindings.check_types( + self.db(), + &call_arguments, + &tcx, + &self.dataclass_field_specifiers[..], + ) { Ok(bindings) => bindings, Err(CallError(_, bindings)) => { bindings.report_diagnostics(&self.context, call_expression.into()); @@ -9238,8 +9290,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let binding = Binding::single(value_ty, generic_context.signature(self.db())); let bindings = match Bindings::from(binding) .match_parameters(self.db(), &call_argument_types) - .check_types(self.db(), &call_argument_types, &TypeContext::default()) - { + .check_types( + self.db(), + &call_argument_types, + &TypeContext::default(), + &self.dataclass_field_specifiers[..], + ) { Ok(bindings) => bindings, Err(CallError(_, bindings)) => { bindings.report_diagnostics(&self.context, subscript.into()); @@ -9771,6 +9827,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred, cycle_recovery, all_definitely_bound, + dataclass_field_specifiers: _, // Ignored; only relevant to definition regions undecorated_type: _, @@ -9837,8 +9894,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred, cycle_recovery, undecorated_type, - all_definitely_bound: _, // builder only state + dataclass_field_specifiers: _, + all_definitely_bound: _, typevar_binding_context: _, deferred_state: _, multi_inference_state: _, @@ -9905,12 +9963,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { deferred: _, bindings: _, declarations: _, - all_definitely_bound: _, // Ignored; only relevant to definition regions undecorated_type: _, // Builder only state + dataclass_field_specifiers: _, + all_definitely_bound: _, typevar_binding_context: _, deferred_state: _, multi_inference_state: _, diff --git a/crates/ty_python_semantic/src/types/type_ordering.rs b/crates/ty_python_semantic/src/types/type_ordering.rs index f331aefa63..d561e6a8b8 100644 --- a/crates/ty_python_semantic/src/types/type_ordering.rs +++ b/crates/ty_python_semantic/src/types/type_ordering.rs @@ -83,15 +83,11 @@ pub(super) fn union_or_intersection_elements_ordering<'db>( (Type::WrapperDescriptor(_), _) => Ordering::Less, (_, Type::WrapperDescriptor(_)) => Ordering::Greater, - (Type::DataclassDecorator(left), Type::DataclassDecorator(right)) => { - left.bits().cmp(&right.bits()) - } + (Type::DataclassDecorator(left), Type::DataclassDecorator(right)) => left.cmp(right), (Type::DataclassDecorator(_), _) => Ordering::Less, (_, Type::DataclassDecorator(_)) => Ordering::Greater, - (Type::DataclassTransformer(left), Type::DataclassTransformer(right)) => { - left.bits().cmp(&right.bits()) - } + (Type::DataclassTransformer(left), Type::DataclassTransformer(right)) => left.cmp(right), (Type::DataclassTransformer(_), _) => Ordering::Less, (_, Type::DataclassTransformer(_)) => Ordering::Greater, From 1ade4f20816bc46d73eb7c62fdda386e52dbf0be Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Thu, 16 Oct 2025 15:17:37 -0400 Subject: [PATCH 077/113] [ty] Avoid unnecessarily widening generic specializations (#20875) ## Summary Ignore the type context when specializing a generic call if it leads to an unnecessarily wide return type. For example, [the example mentioned here](https://github.com/astral-sh/ruff/pull/20796#issuecomment-3403319536) works as expected after this change: ```py def id[T](x: T) -> T: return x def _(i: int): x: int | None = id(i) y: int | None = i reveal_type(x) # revealed: int reveal_type(y) # revealed: int ``` I also added extended our usage of `filter_disjoint_elements` to tuple and typed-dict inference, which resolves https://github.com/astral-sh/ty/issues/1266. --- .../mdtest/assignment/annotations.md | 53 +++++++++++-- .../resources/mdtest/dataclasses/fields.md | 6 +- .../resources/mdtest/typed_dict.md | 2 +- crates/ty_python_semantic/src/types.rs | 37 ++++++--- .../ty_python_semantic/src/types/call/bind.rs | 77 ++++++++++++------- .../ty_python_semantic/src/types/generics.rs | 11 ++- crates/ty_python_semantic/src/types/infer.rs | 2 +- .../src/types/infer/builder.rs | 26 ++++++- 8 files changed, 156 insertions(+), 58 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md index 24b04f68f3..fe0bbf84fd 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md @@ -190,8 +190,7 @@ k: list[tuple[list[int], ...]] | None = [([],), ([1, 2], [3, 4]), ([5], [6], [7] reveal_type(k) # revealed: list[tuple[list[int], ...]] l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] | None = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) -# TODO: this should be `tuple[list[int], list[Any | int], list[Any | int], list[str]]` -reveal_type(l) # revealed: tuple[list[Unknown | int], list[Unknown | int], list[Unknown | int], list[Unknown | str]] +reveal_type(l) # revealed: tuple[list[int], list[Any | int], list[Any | int], list[str]] type IntList = list[int] @@ -416,13 +415,14 @@ a = f("a") reveal_type(a) # revealed: list[Literal["a"]] b: list[int | Literal["a"]] = f("a") -reveal_type(b) # revealed: list[int | Literal["a"]] +reveal_type(b) # revealed: list[Literal["a"] | int] c: list[int | str] = f("a") -reveal_type(c) # revealed: list[int | str] +reveal_type(c) # revealed: list[str | int] d: list[int | tuple[int, int]] = f((1, 2)) -reveal_type(d) # revealed: list[int | tuple[int, int]] +# TODO: We could avoid reordering the union elements here. +reveal_type(d) # revealed: list[tuple[int, int] | int] e: list[int] = f(True) reveal_type(e) # revealed: list[int] @@ -437,8 +437,49 @@ def f2[T: int](x: T) -> T: return x i: int = f2(True) -reveal_type(i) # revealed: int +reveal_type(i) # revealed: Literal[True] j: int | str = f2(True) reveal_type(j) # revealed: Literal[True] ``` + +Types are not widened unnecessarily: + +```py +def id[T](x: T) -> T: + return x + +def lst[T](x: T) -> list[T]: + return [x] + +def _(i: int): + a: int | None = i + b: int | None = id(i) + c: int | str | None = id(i) + reveal_type(a) # revealed: int + reveal_type(b) # revealed: int + reveal_type(c) # revealed: int + + a: list[int | None] | None = [i] + b: list[int | None] | None = id([i]) + c: list[int | None] | int | None = id([i]) + reveal_type(a) # revealed: list[int | None] + # TODO: these should reveal `list[int | None]` + # we currently do not use the call expression annotation as type context for argument inference + reveal_type(b) # revealed: list[Unknown | int] + reveal_type(c) # revealed: list[Unknown | int] + + a: list[int | None] | None = [i] + b: list[int | None] | None = lst(i) + c: list[int | None] | int | None = lst(i) + reveal_type(a) # revealed: list[int | None] + reveal_type(b) # revealed: list[int | None] + reveal_type(c) # revealed: list[int | None] + + a: list | None = [] + b: list | None = id([]) + c: list | int | None = id([]) + reveal_type(a) # revealed: list[Unknown] + reveal_type(b) # revealed: list[Unknown] + reveal_type(c) # revealed: list[Unknown] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md index 7b6a4369cc..f091a1c991 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md @@ -11,7 +11,7 @@ class Member: role: str = field(default="user") tag: str | None = field(default=None, init=False) -# revealed: (self: Member, name: str, role: str = str) -> None +# revealed: (self: Member, name: str, role: str = Literal["user"]) -> None reveal_type(Member.__init__) alice = Member(name="Alice", role="admin") @@ -37,7 +37,7 @@ class Data: content: list[int] = field(default_factory=list) timestamp: datetime = field(default_factory=datetime.now, init=False) -# revealed: (self: Data, content: list[int] = list[int]) -> None +# revealed: (self: Data, content: list[int] = Unknown) -> None reveal_type(Data.__init__) data = Data([1, 2, 3]) @@ -64,7 +64,7 @@ class Person: role: str = field(default="user", kw_only=True) # TODO: this would ideally show a default value of `None` for `age` -# revealed: (self: Person, name: str, *, age: int | None = int | None, role: str = str) -> None +# revealed: (self: Person, name: str, *, age: int | None = None, role: str = Literal["user"]) -> None reveal_type(Person.__init__) alice = Person(role="admin", name="Alice") diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 8fc8b2fcb6..b9e3015c9f 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -907,7 +907,7 @@ grandchild: Node = {"name": "grandchild", "parent": child} nested: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": "n3", "parent": None}}} -# TODO: this should be an error (invalid type for `name` in innermost node) +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Node`: value of type `Literal[3]`" nested_invalid: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": 3, "parent": None}}} ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index f45e4127c7..1e1c9f0965 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1233,22 +1233,35 @@ impl<'db> Type<'db> { if yes { self.negate(db) } else { *self } } - /// Remove the union elements that are not related to `target`. + /// If the type is a union, filters union elements based on the provided predicate. + /// + /// Otherwise, returns the type unchanged. + pub(crate) fn filter_union( + self, + db: &'db dyn Db, + f: impl FnMut(&Type<'db>) -> bool, + ) -> Type<'db> { + if let Type::Union(union) = self { + union.filter(db, f) + } else { + self + } + } + + /// If the type is a union, removes union elements that are disjoint from `target`. + /// + /// Otherwise, returns the type unchanged. pub(crate) fn filter_disjoint_elements( self, db: &'db dyn Db, target: Type<'db>, inferable: InferableTypeVars<'_, 'db>, ) -> Type<'db> { - if let Type::Union(union) = self { - union.filter(db, |elem| { - !elem - .when_disjoint_from(db, target, inferable) - .is_always_satisfied() - }) - } else { - self - } + self.filter_union(db, |elem| { + !elem + .when_disjoint_from(db, target, inferable) + .is_always_satisfied() + }) } /// Returns the fallback instance type that a literal is an instance of, or `None` if the type @@ -11185,9 +11198,9 @@ impl<'db> UnionType<'db> { pub(crate) fn filter( self, db: &'db dyn Db, - filter_fn: impl FnMut(&&Type<'db>) -> bool, + mut f: impl FnMut(&Type<'db>) -> bool, ) -> Type<'db> { - Self::from_elements(db, self.elements(db).iter().filter(filter_fn)) + Self::from_elements(db, self.elements(db).iter().filter(|ty| f(ty))) } pub(crate) fn map_with_boundness( diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 641fce2ad9..2260350b46 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2524,6 +2524,7 @@ struct ArgumentTypeChecker<'a, 'db> { argument_matches: &'a [MatchedArgument<'db>], parameter_tys: &'a mut [Option>], call_expression_tcx: &'a TypeContext<'db>, + return_ty: Type<'db>, errors: &'a mut Vec>, inferable_typevars: InferableTypeVars<'db, 'db>, @@ -2531,6 +2532,7 @@ struct ArgumentTypeChecker<'a, 'db> { } impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { + #[expect(clippy::too_many_arguments)] fn new( db: &'db dyn Db, signature: &'a Signature<'db>, @@ -2538,6 +2540,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { argument_matches: &'a [MatchedArgument<'db>], parameter_tys: &'a mut [Option>], call_expression_tcx: &'a TypeContext<'db>, + return_ty: Type<'db>, errors: &'a mut Vec>, ) -> Self { Self { @@ -2547,6 +2550,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { argument_matches, parameter_tys, call_expression_tcx, + return_ty, errors, inferable_typevars: InferableTypeVars::None, specialization: None, @@ -2588,25 +2592,6 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // TODO: Use the list of inferable typevars from the generic context of the callable. let mut builder = SpecializationBuilder::new(self.db, self.inferable_typevars); - // Note that we infer the annotated type _before_ the arguments if this call is part of - // an annotated assignment, to closer match the order of any unions written in the type - // annotation. - if let Some(return_ty) = self.signature.return_ty - && let Some(call_expression_tcx) = self.call_expression_tcx.annotation - { - match call_expression_tcx { - // A type variable is not a useful type-context for expression inference, and applying it - // to the return type can lead to confusing unions in nested generic calls. - Type::TypeVar(_) => {} - - _ => { - // Ignore any specialization errors here, because the type context is only used as a hint - // to infer a more assignable return type. - let _ = builder.infer(return_ty, call_expression_tcx); - } - } - } - let parameters = self.signature.parameters(); for (argument_index, adjusted_argument_index, _, argument_type) in self.enumerate_argument_types() @@ -2631,7 +2616,41 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - self.specialization = Some(builder.build(generic_context, *self.call_expression_tcx)); + // Build the specialization first without inferring the type context. + let isolated_specialization = builder.build(generic_context, *self.call_expression_tcx); + let isolated_return_ty = self + .return_ty + .apply_specialization(self.db, isolated_specialization); + + let mut try_infer_tcx = || { + let return_ty = self.signature.return_ty?; + let call_expression_tcx = self.call_expression_tcx.annotation?; + + // A type variable is not a useful type-context for expression inference, and applying it + // to the return type can lead to confusing unions in nested generic calls. + if call_expression_tcx.is_type_var() { + return None; + } + + // If the return type is already assignable to the annotated type, we can ignore the + // type context and prefer the narrower inferred type. + if isolated_return_ty.is_assignable_to(self.db, call_expression_tcx) { + return None; + } + + // TODO: Ideally we would infer the annotated type _before_ the arguments if this call is part of an + // annotated assignment, to closer match the order of any unions written in the type annotation. + builder.infer(return_ty, call_expression_tcx).ok()?; + + // Otherwise, build the specialization again after inferring the type context. + let specialization = builder.build(generic_context, *self.call_expression_tcx); + let return_ty = return_ty.apply_specialization(self.db, specialization); + + Some((Some(specialization), return_ty)) + }; + + (self.specialization, self.return_ty) = + try_infer_tcx().unwrap_or((Some(isolated_specialization), isolated_return_ty)); } fn check_argument_type( @@ -2826,8 +2845,14 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { } } - fn finish(self) -> (InferableTypeVars<'db, 'db>, Option>) { - (self.inferable_typevars, self.specialization) + fn finish( + self, + ) -> ( + InferableTypeVars<'db, 'db>, + Option>, + Type<'db>, + ) { + (self.inferable_typevars, self.specialization, self.return_ty) } } @@ -2985,18 +3010,16 @@ impl<'db> Binding<'db> { &self.argument_matches, &mut self.parameter_tys, call_expression_tcx, + self.return_ty, &mut self.errors, ); // If this overload is generic, first see if we can infer a specialization of the function // from the arguments that were passed in. checker.infer_specialization(); - checker.check_argument_types(); - (self.inferable_typevars, self.specialization) = checker.finish(); - if let Some(specialization) = self.specialization { - self.return_ty = self.return_ty.apply_specialization(db, specialization); - } + + (self.inferable_typevars, self.specialization, self.return_ty) = checker.finish(); } pub(crate) fn set_return_type(&mut self, return_ty: Type<'db>) { diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 20e1ab127c..456e5b237f 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1229,6 +1229,7 @@ impl<'db> SpecializationBuilder<'db> { let tcx = tcx_specialization.and_then(|specialization| { specialization.get(self.db, variable.bound_typevar) }); + ty = ty.map(|ty| ty.promote_literals(self.db, TypeContext::new(tcx))); } @@ -1251,7 +1252,7 @@ impl<'db> SpecializationBuilder<'db> { pub(crate) fn infer( &mut self, formal: Type<'db>, - mut actual: Type<'db>, + actual: Type<'db>, ) -> Result<(), SpecializationError<'db>> { if formal == actual { return Ok(()); @@ -1282,9 +1283,11 @@ impl<'db> SpecializationBuilder<'db> { return Ok(()); } - // For example, if `formal` is `list[T]` and `actual` is `list[int] | None`, we want to specialize `T` to `int`. - // So, here we remove the union elements that are not related to `formal`. - actual = actual.filter_disjoint_elements(self.db, formal, self.inferable); + // Remove the union elements that are not related to `formal`. + // + // For example, if `formal` is `list[T]` and `actual` is `list[int] | None`, we want to specialize `T` + // to `int`. + let actual = actual.filter_disjoint_elements(self.db, formal, self.inferable); match (formal, actual) { // TODO: We haven't implemented a full unification solver yet. If typevars appear in diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 52a8119465..f2c256f304 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -391,7 +391,7 @@ impl<'db> TypeContext<'db> { .and_then(|ty| ty.known_specialization(db, known_class)) } - pub(crate) fn map_annotation(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { + pub(crate) fn map(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Self { Self { annotation: self.annotation.map(f), } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1053fecef2..8ea2ca9988 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -5890,6 +5890,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { parenthesized: _, } = tuple; + // TODO: Use the list of inferable typevars from the generic context of tuple. + let inferable = InferableTypeVars::None; + + // Remove any union elements of that are unrelated to the tuple type. + let tcx = tcx.map(|annotation| { + annotation.filter_disjoint_elements( + self.db(), + KnownClass::Tuple.to_instance(self.db()), + inferable, + ) + }); + let annotated_tuple = tcx .known_specialization(self.db(), KnownClass::Tuple) .and_then(|specialization| { @@ -5955,7 +5967,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = dict; // Validate `TypedDict` dictionary literal assignments. - if let Some(typed_dict) = tcx.annotation.and_then(Type::as_typed_dict) + if let Some(tcx) = tcx.annotation + && let Some(typed_dict) = tcx + .filter_union(self.db(), Type::is_typed_dict) + .as_typed_dict() && let Some(ty) = self.infer_typed_dict_expression(dict, typed_dict) { return ty; @@ -6038,9 +6053,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // TODO: Use the list of inferable typevars from the generic context of the collection // class. let inferable = InferableTypeVars::None; - let tcx = tcx.map_annotation(|annotation| { - // Remove any union elements of `annotation` that are not related to `collection_ty`. - // e.g. `annotation: list[int] | None => list[int]` if `collection_ty: list` + + // Remove any union elements of that are unrelated to the collection type. + // + // For example, we only want the `list[int]` from `annotation: list[int] | None` if + // `collection_ty` is `list`. + let tcx = tcx.map(|annotation| { let collection_ty = collection_class.to_instance(self.db()); annotation.filter_disjoint_elements(self.db(), collection_ty, inferable) }); From 25023cc0ea779750e2b7c6a2c2a4aa5c4815ec33 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Thu, 16 Oct 2025 15:40:39 -0400 Subject: [PATCH 078/113] [ty] Use declared variable types as bidirectional type context (#20796) ## Summary Use the declared type of variables as type context for the RHS of assignment expressions, e.g., ```py x: list[int | str] x = [1] reveal_type(x) # revealed: list[int | str] ``` --- .../mdtest/assignment/annotations.md | 12 ++ .../mdtest/narrow/conditionals/nested.md | 4 +- .../resources/mdtest/typed_dict.md | 14 +- .../src/types/infer/builder.rs | 135 ++++++++++-------- 4 files changed, 102 insertions(+), 63 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md index fe0bbf84fd..3d5e75ab99 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md @@ -144,6 +144,12 @@ reveal_type(q) # revealed: dict[int | str, int] r: dict[int | str, int | str] = {1: 1, 2: 2, 3: 3} reveal_type(r) # revealed: dict[int | str, int | str] + +s: dict[int | str, int | str] +s = {1: 1, 2: 2, 3: 3} +reveal_type(s) # revealed: dict[int | str, int | str] +(s := {1: 1, 2: 2, 3: 3}) +reveal_type(s) # revealed: dict[int | str, int | str] ``` ## Optional collection literal annotations are understood @@ -296,6 +302,12 @@ reveal_type(q) # revealed: list[int] r: list[Literal[1, 2, 3, 4]] = [1, 2] reveal_type(r) # revealed: list[Literal[1, 2, 3, 4]] + +s: list[Literal[1]] +s = [1] +reveal_type(s) # revealed: list[Literal[1]] +(s := [1]) +reveal_type(s) # revealed: list[Literal[1]] ``` ## PEP-604 annotations are supported diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/nested.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/nested.md index f27b3deb08..2cb4585b3b 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/nested.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/nested.md @@ -310,13 +310,13 @@ no longer valid in the inner lazy scope. def f(l: list[str | None]): if l[0] is not None: def _(): - reveal_type(l[0]) # revealed: str | None | Unknown + reveal_type(l[0]) # revealed: str | None l = [None] def f(l: list[str | None]): l[0] = "a" def _(): - reveal_type(l[0]) # revealed: str | None | Unknown + reveal_type(l[0]) # revealed: str | None l = [None] def f(l: list[str | None]): diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index b9e3015c9f..5cefed9b28 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -233,10 +233,12 @@ Person({"name": "Alice"}) # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" accepts_person({"name": "Alice"}) + # TODO: this should be an error, similar to the above house.owner = {"name": "Alice"} + a_person: Person -# TODO: this should be an error, similar to the above +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" a_person = {"name": "Alice"} ``` @@ -254,9 +256,12 @@ Person({"name": None, "age": 30}) accepts_person({"name": None, "age": 30}) # TODO: this should be an error, similar to the above house.owner = {"name": None, "age": 30} + a_person: Person -# TODO: this should be an error, similar to the above +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" a_person = {"name": None, "age": 30} +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" +(a_person := {"name": None, "age": 30}) ``` All of these have an extra field that is not defined in the `TypedDict`: @@ -273,9 +278,12 @@ Person({"name": "Alice", "age": 30, "extra": True}) accepts_person({"name": "Alice", "age": 30, "extra": True}) # TODO: this should be an error house.owner = {"name": "Alice", "age": 30, "extra": True} -# TODO: this should be an error + a_person: Person +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" a_person = {"name": "Alice", "age": 30, "extra": True} +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +(a_person := {"name": "Alice", "age": 30, "extra": True}) ``` ## Type ignore compatibility issues diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 8ea2ca9988..f84922a5f1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1345,7 +1345,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { true } - fn add_binding(&mut self, node: AnyNodeRef, binding: Definition<'db>, ty: Type<'db>) { + /// Add a binding for the given definition. + /// + /// Returns the result of the `infer_value_ty` closure, which is called with the declared type + /// as type context. + fn add_binding( + &mut self, + node: AnyNodeRef, + binding: Definition<'db>, + infer_value_ty: impl FnOnce(&mut Self, TypeContext<'db>) -> Type<'db>, + ) -> Type<'db> { /// Arbitrary `__getitem__`/`__setitem__` methods on a class do not /// necessarily guarantee that the passed-in value for `__setitem__` is stored and /// can be retrieved unmodified via `__getitem__`. Therefore, we currently only @@ -1390,7 +1399,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let file_scope_id = binding.file_scope(db); let place_table = self.index.place_table(file_scope_id); let use_def = self.index.use_def_map(file_scope_id); - let mut bound_ty = ty; let global_use_def_map = self.index.use_def_map(FileScopeId::global()); let place_id = binding.place(self.db()); @@ -1501,12 +1509,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { qualifiers, } = place_and_quals; - let unwrap_declared_ty = || { - resolved_place - .ignore_possibly_undefined() - .unwrap_or(Type::unknown()) - }; - // If the place is unbound and its an attribute or subscript place, fall back to normal // attribute/subscript inference on the root type. let declared_ty = @@ -1518,9 +1520,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { value_type.member(db, attr).place { // TODO: also consider qualifiers on the attribute - ty + Some(ty) } else { - unwrap_declared_ty() + None } } else if let AnyNodeRef::ExprSubscript( subscript @ ast::ExprSubscript { @@ -1530,13 +1532,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let value_ty = self.infer_expression(value, TypeContext::default()); let slice_ty = self.infer_expression(slice, TypeContext::default()); - self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx) + Some(self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx)) } else { - unwrap_declared_ty() + None } } else { - unwrap_declared_ty() - }; + None + } + .or_else(|| resolved_place.ignore_possibly_undefined()); + + let inferred_ty = infer_value_ty(self, TypeContext::new(declared_ty)); + + let declared_ty = declared_ty.unwrap_or(Type::unknown()); + let mut bound_ty = inferred_ty; if qualifiers.contains(TypeQualifiers::FINAL) { let mut previous_bindings = use_def.bindings_at_definition(binding); @@ -1592,7 +1600,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if !bound_ty.is_assignable_to(db, declared_ty) { report_invalid_assignment(&self.context, node, binding, declared_ty, bound_ty); - // allow declarations to override inference in case of invalid assignment + + // Allow declarations to override inference in case of invalid assignment. bound_ty = declared_ty; } // In the following cases, the bound type may not be the same as the RHS value type. @@ -1620,6 +1629,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } self.bindings.insert(binding, bound_ty); + + inferred_ty } /// Returns `true` if `symbol_id` should be looked up in the global scope, skipping intervening @@ -2485,7 +2496,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } else { Type::unknown() }; - self.add_binding(parameter.into(), definition, ty); + + self.add_binding(parameter.into(), definition, |_, _| ty); } } @@ -2515,11 +2527,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &DeclaredAndInferredType::are_the_same_type(ty), ); } else { - self.add_binding( - parameter.into(), - definition, - Type::homogeneous_tuple(self.db(), Type::unknown()), - ); + self.add_binding(parameter.into(), definition, |builder, _| { + Type::homogeneous_tuple(builder.db(), Type::unknown()) + }); } } @@ -2547,14 +2557,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &DeclaredAndInferredType::are_the_same_type(ty), ); } else { - self.add_binding( - parameter.into(), - definition, + self.add_binding(parameter.into(), definition, |builder, _| { KnownClass::Dict.to_specialized_instance( - self.db(), - [KnownClass::Str.to_instance(self.db()), Type::unknown()], - ), - ); + builder.db(), + [KnownClass::Str.to_instance(builder.db()), Type::unknown()], + ) + }); } } @@ -2828,12 +2836,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for item in items { let target = item.optional_vars.as_deref(); if let Some(target) = target { - self.infer_target(target, &item.context_expr, |builder, context_expr| { + self.infer_target(target, &item.context_expr, |builder| { // TODO: `infer_with_statement_definition` reports a diagnostic if `ctx_manager_ty` isn't a context manager // but only if the target is a name. We should report a diagnostic here if the target isn't a name: // `with not_context_manager as a.x: ... builder - .infer_standalone_expression(context_expr, TypeContext::default()) + .infer_standalone_expression(&item.context_expr, TypeContext::default()) .enter(builder.db()) }); } else { @@ -2873,7 +2881,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; self.store_expression_type(target, target_ty); - self.add_binding(target.into(), definition, target_ty); + self.add_binding(target.into(), definition, |_, _| target_ty); } /// Infers the type of a context expression (`with expr`) and returns the target's type @@ -3005,7 +3013,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.add_binding( except_handler_definition.node(self.module()).into(), definition, - symbol_ty, + |_, _| symbol_ty, ); } @@ -3174,11 +3182,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // against the subject expression type (which we can query via `infer_expression_types`) // and extract the type at the `index` position if the pattern matches. This will be // similar to the logic in `self.infer_assignment_definition`. - self.add_binding( - pattern.into(), - definition, - todo_type!("`match` pattern definition types"), - ); + self.add_binding(pattern.into(), definition, |_, _| { + todo_type!("`match` pattern definition types") + }); } fn infer_match_pattern(&mut self, pattern: &ast::Pattern) { @@ -3299,8 +3305,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = assignment; for target in targets { - self.infer_target(target, value, |builder, value_expr| { - builder.infer_standalone_expression(value_expr, TypeContext::default()) + self.infer_target(target, value, |builder| { + builder.infer_standalone_expression(value, TypeContext::default()) }); } } @@ -3316,11 +3322,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// `target`. fn infer_target(&mut self, target: &ast::Expr, value: &ast::Expr, infer_value_expr: F) where - F: Fn(&mut TypeInferenceBuilder<'db, '_>, &ast::Expr) -> Type<'db>, + F: Fn(&mut Self) -> Type<'db>, { let assigned_ty = match target { ast::Expr::Name(_) => None, - _ => Some(infer_value_expr(self, value)), + _ => Some(infer_value_expr(self)), }; self.infer_target_impl(target, value, assigned_ty); } @@ -4069,6 +4075,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { assignment: &AssignmentDefinitionKind<'db>, definition: Definition<'db>, ) { + let target = assignment.target(self.module()); + + self.add_binding(target.into(), definition, |builder, tcx| { + let target_ty = builder.infer_assignment_definition_impl(assignment, definition, tcx); + builder.store_expression_type(target, target_ty); + target_ty + }); + } + + fn infer_assignment_definition_impl( + &mut self, + assignment: &AssignmentDefinitionKind<'db>, + definition: Definition<'db>, + tcx: TypeContext<'db>, + ) -> Type<'db> { let value = assignment.value(self.module()); let target = assignment.target(self.module()); @@ -4084,7 +4105,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { unpacked.expression_type(target) } TargetKind::Single => { - let tcx = TypeContext::default(); let value_ty = if let Some(standalone_expression) = self.index.try_expression(value) { self.infer_standalone_expression_impl(value, standalone_expression, tcx) @@ -4109,6 +4129,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } else { self.infer_call_expression_impl(call_expr, callable_type, tcx) }; + self.store_expression_type(value, ty); ty } else { @@ -4140,8 +4161,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { target_ty = Type::SpecialForm(special_form); } - self.store_expression_type(target, target_ty); - self.add_binding(target.into(), definition, target_ty); + target_ty } fn infer_legacy_typevar( @@ -4678,7 +4698,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { definition: Definition<'db>, ) { let target_ty = self.infer_augment_assignment(assignment); - self.add_binding(assignment.into(), definition, target_ty); + self.add_binding(assignment.into(), definition, |_, _| target_ty); } fn infer_augment_assignment(&mut self, assignment: &ast::StmtAugAssign) -> Type<'db> { @@ -4729,12 +4749,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { is_async: _, } = for_statement; - self.infer_target(target, iter, |builder, iter_expr| { + self.infer_target(target, iter, |builder| { // TODO: `infer_for_statement_definition` reports a diagnostic if `iter_ty` isn't iterable // but only if the target is a name. We should report a diagnostic here if the target isn't a name: // `for a.x in not_iterable: ... builder - .infer_standalone_expression(iter_expr, TypeContext::default()) + .infer_standalone_expression(iter, TypeContext::default()) .iterate(builder.db()) .homogeneous_element_type(builder.db()) }); @@ -4778,7 +4798,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; self.store_expression_type(target, loop_var_value_type); - self.add_binding(target.into(), definition, loop_var_value_type); + self.add_binding(target.into(), definition, |_, _| loop_var_value_type); } fn infer_while_statement(&mut self, while_statement: &ast::StmtWhile) { @@ -6291,23 +6311,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { is_async: _, } = comprehension; - self.infer_target(target, iter, |builder, iter_expr| { + self.infer_target(target, iter, |builder| { // TODO: `infer_comprehension_definition` reports a diagnostic if `iter_ty` isn't iterable // but only if the target is a name. We should report a diagnostic here if the target isn't a name: // `[... for a.x in not_iterable] if is_first { infer_same_file_expression_type( builder.db(), - builder.index.expression(iter_expr), + builder.index.expression(iter), TypeContext::default(), builder.module(), ) } else { - builder.infer_standalone_expression(iter_expr, TypeContext::default()) + builder.infer_standalone_expression(iter, TypeContext::default()) } .iterate(builder.db()) .homogeneous_element_type(builder.db()) }); + for expr in ifs { self.infer_standalone_expression(expr, TypeContext::default()); } @@ -6365,7 +6386,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; self.expressions.insert(target.into(), target_type); - self.add_binding(target.into(), definition, target_type); + self.add_binding(target.into(), definition, |_, _| target_type); } fn infer_named_expression(&mut self, named: &ast::ExprNamed) -> Type<'db> { @@ -6395,12 +6416,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { value, } = named; - let value_ty = self.infer_expression(value, TypeContext::default()); self.infer_expression(target, TypeContext::default()); - self.add_binding(named.into(), definition, value_ty); - - value_ty + self.add_binding(named.into(), definition, |builder, tcx| { + builder.infer_expression(value, tcx) + }) } fn infer_if_expression(&mut self, if_expression: &ast::ExprIf) -> Type<'db> { @@ -8549,8 +8569,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // - `[ast::CompOp::Is]`: return `false` if unequal, `bool` if equal // - `[ast::CompOp::IsNot]`: return `true` if unequal, `bool` if equal let db = self.db(); - let try_dunder = |inference: &mut TypeInferenceBuilder<'db, '_>, - policy: MemberLookupPolicy| { + let try_dunder = |inference: &mut Self, policy: MemberLookupPolicy| { let rich_comparison = |op| inference.infer_rich_comparison(left, right, op, policy); let membership_test_comparison = |op, range: TextRange| { inference.infer_membership_test_comparison(left, right, op, range) From b0e10a977744865bf412494641656a72c8eec239 Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Thu, 16 Oct 2025 15:59:46 -0400 Subject: [PATCH 079/113] [ty] Don't track inferability via different `Type` variants (#20677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have to track whether a typevar appears in a position where it's inferable or not. In a non-inferable position (in the body of the generic class or function that binds it), assignability must hold for every possible specialization of the typevar. In an inferable position, it only needs to hold for _some_ specialization. https://github.com/astral-sh/ruff/pull/20093 is working on using constraint sets to model assignability of typevars, and the constraint sets that we produce will be the same for inferable vs non-inferable typevars; what changes is what we _compare_ that constraint set to. (For a non-inferable typevar, the constraint set must equal the set of valid specializations; for an inferable typevar, it must not be `never`.) When I first added support for tracking inferable vs non-inferable typevars, it seemed like it would be easiest to have separate `Type` variants for each. The alternative (which lines up with the Δ set in [POPL15](https://doi.org/10.1145/2676726.2676991)) would be to explicitly plumb through a list of inferable typevars through our type property methods. That seemed cumbersome. In retrospect, that was the wrong decision. We've had to jump through hoops to translate types between the inferable and non-inferable variants, which has been quite brittle. Combined with the original point above, that much of the assignability logic will become more identical between inferable and non-inferable, there is less justification for the two `Type` variants. And plumbing an extra `inferable` parameter through all of these methods turns out to not be as bad as I anticipated. --------- Co-authored-by: Alex Waygood --- crates/ty_ide/src/completion.rs | 2 +- crates/ty_ide/src/semantic_tokens.rs | 4 +- crates/ty_python_semantic/src/types.rs | 317 +++++------------- .../src/types/bound_super.rs | 2 +- .../ty_python_semantic/src/types/builder.rs | 3 +- .../ty_python_semantic/src/types/call/bind.rs | 2 +- crates/ty_python_semantic/src/types/class.rs | 11 +- .../src/types/class_base.rs | 1 - .../ty_python_semantic/src/types/display.rs | 4 +- .../ty_python_semantic/src/types/function.rs | 31 +- .../ty_python_semantic/src/types/generics.rs | 167 +++++++-- .../src/types/ide_support.rs | 1 - .../src/types/infer/builder.rs | 58 +++- crates/ty_python_semantic/src/types/narrow.rs | 18 +- .../src/types/signatures.rs | 53 ++- .../src/types/type_ordering.rs | 4 - .../ty_python_semantic/src/types/visitor.rs | 7 - 17 files changed, 312 insertions(+), 373 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 2c57152248..1599a2bed5 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -131,7 +131,7 @@ impl<'db> Completion<'db> { | Type::BytesLiteral(_) => CompletionKind::Value, Type::EnumLiteral(_) => CompletionKind::Enum, Type::ProtocolInstance(_) => CompletionKind::Interface, - Type::NonInferableTypeVar(_) | Type::TypeVar(_) => CompletionKind::TypeParameter, + Type::TypeVar(_) => CompletionKind::TypeParameter, Type::Union(union) => union .elements(db) .iter() diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index dccebf3c4b..f5ea379884 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -336,9 +336,7 @@ impl<'db> SemanticTokenVisitor<'db> { match ty { Type::ClassLiteral(_) => (SemanticTokenType::Class, modifiers), - Type::NonInferableTypeVar(_) | Type::TypeVar(_) => { - (SemanticTokenType::TypeParameter, modifiers) - } + Type::TypeVar(_) => (SemanticTokenType::TypeParameter, modifiers), Type::FunctionLiteral(_) => { // Check if this is a method based on current scope if self.in_class_scope { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1e1c9f0965..53c4f427aa 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -800,15 +800,9 @@ pub enum Type<'db> { LiteralString, /// A bytes literal BytesLiteral(BytesLiteralType<'db>), - /// An instance of a typevar in a context where we can infer a specialization for it. (This is - /// typically the signature of a generic function, or of a constructor of a generic class.) - /// When the generic class or function binding this typevar is specialized, we will replace the - /// typevar with its specialization. + /// An instance of a typevar. When the generic class or function binding this typevar is + /// specialized, we will replace the typevar with its specialization. TypeVar(BoundTypeVarInstance<'db>), - /// An instance of a typevar where we cannot infer a specialization for it. (This is typically - /// the body of the generic function or class that binds the typevar.) In these positions, - /// properties like assignability must hold for all possible specializations. - NonInferableTypeVar(BoundTypeVarInstance<'db>), /// A bound super object like `super()` or `super(A, A())` /// This type doesn't handle an unbound super object like `super(A)`; for that we just use /// a `Type::NominalInstance` of `builtins.super`. @@ -1374,9 +1368,6 @@ impl<'db> Type<'db> { Type::TypeVar(bound_typevar) => visitor.visit(self, || { Type::TypeVar(bound_typevar.normalized_impl(db, visitor)) }), - Type::NonInferableTypeVar(bound_typevar) => visitor.visit(self, || { - Type::NonInferableTypeVar(bound_typevar.normalized_impl(db, visitor)) - }), Type::KnownInstance(known_instance) => visitor.visit(self, || { Type::KnownInstance(known_instance.normalized_impl(db, visitor)) }), @@ -1453,7 +1444,6 @@ impl<'db> Type<'db> { | Type::Union(_) | Type::Intersection(_) | Type::Callable(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::BoundSuper(_) | Type::TypeIs(_) @@ -1544,7 +1534,6 @@ impl<'db> Type<'db> { | Type::KnownInstance(_) | Type::PropertyInstance(_) | Type::Intersection(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::BoundSuper(_) => None, } @@ -1729,18 +1718,21 @@ impl<'db> Type<'db> { // However, there is one exception to this general rule: for any given typevar `T`, // `T` will always be a subtype of any union containing `T`. // A similar rule applies in reverse to intersection types. - (Type::NonInferableTypeVar(_), Type::Union(union)) - if union.elements(db).contains(&self) => + (Type::TypeVar(bound_typevar), Type::Union(union)) + if !bound_typevar.is_inferable(db, inferable) + && union.elements(db).contains(&self) => { ConstraintSet::from(true) } - (Type::Intersection(intersection), Type::NonInferableTypeVar(_)) - if intersection.positive(db).contains(&target) => + (Type::Intersection(intersection), Type::TypeVar(bound_typevar)) + if !bound_typevar.is_inferable(db, inferable) + && intersection.positive(db).contains(&target) => { ConstraintSet::from(true) } - (Type::Intersection(intersection), Type::NonInferableTypeVar(_)) - if intersection.negative(db).contains(&target) => + (Type::Intersection(intersection), Type::TypeVar(bound_typevar)) + if !bound_typevar.is_inferable(db, inferable) + && intersection.negative(db).contains(&target) => { ConstraintSet::from(false) } @@ -1750,18 +1742,19 @@ impl<'db> Type<'db> { // // Note that this is not handled by the early return at the beginning of this method, // since subtyping between a TypeVar and an arbitrary other type cannot be guaranteed to be reflexive. - ( - Type::NonInferableTypeVar(lhs_bound_typevar), - Type::NonInferableTypeVar(rhs_bound_typevar), - ) if lhs_bound_typevar.identity(db) == rhs_bound_typevar.identity(db) => { + (Type::TypeVar(lhs_bound_typevar), Type::TypeVar(rhs_bound_typevar)) + if !lhs_bound_typevar.is_inferable(db, inferable) + && lhs_bound_typevar.identity(db) == rhs_bound_typevar.identity(db) => + { ConstraintSet::from(true) } // A fully static typevar is a subtype of its upper bound, and to something similar to // the union of its constraints. An unbound, unconstrained, fully static typevar has an // implicit upper bound of `object` (which is handled above). - (Type::NonInferableTypeVar(bound_typevar), _) - if bound_typevar.typevar(db).bound_or_constraints(db).is_some() => + (Type::TypeVar(bound_typevar), _) + if !bound_typevar.is_inferable(db, inferable) + && bound_typevar.typevar(db).bound_or_constraints(db).is_some() => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => unreachable!(), @@ -1792,23 +1785,24 @@ impl<'db> Type<'db> { // If the typevar is constrained, there must be multiple constraints, and the typevar // might be specialized to any one of them. However, the constraints do not have to be // disjoint, which means an lhs type might be a subtype of all of the constraints. - (_, Type::NonInferableTypeVar(bound_typevar)) - if !bound_typevar - .typevar(db) - .constraints(db) - .when_some_and(|constraints| { - constraints.iter().when_all(db, |constraint| { - self.has_relation_to_impl( - db, - *constraint, - inferable, - relation, - relation_visitor, - disjointness_visitor, - ) + (_, Type::TypeVar(bound_typevar)) + if !bound_typevar.is_inferable(db, inferable) + && !bound_typevar + .typevar(db) + .constraints(db) + .when_some_and(|constraints| { + constraints.iter().when_all(db, |constraint| { + self.has_relation_to_impl( + db, + *constraint, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) }) - }) - .is_never_satisfied() => + .is_never_satisfied() => { // TODO: The repetition here isn't great, but we really need the fallthrough logic, // where this arm only engages if it returns true (or in the world of constraints, @@ -1831,7 +1825,9 @@ impl<'db> Type<'db> { }) } - (Type::TypeVar(_), _) if relation.is_assignability() => { + (Type::TypeVar(bound_typevar), _) + if bound_typevar.is_inferable(db, inferable) && relation.is_assignability() => + { // The implicit lower bound of a typevar is `Never`, which means // that it is always assignable to any other type. @@ -1932,10 +1928,13 @@ impl<'db> Type<'db> { // (If the typevar is bounded, it might be specialized to a smaller type than the // bound. This is true even if the bound is a final class, since the typevar can still // be specialized to `Never`.) - (_, Type::NonInferableTypeVar(_)) => ConstraintSet::from(false), + (_, Type::TypeVar(bound_typevar)) if !bound_typevar.is_inferable(db, inferable) => { + ConstraintSet::from(false) + } (_, Type::TypeVar(typevar)) - if relation.is_assignability() + if typevar.is_inferable(db, inferable) + && relation.is_assignability() && typevar.typevar(db).upper_bound(db).is_none_or(|bound| { !self .has_relation_to_impl( @@ -1964,7 +1963,11 @@ impl<'db> Type<'db> { } // TODO: Infer specializations here - (Type::TypeVar(_), _) | (_, Type::TypeVar(_)) => ConstraintSet::from(false), + (Type::TypeVar(bound_typevar), _) | (_, Type::TypeVar(bound_typevar)) + if bound_typevar.is_inferable(db, inferable) => + { + ConstraintSet::from(false) + } (Type::TypedDict(_), _) | (_, Type::TypedDict(_)) => { // TODO: Implement assignability and subtyping for TypedDict @@ -2399,9 +2402,12 @@ impl<'db> Type<'db> { // Other than the special cases enumerated above, `Instance` types and typevars are // never subtypes of any other variants - (Type::NominalInstance(_) | Type::NonInferableTypeVar(_), _) => { + (Type::TypeVar(bound_typevar), _) => { + // All inferable cases should have been handled above + assert!(!bound_typevar.is_inferable(db, inferable)); ConstraintSet::from(false) } + (Type::NominalInstance(_), _) => ConstraintSet::from(false), } } @@ -2633,16 +2639,17 @@ impl<'db> Type<'db> { // be specialized to the same type. (This is an important difference between typevars // and `Any`!) Different typevars might be disjoint, depending on their bounds and // constraints, which are handled below. - ( - Type::NonInferableTypeVar(self_bound_typevar), - Type::NonInferableTypeVar(other_bound_typevar), - ) if self_bound_typevar.identity(db) == other_bound_typevar.identity(db) => { + (Type::TypeVar(self_bound_typevar), Type::TypeVar(other_bound_typevar)) + if !self_bound_typevar.is_inferable(db, inferable) + && self_bound_typevar.identity(db) == other_bound_typevar.identity(db) => + { ConstraintSet::from(false) } - (tvar @ Type::NonInferableTypeVar(_), Type::Intersection(intersection)) - | (Type::Intersection(intersection), tvar @ Type::NonInferableTypeVar(_)) - if intersection.negative(db).contains(&tvar) => + (tvar @ Type::TypeVar(bound_typevar), Type::Intersection(intersection)) + | (Type::Intersection(intersection), tvar @ Type::TypeVar(bound_typevar)) + if !bound_typevar.is_inferable(db, inferable) + && intersection.negative(db).contains(&tvar) => { ConstraintSet::from(true) } @@ -2651,8 +2658,9 @@ impl<'db> Type<'db> { // specialized to any type. A bounded typevar is not disjoint from its bound, and is // only disjoint from other types if its bound is. A constrained typevar is disjoint // from a type if all of its constraints are. - (Type::NonInferableTypeVar(bound_typevar), other) - | (other, Type::NonInferableTypeVar(bound_typevar)) => { + (Type::TypeVar(bound_typevar), other) | (other, Type::TypeVar(bound_typevar)) + if !bound_typevar.is_inferable(db, inferable) => + { match bound_typevar.typevar(db).bound_or_constraints(db) { None => ConstraintSet::from(false), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => bound @@ -3300,7 +3308,7 @@ impl<'db> Type<'db> { // the bound is a final singleton class, since it can still be specialized to `Never`. // A constrained typevar is a singleton if all of its constraints are singletons. (Note // that you cannot specialize a constrained typevar to a subtype of a constraint.) - Type::NonInferableTypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => false, Some(TypeVarBoundOrConstraints::UpperBound(_)) => false, @@ -3311,8 +3319,6 @@ impl<'db> Type<'db> { } } - Type::TypeVar(_) => false, - // We eagerly transform `SubclassOf` to `ClassLiteral` for final types, so `SubclassOf` is never a singleton. Type::SubclassOf(..) => false, Type::BoundSuper(..) => false, @@ -3411,7 +3417,7 @@ impl<'db> Type<'db> { // `Never`. A constrained typevar is single-valued if all of its constraints are // single-valued. (Note that you cannot specialize a constrained typevar to a subtype // of a constraint.) - Type::NonInferableTypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => false, Some(TypeVarBoundOrConstraints::UpperBound(_)) => false, @@ -3422,8 +3428,6 @@ impl<'db> Type<'db> { } } - Type::TypeVar(_) => false, - Type::SubclassOf(..) => { // TODO: Same comment as above for `is_singleton` false @@ -3588,7 +3592,6 @@ impl<'db> Type<'db> { | Type::LiteralString | Type::BytesLiteral(_) | Type::EnumLiteral(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::NominalInstance(_) | Type::ProtocolInstance(_) @@ -3699,7 +3702,7 @@ impl<'db> Type<'db> { Type::object().instance_member(db, name) } - Type::NonInferableTypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => Type::object().instance_member(db, name), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { @@ -3712,16 +3715,6 @@ impl<'db> Type<'db> { } } - Type::TypeVar(_) => { - debug_assert!( - false, - "should not be able to access instance member `{name}` \ - of type variable {} in inferable position", - self.display(db) - ); - Place::Undefined.into() - } - Type::IntLiteral(_) => KnownClass::Int.to_instance(db).instance_member(db, name), Type::BooleanLiteral(_) | Type::TypeIs(_) => { KnownClass::Bool.to_instance(db).instance_member(db, name) @@ -4298,7 +4291,6 @@ impl<'db> Type<'db> { | Type::BytesLiteral(..) | Type::EnumLiteral(..) | Type::LiteralString - | Type::NonInferableTypeVar(..) | Type::TypeVar(..) | Type::SpecialForm(..) | Type::KnownInstance(..) @@ -4647,7 +4639,7 @@ impl<'db> Type<'db> { } }, - Type::NonInferableTypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => Truthiness::Ambiguous, Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { @@ -4658,7 +4650,6 @@ impl<'db> Type<'db> { } } } - Type::TypeVar(_) => Truthiness::Ambiguous, Type::NominalInstance(instance) => instance .known_class(db) @@ -4757,7 +4748,7 @@ impl<'db> Type<'db> { .into() } - Type::NonInferableTypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => CallableBinding::not_callable(self).into(), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => bound.bindings(db), @@ -4770,15 +4761,6 @@ impl<'db> Type<'db> { } } - Type::TypeVar(_) => { - debug_assert!( - false, - "should not be able to call type variable {} in inferable position", - self.display(db) - ); - CallableBinding::not_callable(self).into() - } - Type::BoundMethod(bound_method) => { let signature = bound_method.function(db).signature(db); CallableBinding::from_overloads(self, signature.overloads.iter().cloned()) @@ -5635,16 +5617,12 @@ impl<'db> Type<'db> { Type::TypeAlias(alias) => { non_async_special_case(db, alias.value_type(db)) } - Type::NonInferableTypeVar(tvar) => match tvar.typevar(db).bound_or_constraints(db)? { + Type::TypeVar(tvar) => match tvar.typevar(db).bound_or_constraints(db)? { TypeVarBoundOrConstraints::UpperBound(bound) => { non_async_special_case(db, bound) } TypeVarBoundOrConstraints::Constraints(union) => non_async_special_case(db, Type::Union(union)), }, - Type::TypeVar(_) => unreachable!( - "should not be able to iterate over type variable {} in inferable position", - ty.display(db) - ), Type::Union(union) => { let elements = union.elements(db); if elements.len() < MAX_TUPLE_LENGTH { @@ -6040,7 +6018,7 @@ impl<'db> Type<'db> { // It is important that identity_specialization specializes the class with // _inferable_ typevars, so that our specialization inference logic will // try to find a specialization for them. - Type::from(class.identity_specialization(db, &Type::TypeVar)), + Type::from(class.identity_specialization(db)), ), _ => (None, None, self), }, @@ -6193,9 +6171,6 @@ impl<'db> Type<'db> { // If there is no bound or constraints on a typevar `T`, `T: object` implicitly, which // has no instance type. Otherwise, synthesize a typevar with bound or constraints // mapped through `to_instance`. - Type::NonInferableTypeVar(bound_typevar) => { - Some(Type::NonInferableTypeVar(bound_typevar.to_instance(db)?)) - } Type::TypeVar(bound_typevar) => Some(Type::TypeVar(bound_typevar.to_instance(db)?)), Type::TypeAlias(alias) => alias.value_type(db).to_instance(db), Type::Intersection(_) => Some(todo_type!("Type::Intersection.to_instance")), @@ -6279,7 +6254,6 @@ impl<'db> Type<'db> { | Type::LiteralString | Type::ModuleLiteral(_) | Type::StringLiteral(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::Callable(_) | Type::BoundMethod(_) @@ -6311,7 +6285,7 @@ impl<'db> Type<'db> { typevar_binding_context, *typevar, ) - .map(Type::NonInferableTypeVar) + .map(Type::TypeVar) .unwrap_or(*self)) } KnownInstanceType::Deprecated(_) => Err(InvalidTypeExpressionError { @@ -6388,14 +6362,7 @@ impl<'db> Type<'db> { }); }; - Ok(typing_self( - db, - scope_id, - typevar_binding_context, - class, - &Type::NonInferableTypeVar, - ) - .unwrap_or(*self)) + Ok(typing_self(db, scope_id, typevar_binding_context, class).unwrap_or(*self)) } SpecialFormType::TypeAlias => Ok(Type::Dynamic(DynamicType::TodoTypeAlias)), SpecialFormType::TypedDict => Err(InvalidTypeExpressionError { @@ -6565,7 +6532,7 @@ impl<'db> Type<'db> { } Type::Callable(_) | Type::DataclassTransformer(_) => KnownClass::Type.to_instance(db), Type::ModuleLiteral(_) => KnownClass::ModuleType.to_class_literal(db), - Type::NonInferableTypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => KnownClass::Type.to_instance(db), Some(TypeVarBoundOrConstraints::UpperBound(bound)) => bound.to_meta_type(db), @@ -6576,7 +6543,6 @@ impl<'db> Type<'db> { } } } - Type::TypeVar(_) => KnownClass::Type.to_instance(db), Type::ClassLiteral(class) => class.metaclass(db), Type::GenericAlias(alias) => ClassType::from(alias).metaclass(db), @@ -6710,36 +6676,12 @@ impl<'db> Type<'db> { } } TypeMapping::PromoteLiterals - | TypeMapping::BindLegacyTypevars(_) - | TypeMapping::MarkTypeVarsInferable(_) => self, + | TypeMapping::BindLegacyTypevars(_) => self, TypeMapping::Materialize(materialization_kind) => { Type::TypeVar(bound_typevar.materialize_impl(db, *materialization_kind, visitor)) } } - Type::NonInferableTypeVar(bound_typevar) => match type_mapping { - TypeMapping::Specialization(specialization) => { - specialization.get(db, bound_typevar).unwrap_or(self) - } - TypeMapping::PartialSpecialization(partial) => { - partial.get(db, bound_typevar).unwrap_or(self) - } - TypeMapping::MarkTypeVarsInferable(binding_context) => { - if binding_context.is_none_or(|context| context == bound_typevar.binding_context(db)) { - Type::TypeVar(bound_typevar.mark_typevars_inferable(db, visitor)) - } else { - self - } - } - TypeMapping::PromoteLiterals - | TypeMapping::BindLegacyTypevars(_) - | TypeMapping::BindSelf(_) - | TypeMapping::ReplaceSelf { .. } - => self, - TypeMapping::Materialize(materialization_kind) => Type::NonInferableTypeVar(bound_typevar.materialize_impl(db, *materialization_kind, visitor)) - - } - Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => match type_mapping { TypeMapping::BindLegacyTypevars(binding_context) => { Type::TypeVar(BoundTypeVarInstance::new(db, typevar, *binding_context)) @@ -6749,7 +6691,6 @@ impl<'db> Type<'db> { TypeMapping::PromoteLiterals | TypeMapping::BindSelf(_) | TypeMapping::ReplaceSelf { .. } | - TypeMapping::MarkTypeVarsInferable(_) | TypeMapping::Materialize(_) => self, } @@ -6864,7 +6805,6 @@ impl<'db> Type<'db> { TypeMapping::BindLegacyTypevars(_) | TypeMapping::BindSelf(_) | TypeMapping::ReplaceSelf { .. } | - TypeMapping::MarkTypeVarsInferable(_) | TypeMapping::Materialize(_) => self, TypeMapping::PromoteLiterals => self.promote_literals_impl(db, tcx) } @@ -6875,7 +6815,6 @@ impl<'db> Type<'db> { TypeMapping::BindLegacyTypevars(_) | TypeMapping::BindSelf(_) | TypeMapping::ReplaceSelf { .. } | - TypeMapping::MarkTypeVarsInferable(_) | TypeMapping::PromoteLiterals => self, TypeMapping::Materialize(materialization_kind) => match materialization_kind { MaterializationKind::Top => Type::object(), @@ -6925,7 +6864,7 @@ impl<'db> Type<'db> { visitor: &FindLegacyTypeVarsVisitor<'db>, ) { match self { - Type::NonInferableTypeVar(bound_typevar) | Type::TypeVar(bound_typevar) => { + Type::TypeVar(bound_typevar) => { if matches!( bound_typevar.typevar(db).kind(db), TypeVarKind::Legacy | TypeVarKind::TypingSelf @@ -7157,7 +7096,6 @@ impl<'db> Type<'db> { | Self::PropertyInstance(_) | Self::BoundSuper(_) => self.to_meta_type(db).definition(db), - Self::NonInferableTypeVar(bound_typevar) | Self::TypeVar(bound_typevar) => Some(TypeDefinition::TypeVar(bound_typevar.typevar(db).definition(db)?)), Self::ProtocolInstance(protocol) => match protocol.inner { @@ -7299,9 +7237,7 @@ impl<'db> VarianceInferable<'db> for Type<'db> { Type::GenericAlias(generic_alias) => generic_alias.variance_of(db, typevar), Type::Callable(callable_type) => callable_type.signatures(db).variance_of(db, typevar), // A type variable is always covariant in itself. - Type::TypeVar(other_typevar) | Type::NonInferableTypeVar(other_typevar) - if other_typevar == typevar => - { + Type::TypeVar(other_typevar) if other_typevar == typevar => { // type variables are covariant in themselves TypeVarVariance::Covariant } @@ -7357,7 +7293,6 @@ impl<'db> VarianceInferable<'db> for Type<'db> { | Type::AlwaysTruthy | Type::BoundSuper(_) | Type::TypeVar(_) - | Type::NonInferableTypeVar(_) | Type::TypedDict(_) | Type::TypeAlias(_) => TypeVarVariance::Bivariant, }; @@ -7430,17 +7365,6 @@ pub enum TypeMapping<'a, 'db> { BindSelf(Type<'db>), /// Replaces occurrences of `typing.Self` with a new `Self` type variable with the given upper bound. ReplaceSelf { new_upper_bound: Type<'db> }, - /// Marks type variables as inferable. - /// - /// When we create the signature for a generic function, we mark its type variables as inferable. Since - /// the generic function might reference type variables from enclosing generic scopes, we include the - /// function's binding context in order to only mark those type variables as inferable that are actually - /// bound by that function. - /// - /// When the parameter is set to `None`, *all* type variables will be marked as inferable. We use this - /// variant when descending into the bounds and/or constraints, and the default value of a type variable, - /// which may include nested type variables (`Self` has a bound of `C[T]` for a generic class `C[T]`). - MarkTypeVarsInferable(Option>), /// Create the top or bottom materialization of a type. Materialize(MaterializationKind), } @@ -7457,7 +7381,6 @@ impl<'db> TypeMapping<'_, 'db> { | TypeMapping::PartialSpecialization(_) | TypeMapping::PromoteLiterals | TypeMapping::BindLegacyTypevars(_) - | TypeMapping::MarkTypeVarsInferable(_) | TypeMapping::Materialize(_) => context, TypeMapping::BindSelf(_) => GenericContext::from_typevar_instances( db, @@ -8344,48 +8267,6 @@ impl<'db> TypeVarInstance<'db> { ) } - fn mark_typevars_inferable( - self, - db: &'db dyn Db, - visitor: &ApplyTypeMappingVisitor<'db>, - ) -> Self { - // Type variables can have nested type variables in their bounds, constraints, or default value. - // When we mark a type variable as inferable, we also mark all of these nested type variables as - // inferable, so we set the parameter to `None` here. - let type_mapping = &TypeMapping::MarkTypeVarsInferable(None); - - let new_bound_or_constraints = - self._bound_or_constraints(db) - .map(|bound_or_constraints| match bound_or_constraints { - TypeVarBoundOrConstraintsEvaluation::Eager(bound_or_constraints) => { - bound_or_constraints - .mark_typevars_inferable(db, visitor) - .into() - } - TypeVarBoundOrConstraintsEvaluation::LazyUpperBound - | TypeVarBoundOrConstraintsEvaluation::LazyConstraints => bound_or_constraints, - }); - - let new_default = self._default(db).and_then(|default| match default { - TypeVarDefaultEvaluation::Eager(ty) => Some( - ty.apply_type_mapping_impl(db, type_mapping, TypeContext::default(), visitor) - .into(), - ), - TypeVarDefaultEvaluation::Lazy => self.lazy_default(db).map(|ty| { - ty.apply_type_mapping_impl(db, type_mapping, TypeContext::default(), visitor) - .into() - }), - }); - - Self::new( - db, - self.identity(db), - new_bound_or_constraints, - self.explicit_variance(db), - new_default, - ) - } - fn to_instance(self, db: &'db dyn Db) -> Option { let bound_or_constraints = match self.bound_or_constraints(db)? { TypeVarBoundOrConstraints::UpperBound(upper_bound) => { @@ -8543,7 +8424,7 @@ impl<'db> BindingContext<'db> { /// independent of the typevar's bounds or constraints. Two bound typevars have the same identity /// if they represent the same logical typevar bound in the same context, even if their bounds /// have been materialized differently. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] pub struct BoundTypeVarIdentity<'db> { pub(crate) identity: TypeVarIdentity<'db>, pub(crate) binding_context: BindingContext<'db>, @@ -8708,18 +8589,6 @@ impl<'db> BoundTypeVarInstance<'db> { ) } - fn mark_typevars_inferable( - self, - db: &'db dyn Db, - visitor: &ApplyTypeMappingVisitor<'db>, - ) -> Self { - Self::new( - db, - self.typevar(db).mark_typevars_inferable(db, visitor), - self.binding_context(db), - ) - } - fn to_instance(self, db: &'db dyn Db) -> Option { Some(Self::new( db, @@ -8825,38 +8694,6 @@ impl<'db> TypeVarBoundOrConstraints<'db> { } } } - - fn mark_typevars_inferable( - self, - db: &'db dyn Db, - visitor: &ApplyTypeMappingVisitor<'db>, - ) -> Self { - let type_mapping = &TypeMapping::MarkTypeVarsInferable(None); - - match self { - TypeVarBoundOrConstraints::UpperBound(bound) => TypeVarBoundOrConstraints::UpperBound( - bound.apply_type_mapping_impl(db, type_mapping, TypeContext::default(), visitor), - ), - TypeVarBoundOrConstraints::Constraints(constraints) => { - TypeVarBoundOrConstraints::Constraints(UnionType::new( - db, - constraints - .elements(db) - .iter() - .map(|ty| { - ty.apply_type_mapping_impl( - db, - type_mapping, - TypeContext::default(), - visitor, - ) - }) - .collect::>() - .into_boxed_slice(), - )) - } - } - } } /// Error returned if a type is not awaitable. diff --git a/crates/ty_python_semantic/src/types/bound_super.rs b/crates/ty_python_semantic/src/types/bound_super.rs index 9bacb1454c..72782a4eac 100644 --- a/crates/ty_python_semantic/src/types/bound_super.rs +++ b/crates/ty_python_semantic/src/types/bound_super.rs @@ -343,7 +343,7 @@ impl<'db> BoundSuperType<'db> { Type::TypeAlias(alias) => { return delegate_with_error_mapped(alias.value_type(db), None); } - Type::TypeVar(type_var) | Type::NonInferableTypeVar(type_var) => { + Type::TypeVar(type_var) => { let type_var = type_var.typevar(db); return match type_var.bound_or_constraints(db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 944516c6e8..152f9ba325 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -1062,8 +1062,7 @@ impl<'db> InnerIntersectionBuilder<'db> { let mut positive_to_remove = SmallVec::<[usize; 1]>::new(); for (typevar_index, ty) in self.positive.iter().enumerate() { - let (Type::NonInferableTypeVar(bound_typevar) | Type::TypeVar(bound_typevar)) = ty - else { + let Type::TypeVar(bound_typevar) = ty else { continue; }; let Some(TypeVarBoundOrConstraints::Constraints(constraints)) = diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 2260350b46..b4c00af378 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2589,7 +2589,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { return; }; - // TODO: Use the list of inferable typevars from the generic context of the callable. + self.inferable_typevars = generic_context.inferable_typevars(self.db); let mut builder = SpecializationBuilder::new(self.db, self.inferable_typevars); let parameters = self.signature.parameters(); diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 15cb368c70..d62ee839ce 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1627,15 +1627,10 @@ impl<'db> ClassLiteral<'db> { }) } - /// Returns a specialization of this class where each typevar is mapped to itself. The second - /// parameter can be `Type::TypeVar` or `Type::NonInferableTypeVar`, depending on the use case. - pub(crate) fn identity_specialization( - self, - db: &'db dyn Db, - typevar_to_type: &impl Fn(BoundTypeVarInstance<'db>) -> Type<'db>, - ) -> ClassType<'db> { + /// Returns a specialization of this class where each typevar is mapped to itself. + pub(crate) fn identity_specialization(self, db: &'db dyn Db) -> ClassType<'db> { self.apply_specialization(db, |generic_context| { - generic_context.identity_specialization(db, typevar_to_type) + generic_context.identity_specialization(db) }) } diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 204cbfb350..d22dbd5542 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -155,7 +155,6 @@ impl<'db> ClassBase<'db> { | Type::StringLiteral(_) | Type::LiteralString | Type::ModuleLiteral(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::BoundSuper(_) | Type::ProtocolInstance(_) diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 03e5d738b9..ed19a16358 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -560,9 +560,7 @@ impl Display for DisplayRepresentation<'_> { .display_with(self.db, self.settings.clone()), literal_name = enum_literal.name(self.db) ), - Type::NonInferableTypeVar(bound_typevar) | Type::TypeVar(bound_typevar) => { - bound_typevar.identity(self.db).display(self.db).fmt(f) - } + Type::TypeVar(bound_typevar) => bound_typevar.identity(self.db).display(self.db).fmt(f), Type::AlwaysTruthy => f.write_str("AlwaysTruthy"), Type::AlwaysFalsy => f.write_str("AlwaysFalsy"), Type::BoundSuper(bound_super) => { diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index e691ed1cf8..1020056b39 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -365,28 +365,12 @@ impl<'db> OverloadLiteral<'db> { if function_node.is_async && !is_generator { signature = signature.wrap_coroutine_return_type(db); } - signature = signature.mark_typevars_inferable(db); - - let pep695_ctx = function_node.type_params.as_ref().map(|type_params| { - GenericContext::from_type_params(db, index, self.definition(db), type_params) - }); - let legacy_ctx = GenericContext::from_function_params( - db, - self.definition(db), - signature.parameters(), - signature.return_ty, - ); - // We need to update `signature.generic_context` here, - // because type variables in `GenericContext::variables` are still non-inferable. - signature.generic_context = - GenericContext::merge_pep695_and_legacy(db, pep695_ctx, legacy_ctx); signature } /// Typed internally-visible "raw" signature for this function. - /// That is, type variables in parameter types and the return type remain non-inferable, - /// and the return types of async functions are not wrapped in `CoroutineType[...]`. + /// That is, the return types of async functions are not wrapped in `CoroutineType[...]`. /// /// ## Warning /// @@ -1133,7 +1117,6 @@ fn is_instance_truthiness<'db>( | Type::PropertyInstance(..) | Type::AlwaysTruthy | Type::AlwaysFalsy - | Type::NonInferableTypeVar(..) | Type::TypeVar(..) | Type::BoundSuper(..) | Type::TypeIs(..) @@ -1729,11 +1712,7 @@ impl KnownFunction { } KnownFunction::RangeConstraint => { - let [ - Some(lower), - Some(Type::NonInferableTypeVar(typevar)), - Some(upper), - ] = parameter_types + let [Some(lower), Some(Type::TypeVar(typevar)), Some(upper)] = parameter_types else { return; }; @@ -1746,11 +1725,7 @@ impl KnownFunction { } KnownFunction::NegatedRangeConstraint => { - let [ - Some(lower), - Some(Type::NonInferableTypeVar(typevar)), - Some(upper), - ] = parameter_types + let [Some(lower), Some(Type::TypeVar(typevar)), Some(upper)] = parameter_types else { return; }; diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 456e5b237f..9a9864bab2 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1,8 +1,9 @@ -use std::marker::PhantomData; +use std::cell::RefCell; +use std::fmt::Display; use itertools::Itertools; use ruff_python_ast as ast; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::semantic_index::definition::Definition; use crate::semantic_index::scope::{FileScopeId, NodeWithScopeKind, ScopeId}; @@ -14,14 +15,16 @@ use crate::types::infer::infer_definition_types; use crate::types::instance::{Protocol, ProtocolInstanceType}; use crate::types::signatures::{Parameter, Parameters, Signature}; use crate::types::tuple::{TupleSpec, TupleType, walk_tuple_type}; +use crate::types::visitor::{NonAtomicType, TypeKind, TypeVisitor, walk_non_atomic_type}; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, ClassLiteral, FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, KnownClass, KnownInstanceType, MaterializationKind, NormalizedVisitor, Type, TypeContext, TypeMapping, TypeRelation, TypeVarBoundOrConstraints, TypeVarIdentity, TypeVarInstance, TypeVarKind, TypeVarVariance, UnionType, binding_type, declaration_type, + walk_bound_type_var_type, }; -use crate::{Db, FxOrderMap, FxOrderSet}; +use crate::{Db, FxIndexSet, FxOrderMap, FxOrderSet}; /// Returns an iterator of any generic context introduced by the given scope or any enclosing /// scope. @@ -106,7 +109,6 @@ pub(crate) fn typing_self<'db>( scope_id: ScopeId, typevar_binding_context: Option>, class: ClassLiteral<'db>, - typevar_to_type: &impl Fn(BoundTypeVarInstance<'db>) -> Type<'db>, ) -> Option> { let index = semantic_index(db, scope_id.file(db)); @@ -118,7 +120,7 @@ pub(crate) fn typing_self<'db>( ); let bounds = TypeVarBoundOrConstraints::UpperBound(Type::instance( db, - class.identity_specialization(db, typevar_to_type), + class.identity_specialization(db), )); let typevar = TypeVarInstance::new( db, @@ -138,17 +140,70 @@ pub(crate) fn typing_self<'db>( typevar_binding_context, typevar, ) - .map(typevar_to_type) + .map(Type::TypeVar) } #[derive(Clone, Copy, Debug)] pub(crate) enum InferableTypeVars<'a, 'db> { None, - // TODO: This variant isn't used, and only exists so that we can include the 'a and 'db in the - // type definition. They will be used soon when we start creating real InferableTypeVars - // instances. - #[expect(unused)] - Unused(PhantomData<&'a &'db ()>), + One(&'a FxHashSet>), + Two( + &'a InferableTypeVars<'a, 'db>, + &'a InferableTypeVars<'a, 'db>, + ), +} + +impl<'db> BoundTypeVarInstance<'db> { + pub(crate) fn is_inferable( + self, + db: &'db dyn Db, + inferable: InferableTypeVars<'_, 'db>, + ) -> bool { + match inferable { + InferableTypeVars::None => false, + InferableTypeVars::One(typevars) => typevars.contains(&self.identity(db)), + InferableTypeVars::Two(left, right) => { + self.is_inferable(db, *left) || self.is_inferable(db, *right) + } + } + } +} + +impl<'a, 'db> InferableTypeVars<'a, 'db> { + pub(crate) fn merge(&'a self, other: Option<&'a InferableTypeVars<'a, 'db>>) -> Self { + match other { + Some(other) => InferableTypeVars::Two(self, other), + None => *self, + } + } + + // Keep this around for debugging purposes + #[expect(dead_code)] + pub(crate) fn display(&self, db: &'db dyn Db) -> impl Display { + fn find_typevars<'db>( + result: &mut FxHashSet>, + inferable: &InferableTypeVars<'_, 'db>, + ) { + match inferable { + InferableTypeVars::None => {} + InferableTypeVars::One(typevars) => result.extend(typevars.iter().copied()), + InferableTypeVars::Two(left, right) => { + find_typevars(result, left); + find_typevars(result, right); + } + } + } + + let mut typevars = FxHashSet::default(); + find_typevars(&mut typevars, self); + format!( + "[{}]", + typevars + .into_iter() + .map(|identity| identity.display(db)) + .format(", ") + ) + } } #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] @@ -255,6 +310,66 @@ impl<'db> GenericContext<'db> { ) } + pub(crate) fn inferable_typevars(self, db: &'db dyn Db) -> InferableTypeVars<'db, 'db> { + #[derive(Default)] + struct CollectTypeVars<'db> { + typevars: RefCell>>, + seen_types: RefCell>>, + } + + impl<'db> TypeVisitor<'db> for CollectTypeVars<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + true + } + + fn visit_bound_type_var_type( + &self, + db: &'db dyn Db, + bound_typevar: BoundTypeVarInstance<'db>, + ) { + self.typevars + .borrow_mut() + .insert(bound_typevar.identity(db)); + walk_bound_type_var_type(db, bound_typevar, self); + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + match TypeKind::from(ty) { + TypeKind::Atomic => {} + TypeKind::NonAtomic(non_atomic_type) => { + if !self.seen_types.borrow_mut().insert(non_atomic_type) { + // If we have already seen this type, we can skip it. + return; + } + walk_non_atomic_type(db, non_atomic_type, self); + } + } + } + } + + #[salsa::tracked( + returns(ref), + cycle_fn=inferable_typevars_cycle_recover, + cycle_initial=inferable_typevars_cycle_initial, + heap_size=ruff_memory_usage::heap_size, + )] + fn inferable_typevars_inner<'db>( + db: &'db dyn Db, + generic_context: GenericContext<'db>, + ) -> FxHashSet> { + let visitor = CollectTypeVars::default(); + for bound_typevar in generic_context.variables(db) { + visitor.visit_bound_type_var_type(db, bound_typevar); + } + visitor.typevars.into_inner() + } + + // This ensures that salsa caches the FxHashSet, not the InferableTypeVars that wraps it. + // (That way InferableTypeVars can contain references, and doesn't need to impl + // salsa::Update.) + InferableTypeVars::One(inferable_typevars_inner(db, self)) + } + pub(crate) fn variables( self, db: &'db dyn Db, @@ -410,14 +525,8 @@ impl<'db> GenericContext<'db> { } /// Returns a specialization of this generic context where each typevar is mapped to itself. - /// The second parameter can be `Type::TypeVar` or `Type::NonInferableTypeVar`, depending on - /// the use case. - pub(crate) fn identity_specialization( - self, - db: &'db dyn Db, - typevar_to_type: &impl Fn(BoundTypeVarInstance<'db>) -> Type<'db>, - ) -> Specialization<'db> { - let types = self.variables(db).map(typevar_to_type).collect(); + pub(crate) fn identity_specialization(self, db: &'db dyn Db) -> Specialization<'db> { + let types = self.variables(db).map(Type::TypeVar).collect(); self.specialize(db, types) } @@ -543,6 +652,22 @@ impl<'db> GenericContext<'db> { } } +fn inferable_typevars_cycle_recover<'db>( + _db: &'db dyn Db, + _value: &FxHashSet>, + _count: u32, + _self: GenericContext<'db>, +) -> salsa::CycleRecoveryAction>> { + salsa::CycleRecoveryAction::Iterate +} + +fn inferable_typevars_cycle_initial<'db>( + _db: &'db dyn Db, + _self: GenericContext<'db>, +) -> FxHashSet> { + FxHashSet::default() +} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(super) enum LegacyGenericBase { Generic, @@ -1357,7 +1482,9 @@ impl<'db> SpecializationBuilder<'db> { } } - (Type::TypeVar(bound_typevar), ty) | (ty, Type::TypeVar(bound_typevar)) => { + (Type::TypeVar(bound_typevar), ty) | (ty, Type::TypeVar(bound_typevar)) + if bound_typevar.is_inferable(self.db, self.inferable) => + { match bound_typevar.typevar(self.db).bound_or_constraints(self.db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { if !ty diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 0dfa18dbed..c47a508d61 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -194,7 +194,6 @@ impl<'db> AllMembers<'db> { | Type::ProtocolInstance(_) | Type::SpecialForm(_) | Type::KnownInstance(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::BoundSuper(_) | Type::TypeIs(_) => match ty.to_meta_type(db) { diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index f84922a5f1..34e2e89f71 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3617,7 +3617,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::WrapperDescriptor(_) | Type::DataclassDecorator(_) | Type::DataclassTransformer(_) - | Type::NonInferableTypeVar(..) | Type::TypeVar(..) | Type::AlwaysTruthy | Type::AlwaysFalsy @@ -5513,6 +5512,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // Retrieve the parameter type for the current argument in a given overload and its binding. + let db = self.db(); let parameter_type = |overload: &Binding<'db>, binding: &CallableBinding<'db>| { let argument_index = if binding.bound_type.is_some() { argument_index + 1 @@ -5525,7 +5525,36 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; }; - overload.signature.parameters()[*parameter_index].annotated_type() + let parameter_type = + overload.signature.parameters()[*parameter_index].annotated_type()?; + + // TODO: For now, skip any parameter annotations that mention any typevars. There + // are two issues: + // + // First, if we include those typevars in the type context that we use to infer the + // corresponding argument type, the typevars might end up appearing in the inferred + // argument type as well. As part of analyzing this call, we're going to (try to) + // infer a specialization of those typevars, and would need to substitute those + // typevars in the inferred argument type. We can't do that easily at the moment, + // since specialization inference occurs _after_ we've inferred argument types, and + // we can't _update_ an expression's inferred type after the fact. + // + // Second, certain kinds of arguments themselves have typevars that we need to + // infer specializations for. (For instance, passing the result of _another_ call + // to the argument of _this_ call, where both are calls to generic functions.) In + // that case, we want to "tie together" the typevars of the two calls so that we + // can infer their specializations at the same time — or at least, for the + // specialization of one to influence the specialization of the other. It's not yet + // clear how we're going to do that. (We might have to start inferring constraint + // sets for each expression, instead of simple types?) + // + // Regardless, for now, the expedient "solution" is to not perform bidi type + // checking for these kinds of parameters. + if parameter_type.has_typevar(db) { + return None; + } + + Some(parameter_type) }; // If there is only a single binding and overload, we can infer the argument directly with @@ -5910,11 +5939,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { parenthesized: _, } = tuple; - // TODO: Use the list of inferable typevars from the generic context of tuple. - let inferable = InferableTypeVars::None; - // Remove any union elements of that are unrelated to the tuple type. let tcx = tcx.map(|annotation| { + let inferable = KnownClass::Tuple + .try_to_class_literal(self.db()) + .and_then(|class| class.generic_context(self.db())) + .map(|generic_context| generic_context.inferable_typevars(self.db())) + .unwrap_or(InferableTypeVars::None); annotation.filter_disjoint_elements( self.db(), KnownClass::Tuple.to_instance(self.db()), @@ -6057,10 +6088,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let elt_tys = |collection_class: KnownClass| { let class_literal = collection_class.try_to_class_literal(self.db())?; let generic_context = class_literal.generic_context(self.db())?; - Some((class_literal, generic_context.variables(self.db()))) + Some(( + class_literal, + generic_context, + generic_context.variables(self.db()), + )) }; - let Some((class_literal, elt_tys)) = elt_tys(collection_class) else { + let Some((class_literal, generic_context, elt_tys)) = elt_tys(collection_class) else { // Infer the element types without type context, and fallback to unknown for // custom typesheds. for elt in elts.flatten().flatten() { @@ -6070,9 +6105,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; }; - // TODO: Use the list of inferable typevars from the generic context of the collection - // class. - let inferable = InferableTypeVars::None; + let inferable = generic_context.inferable_typevars(self.db()); // Remove any union elements of that are unrelated to the collection type. // @@ -6154,7 +6187,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - let class_type = class_literal.apply_specialization(self.db(), |generic_context| { + let class_type = class_literal.apply_specialization(self.db(), |_| { builder.build(generic_context, TypeContext::default()) }); @@ -7732,7 +7765,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::BytesLiteral(_) | Type::EnumLiteral(_) | Type::BoundSuper(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::TypeIs(_) | Type::TypedDict(_), @@ -8119,7 +8151,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::BytesLiteral(_) | Type::EnumLiteral(_) | Type::BoundSuper(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::TypeIs(_) | Type::TypedDict(_), @@ -8149,7 +8180,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::BytesLiteral(_) | Type::EnumLiteral(_) | Type::BoundSuper(_) - | Type::NonInferableTypeVar(_) | Type::TypeVar(_) | Type::TypeIs(_) | Type::TypedDict(_), diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index f725f32220..4f4505e5a0 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -207,15 +207,16 @@ impl ClassInfoConstraintFunction { Type::Union(union) => { union.try_map(db, |element| self.generate_constraint(db, *element)) } - Type::NonInferableTypeVar(bound_typevar) => match bound_typevar - .typevar(db) - .bound_or_constraints(db)? - { - TypeVarBoundOrConstraints::UpperBound(bound) => self.generate_constraint(db, bound), - TypeVarBoundOrConstraints::Constraints(constraints) => { - self.generate_constraint(db, Type::Union(constraints)) + Type::TypeVar(bound_typevar) => { + match bound_typevar.typevar(db).bound_or_constraints(db)? { + TypeVarBoundOrConstraints::UpperBound(bound) => { + self.generate_constraint(db, bound) + } + TypeVarBoundOrConstraints::Constraints(constraints) => { + self.generate_constraint(db, Type::Union(constraints)) + } } - }, + } // It's not valid to use a generic alias as the second argument to `isinstance()` or `issubclass()`, // e.g. `isinstance(x, list[int])` fails at runtime. @@ -251,7 +252,6 @@ impl ClassInfoConstraintFunction { | Type::IntLiteral(_) | Type::KnownInstance(_) | Type::TypeIs(_) - | Type::TypeVar(_) | Type::WrapperDescriptor(_) | Type::DataclassTransformer(_) | Type::TypedDict(_) => None, diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 92fb74ad26..a34473dc45 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -28,10 +28,9 @@ use crate::types::generics::{ }; use crate::types::infer::nearest_enclosing_class; use crate::types::{ - ApplyTypeMappingVisitor, BindingContext, BoundTypeVarInstance, ClassLiteral, - FindLegacyTypeVarsVisitor, HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, - KnownClass, MaterializationKind, NormalizedVisitor, TypeContext, TypeMapping, TypeRelation, - VarianceInferable, todo_type, + ApplyTypeMappingVisitor, BoundTypeVarInstance, ClassLiteral, FindLegacyTypeVarsVisitor, + HasRelationToVisitor, IsDisjointVisitor, IsEquivalentVisitor, KnownClass, MaterializationKind, + NormalizedVisitor, TypeContext, TypeMapping, TypeRelation, VarianceInferable, todo_type, }; use crate::{Db, FxOrderSet}; use ruff_python_ast::{self as ast, name::Name}; @@ -446,19 +445,6 @@ impl<'db> Signature<'db> { } } - pub(super) fn mark_typevars_inferable(self, db: &'db dyn Db) -> Self { - if let Some(definition) = self.definition { - self.apply_type_mapping_impl( - db, - &TypeMapping::MarkTypeVarsInferable(Some(BindingContext::Definition(definition))), - TypeContext::default(), - &ApplyTypeMappingVisitor::default(), - ) - } else { - self - } - } - pub(super) fn wrap_coroutine_return_type(self, db: &'db dyn Db) -> Self { let return_ty = self.return_ty.map(|return_ty| { KnownClass::CoroutineType @@ -617,6 +603,15 @@ impl<'db> Signature<'db> { inferable: InferableTypeVars<'_, 'db>, visitor: &IsEquivalentVisitor<'db>, ) -> ConstraintSet<'db> { + // The typevars in self and other should also be considered inferable when checking whether + // two signatures are equivalent. + let self_inferable = + (self.generic_context).map(|generic_context| generic_context.inferable_typevars(db)); + let other_inferable = + (other.generic_context).map(|generic_context| generic_context.inferable_typevars(db)); + let inferable = inferable.merge(self_inferable.as_ref()); + let inferable = inferable.merge(other_inferable.as_ref()); + let mut result = ConstraintSet::from(true); let mut check_types = |self_type: Option>, other_type: Option>| { let self_type = self_type.unwrap_or(Type::unknown()); @@ -767,6 +762,15 @@ impl<'db> Signature<'db> { } } + // The typevars in self and other should also be considered inferable when checking whether + // two signatures are equivalent. + let self_inferable = + (self.generic_context).map(|generic_context| generic_context.inferable_typevars(db)); + let other_inferable = + (other.generic_context).map(|generic_context| generic_context.inferable_typevars(db)); + let inferable = inferable.merge(self_inferable.as_ref()); + let inferable = inferable.merge(other_inferable.as_ref()); + let mut result = ConstraintSet::from(true); let mut check_types = |type1: Option>, type2: Option>| { let type1 = type1.unwrap_or(Type::unknown()); @@ -1301,19 +1305,8 @@ impl<'db> Parameters<'db> { let class = nearest_enclosing_class(db, index, scope_id).unwrap(); Some( - // It looks like unnecessary work here that we create the implicit Self - // annotation using non-inferable typevars and then immediately apply - // `MarkTypeVarsInferable` to it. However, this is currently necessary to - // ensure that implicit-Self and explicit Self annotations are both treated - // the same. Marking type vars inferable will cause reification of lazy - // typevar defaults/bounds/constraints; this needs to happen for both - // implicit and explicit Self so they remain the "same" typevar. - typing_self(db, scope_id, typevar_binding_context, class, &Type::NonInferableTypeVar) - .expect("We should always find the surrounding class for an implicit self: Self annotation").apply_type_mapping( - db, - &TypeMapping::MarkTypeVarsInferable(None), - TypeContext::default() - ) + typing_self(db, scope_id, typevar_binding_context, class) + .expect("We should always find the surrounding class for an implicit self: Self annotation"), ) } else { // For methods of non-generic classes that are not otherwise generic (e.g. return `Self` or diff --git a/crates/ty_python_semantic/src/types/type_ordering.rs b/crates/ty_python_semantic/src/types/type_ordering.rs index d561e6a8b8..7b52a45e8d 100644 --- a/crates/ty_python_semantic/src/types/type_ordering.rs +++ b/crates/ty_python_semantic/src/types/type_ordering.rs @@ -137,10 +137,6 @@ pub(super) fn union_or_intersection_elements_ordering<'db>( (Type::ProtocolInstance(_), _) => Ordering::Less, (_, Type::ProtocolInstance(_)) => Ordering::Greater, - (Type::NonInferableTypeVar(left), Type::NonInferableTypeVar(right)) => left.cmp(right), - (Type::NonInferableTypeVar(_), _) => Ordering::Less, - (_, Type::NonInferableTypeVar(_)) => Ordering::Greater, - (Type::TypeVar(left), Type::TypeVar(right)) => left.cmp(right), (Type::TypeVar(_), _) => Ordering::Less, (_, Type::TypeVar(_)) => Ordering::Greater, diff --git a/crates/ty_python_semantic/src/types/visitor.rs b/crates/ty_python_semantic/src/types/visitor.rs index 69ce7a663d..51b77432a4 100644 --- a/crates/ty_python_semantic/src/types/visitor.rs +++ b/crates/ty_python_semantic/src/types/visitor.rs @@ -122,7 +122,6 @@ pub(super) enum NonAtomicType<'db> { NominalInstance(NominalInstanceType<'db>), PropertyInstance(PropertyInstanceType<'db>), TypeIs(TypeIsType<'db>), - NonInferableTypeVar(BoundTypeVarInstance<'db>), TypeVar(BoundTypeVarInstance<'db>), ProtocolInstance(ProtocolInstanceType<'db>), TypedDict(TypedDictType<'db>), @@ -186,9 +185,6 @@ impl<'db> From> for TypeKind<'db> { Type::PropertyInstance(property) => { TypeKind::NonAtomic(NonAtomicType::PropertyInstance(property)) } - Type::NonInferableTypeVar(bound_typevar) => { - TypeKind::NonAtomic(NonAtomicType::NonInferableTypeVar(bound_typevar)) - } Type::TypeVar(bound_typevar) => { TypeKind::NonAtomic(NonAtomicType::TypeVar(bound_typevar)) } @@ -228,9 +224,6 @@ pub(super) fn walk_non_atomic_type<'db, V: TypeVisitor<'db> + ?Sized>( visitor.visit_property_instance_type(db, property); } NonAtomicType::TypeIs(type_is) => visitor.visit_typeis_type(db, type_is), - NonAtomicType::NonInferableTypeVar(bound_typevar) => { - visitor.visit_bound_type_var_type(db, bound_typevar); - } NonAtomicType::TypeVar(bound_typevar) => { visitor.visit_bound_type_var_type(db, bound_typevar); } From 96b156303b81c5114e8375a6ffd467fb638c3963 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Thu, 16 Oct 2025 16:11:28 -0400 Subject: [PATCH 080/113] [ty] Prefer declared type for invariant collection literals (#20927) ## Summary Prefer the declared type for collection literals, e.g., ```py x: list[Any] = [1, "2", (3,)] reveal_type(x) # list[Any] ``` This solves a large part of https://github.com/astral-sh/ty/issues/136 for invariant generics, where respecting the declared type is a lot more important. It also means that annotated dict literals with `dict[_, Any]` is a way out of https://github.com/astral-sh/ty/issues/1248. --- .../resources/mdtest/assignment/annotations.md | 10 +++++----- crates/ty_python_semantic/src/types/display.rs | 10 +++++++++- crates/ty_python_semantic/src/types/infer/builder.rs | 7 +++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md index 3d5e75ab99..adf0de358d 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md @@ -114,7 +114,7 @@ h: list[list[int]] = [[], [42]] reveal_type(h) # revealed: list[list[int]] i: list[typing.Any] = [1, 2, "3", ([4],)] -reveal_type(i) # revealed: list[Any | int | str | tuple[list[Unknown | int]]] +reveal_type(i) # revealed: list[Any] j: list[tuple[str | int, ...]] = [(1, 2), ("foo", "bar"), ()] reveal_type(j) # revealed: list[tuple[str | int, ...]] @@ -123,7 +123,7 @@ k: list[tuple[list[int], ...]] = [([],), ([1, 2], [3, 4]), ([5], [6], [7])] reveal_type(k) # revealed: list[tuple[list[int], ...]] l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) -reveal_type(l) # revealed: tuple[list[int], list[Any | int], list[Any | int], list[str]] +reveal_type(l) # revealed: tuple[list[int], list[Any], list[Any], list[str]] type IntList = list[int] @@ -187,7 +187,7 @@ h: list[list[int]] | None = [[], [42]] reveal_type(h) # revealed: list[list[int]] i: list[typing.Any] | None = [1, 2, "3", ([4],)] -reveal_type(i) # revealed: list[Any | int | str | tuple[list[Unknown | int]]] +reveal_type(i) # revealed: list[Any] j: list[tuple[str | int, ...]] | None = [(1, 2), ("foo", "bar"), ()] reveal_type(j) # revealed: list[tuple[str | int, ...]] @@ -196,7 +196,7 @@ k: list[tuple[list[int], ...]] | None = [([],), ([1, 2], [3, 4]), ([5], [6], [7] reveal_type(k) # revealed: list[tuple[list[int], ...]] l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] | None = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) -reveal_type(l) # revealed: tuple[list[int], list[Any | int], list[Any | int], list[str]] +reveal_type(l) # revealed: tuple[list[int], list[Any], list[Any], list[str]] type IntList = list[int] @@ -282,7 +282,7 @@ reveal_type(k) # revealed: list[Literal[1, 2, 3]] type Y[T] = list[T] l: Y[Y[Literal[1]]] = [[1]] -reveal_type(l) # revealed: list[list[Literal[1]]] +reveal_type(l) # revealed: list[Y[Literal[1]]] m: list[tuple[Literal[1], Literal[2], Literal[3]]] = [(1, 2, 3)] reveal_type(m) # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]] diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index ed19a16358..73957453c9 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -591,7 +591,15 @@ impl Display for DisplayRepresentation<'_> { .0 .display_with(self.db, self.settings.clone()) .fmt(f), - Type::TypeAlias(alias) => f.write_str(alias.name(self.db)), + Type::TypeAlias(alias) => { + f.write_str(alias.name(self.db))?; + match alias.specialization(self.db) { + None => Ok(()), + Some(specialization) => specialization + .display_short(self.db, TupleSpecialization::No, self.settings.clone()) + .fmt(f), + } + } } } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 34e2e89f71..5ebcfcda15 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -6179,6 +6179,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let inferred_elt_ty = self.get_or_infer_expression(elt, elt_tcx); + // Simplify the inference based on the declared type of the element. + if let Some(elt_tcx) = elt_tcx.annotation { + if inferred_elt_ty.is_assignable_to(self.db(), elt_tcx) { + continue; + } + } + // Convert any element literals to their promoted type form to avoid excessively large // unions for large nested list literals, which the constraint solver struggles with. let inferred_elt_ty = inferred_elt_ty.promote_literals(self.db(), elt_tcx); From 64edfb6ef6f26bb3618c5c888f5abf3a866a172c Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 16 Oct 2025 23:16:37 -0400 Subject: [PATCH 081/113] [ty] add legacy namespace package support (#20897) Detect legacy namespace packages and treat them like namespace packages when looking them up as the *parent* of the module we're interested in. In all other cases treat them like a regular package. (This PR is coauthored by @MichaReiser in a shared coding session) Fixes https://github.com/astral-sh/ty/issues/838 --------- Co-authored-by: Micha Reiser --- .../mdtest/import/legacy_namespace.md | 221 ++++++++++++++++++ .../src/module_resolver/resolver.rs | 186 ++++++++++++++- 2 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/import/legacy_namespace.md diff --git a/crates/ty_python_semantic/resources/mdtest/import/legacy_namespace.md b/crates/ty_python_semantic/resources/mdtest/import/legacy_namespace.md new file mode 100644 index 0000000000..1c3b221c90 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/import/legacy_namespace.md @@ -0,0 +1,221 @@ +# Legacy namespace packages + +## `__import__("pkgutil").extend_path` + +```toml +[environment] +extra-paths = ["/airflow-core/src", "/providers/amazon/src/"] +``` + +`/airflow-core/src/airflow/__init__.py`: + +```py +__path__ = __import__("pkgutil").extend_path(__path__, __name__) +__version__ = "3.2.0" +``` + +`/providers/amazon/src/airflow/__init__.py`: + +```py +__path__ = __import__("pkgutil").extend_path(__path__, __name__) +``` + +`/providers/amazon/src/airflow/providers/__init__.py`: + +```py +__path__ = __import__("pkgutil").extend_path(__path__, __name__) +``` + +`/providers/amazon/src/airflow/providers/amazon/__init__.py`: + +```py +__version__ = "9.15.0" +``` + +`test.py`: + +```py +from airflow import __version__ as airflow_version +from airflow.providers.amazon import __version__ as amazon_provider_version + +reveal_type(airflow_version) # revealed: Literal["3.2.0"] +reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"] +``` + +## `pkgutil.extend_path` + +```toml +[environment] +extra-paths = ["/airflow-core/src", "/providers/amazon/src/"] +``` + +`/airflow-core/src/airflow/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(__path__, __name__) +__version__ = "3.2.0" +``` + +`/providers/amazon/src/airflow/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(__path__, __name__) +``` + +`/providers/amazon/src/airflow/providers/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(__path__, __name__) +``` + +`/providers/amazon/src/airflow/providers/amazon/__init__.py`: + +```py +__version__ = "9.15.0" +``` + +`test.py`: + +```py +from airflow import __version__ as airflow_version +from airflow.providers.amazon import __version__ as amazon_provider_version + +reveal_type(airflow_version) # revealed: Literal["3.2.0"] +reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"] +``` + +## `extend_path` with keyword arguments + +```toml +[environment] +extra-paths = ["/airflow-core/src", "/providers/amazon/src/"] +``` + +`/airflow-core/src/airflow/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(name=__name__, path=__path__) +__version__ = "3.2.0" +``` + +`/providers/amazon/src/airflow/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(name=__name__, path=__path__) +``` + +`/providers/amazon/src/airflow/providers/__init__.py`: + +```py +import pkgutil + +__path__ = pkgutil.extend_path(name=__name__, path=__path__) +``` + +`/providers/amazon/src/airflow/providers/amazon/__init__.py`: + +```py +__version__ = "9.15.0" +``` + +`test.py`: + +```py +from airflow import __version__ as airflow_version +from airflow.providers.amazon import __version__ as amazon_provider_version + +reveal_type(airflow_version) # revealed: Literal["3.2.0"] +reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"] +``` + +## incorrect `__import__` arguments + +```toml +[environment] +extra-paths = ["/airflow-core/src", "/providers/amazon/src/"] +``` + +`/airflow-core/src/airflow/__init__.py`: + +```py +__path__ = __import__("not_pkgutil").extend_path(__path__, __name__) +__version__ = "3.2.0" +``` + +`/providers/amazon/src/airflow/__init__.py`: + +```py +__path__ = __import__("not_pkgutil").extend_path(__path__, __name__) +``` + +`/providers/amazon/src/airflow/providers/__init__.py`: + +```py +__path__ = __import__("not_pkgutil").extend_path(__path__, __name__) +``` + +`/providers/amazon/src/airflow/providers/amazon/__init__.py`: + +```py +__version__ = "9.15.0" +``` + +`test.py`: + +```py +from airflow.providers.amazon import __version__ as amazon_provider_version # error: [unresolved-import] +from airflow import __version__ as airflow_version + +reveal_type(airflow_version) # revealed: Literal["3.2.0"] +``` + +## incorrect `extend_path` arguments + +```toml +[environment] +extra-paths = ["/airflow-core/src", "/providers/amazon/src/"] +``` + +`/airflow-core/src/airflow/__init__.py`: + +```py +__path__ = __import__("pkgutil").extend_path(__path__, "other_module") +__version__ = "3.2.0" +``` + +`/providers/amazon/src/airflow/__init__.py`: + +```py +__path__ = __import__("pkgutil").extend_path(__path__, "other_module") +``` + +`/providers/amazon/src/airflow/providers/__init__.py`: + +```py +__path__ = __import__("pkgutil").extend_path(__path__, "other_module") +``` + +`/providers/amazon/src/airflow/providers/amazon/__init__.py`: + +```py +__version__ = "9.15.0" +``` + +`test.py`: + +```py +from airflow.providers.amazon import __version__ as amazon_provider_version # error: [unresolved-import] +from airflow import __version__ as airflow_version + +reveal_type(airflow_version) # revealed: Literal["3.2.0"] +``` diff --git a/crates/ty_python_semantic/src/module_resolver/resolver.rs b/crates/ty_python_semantic/src/module_resolver/resolver.rs index 2f827f256f..0787859049 100644 --- a/crates/ty_python_semantic/src/module_resolver/resolver.rs +++ b/crates/ty_python_semantic/src/module_resolver/resolver.rs @@ -19,7 +19,10 @@ use rustc_hash::{FxBuildHasher, FxHashSet}; use ruff_db::files::{File, FilePath, FileRootKind}; use ruff_db::system::{DirectoryEntry, System, SystemPath, SystemPathBuf}; use ruff_db::vendored::VendoredFileSystem; -use ruff_python_ast::{PySourceType, PythonVersion}; +use ruff_python_ast::{ + self as ast, PySourceType, PythonVersion, + visitor::{Visitor, walk_body}, +}; use crate::db::Db; use crate::module_name::ModuleName; @@ -1002,7 +1005,12 @@ where let is_regular_package = package_path.is_regular_package(resolver_state); if is_regular_package { - in_namespace_package = false; + // This is the only place where we need to consider the existence of legacy namespace + // packages, as we are explicitly searching for the *parent* package of the module + // we actually want. Here, such a package should be treated as a PEP-420 ("modern") + // namespace package. In all other contexts it acts like a normal package and needs + // no special handling. + in_namespace_package = is_legacy_namespace_package(&package_path, resolver_state); } else if package_path.is_directory(resolver_state) // Pure modules hide namespace packages with the same name && resolve_file_module(&package_path, resolver_state).is_none() @@ -1039,6 +1047,62 @@ where }) } +/// Determines whether a package is a legacy namespace package. +/// +/// Before PEP 420 introduced implicit namespace packages, the ecosystem developed +/// its own form of namespace packages. These legacy namespace packages continue to persist +/// in modern codebases because they work with ancient Pythons and if it ain't broke, don't fix it. +/// +/// A legacy namespace package is distinguished by having an `__init__.py` that contains an +/// expression to the effect of: +/// +/// ```python +/// __path__ = __import__("pkgutil").extend_path(__path__, __name__) +/// ``` +/// +/// The resulting package simultaneously has properties of both regular packages and namespace ones: +/// +/// * Like regular packages, `__init__.py` is defined and can contain items other than submodules +/// * Like implicit namespace packages, multiple copies of the package may exist with different +/// submodules, and they will be merged into one namespace at runtime by the interpreter +/// +/// Now, you may rightly wonder: "What if the `__init__.py` files have different contents?" +/// The apparent official answer is: "Don't do that!" +/// And the reality is: "Of course people do that!" +/// +/// In practice we think it's fine to, just like with regular packages, use the first one +/// we find on the search paths. To the extent that the different copies "need" to have the same +/// contents, they all "need" to have the legacy namespace idiom (we do nothing to enforce that, +/// we will just get confused if you mess it up). +fn is_legacy_namespace_package( + package_path: &ModulePath, + resolver_state: &ResolverContext, +) -> bool { + // Just an optimization, the stdlib and typeshed are never legacy namespace packages + if package_path.search_path().is_standard_library() { + return false; + } + + let mut package_path = package_path.clone(); + package_path.push("__init__"); + let Some(init) = resolve_file_module(&package_path, resolver_state) else { + return false; + }; + + // This is all syntax-only analysis so it *could* be fooled but it's really unlikely. + // + // The benefit of being syntax-only is speed and avoiding circular dependencies + // between module resolution and semantic analysis. + // + // The downside is if you write slightly different syntax we will fail to detect the idiom, + // but hey, this is better than nothing! + let parsed = ruff_db::parsed::parsed_module(resolver_state.db, init); + let mut visitor = LegacyNamespacePackageVisitor::default(); + visitor.visit_body(parsed.load(resolver_state.db).suite()); + + visitor.is_legacy_namespace_package +} + #[derive(Debug)] struct ResolvedPackage { path: ModulePath, @@ -1148,6 +1212,124 @@ impl fmt::Display for RelaxedModuleName { } } +/// Detects if a module contains a statement of the form: +/// ```python +/// __path__ = pkgutil.extend_path(__path__, __name__) +/// ``` +/// or +/// ```python +/// __path__ = __import__("pkgutil").extend_path(__path__, __name__) +/// ``` +#[derive(Default)] +struct LegacyNamespacePackageVisitor { + is_legacy_namespace_package: bool, + in_body: bool, +} + +impl Visitor<'_> for LegacyNamespacePackageVisitor { + fn visit_body(&mut self, body: &[ruff_python_ast::Stmt]) { + if self.is_legacy_namespace_package { + return; + } + + // Don't traverse into nested bodies. + if self.in_body { + return; + } + + self.in_body = true; + + walk_body(self, body); + } + + fn visit_stmt(&mut self, stmt: &ast::Stmt) { + if self.is_legacy_namespace_package { + return; + } + + let ast::Stmt::Assign(ast::StmtAssign { value, targets, .. }) = stmt else { + return; + }; + + let [ast::Expr::Name(maybe_path)] = &**targets else { + return; + }; + + if &*maybe_path.id != "__path__" { + return; + } + + let ast::Expr::Call(ast::ExprCall { + func: extend_func, + arguments: extend_arguments, + .. + }) = &**value + else { + return; + }; + + let ast::Expr::Attribute(ast::ExprAttribute { + value: maybe_pkg_util, + attr: maybe_extend_path, + .. + }) = &**extend_func + else { + return; + }; + + // Match if the left side of the attribute access is either `__import__("pkgutil")` or `pkgutil` + match &**maybe_pkg_util { + // __import__("pkgutil").extend_path(__path__, __name__) + ast::Expr::Call(ruff_python_ast::ExprCall { + func: maybe_import, + arguments: import_arguments, + .. + }) => { + let ast::Expr::Name(maybe_import) = &**maybe_import else { + return; + }; + + if maybe_import.id() != "__import__" { + return; + } + + let Some(ast::Expr::StringLiteral(name)) = + import_arguments.find_argument_value("name", 0) + else { + return; + }; + + if name.value.to_str() != "pkgutil" { + return; + } + } + // "pkgutil.extend_path(__path__, __name__)" + ast::Expr::Name(name) => { + if name.id() != "pkgutil" { + return; + } + } + _ => { + return; + } + } + + // Test that this is an `extend_path(__path__, __name__)` call + if maybe_extend_path != "extend_path" { + return; + } + + let Some(ast::Expr::Name(path)) = extend_arguments.find_argument_value("path", 0) else { + return; + }; + let Some(ast::Expr::Name(name)) = extend_arguments.find_argument_value("name", 1) else { + return; + }; + + self.is_legacy_namespace_package = path.id() == "__path__" && name.id() == "__name__"; + } +} + #[cfg(test)] mod tests { #![expect( From a21cde8a5a5143db4fae5413869def2dd9f74c96 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 17 Oct 2025 09:15:33 +0200 Subject: [PATCH 082/113] [ty] Fix playground crash for very large files (#20934) --- playground/ruff/src/Editor/settings.ts | 9 +++++++++ playground/ty/src/Editor/persist.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/playground/ruff/src/Editor/settings.ts b/playground/ruff/src/Editor/settings.ts index 411399da5d..7a7be3d95b 100644 --- a/playground/ruff/src/Editor/settings.ts +++ b/playground/ruff/src/Editor/settings.ts @@ -79,6 +79,15 @@ export function persistLocal({ settingsSource: string; pythonSource: string; }) { + const totalLength = settingsSource.length + pythonSource.length; + + // Don't persist large files to local storage because they can exceed the local storage quota + // The number here is picked rarely arbitrarily. Also note, JS uses UTF 16: + // that means the limit here is strings larger than 1MB (because UTf 16 uses 2 bytes per character) + if (totalLength > 500_000) { + return; + } + localStorage.setItem( "source", JSON.stringify([settingsSource, pythonSource]), diff --git a/playground/ty/src/Editor/persist.ts b/playground/ty/src/Editor/persist.ts index 2adc5f3fe9..1767e14d0f 100644 --- a/playground/ty/src/Editor/persist.ts +++ b/playground/ty/src/Editor/persist.ts @@ -40,6 +40,18 @@ export async function restore(): Promise { } export function persistLocal(workspace: Workspace) { + let totalLength = 0; + for (const fileContent of Object.values(workspace.files)) { + totalLength += fileContent.length; + + // Don't persist large files to local storage because they can exceed the local storage quota + // The number here is picked rarely arbitrarily. Also note, JS uses UTF 16: + // that means the limit here is strings larger than 1MB (because UTf 16 uses 2 bytes per character) + if (totalLength > 500_000) { + return; + } + } + localStorage.setItem("workspace", JSON.stringify(workspace)); } From baaa8dad3af13bb4b05a97bee7fe85f6bd3798ed Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 17 Oct 2025 12:29:13 +0100 Subject: [PATCH 083/113] [ty] Re-enable fuzzer seeds that are no longer slow (#20937) --- .github/workflows/ci.yaml | 2 +- python/py-fuzzer/fuzz.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 56221fc48d..7424c3a7a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -666,7 +666,7 @@ jobs: - determine_changes # Only runs on pull requests, since that is the only we way we can find the base version for comparison. if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && github.event_name == 'pull_request' && (needs.determine_changes.outputs.ty == 'true' || needs.determine_changes.outputs.py-fuzzer == 'true') }} - timeout-minutes: 20 + timeout-minutes: 5 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: diff --git a/python/py-fuzzer/fuzz.py b/python/py-fuzzer/fuzz.py index b5a14abc1b..895e74dad8 100644 --- a/python/py-fuzzer/fuzz.py +++ b/python/py-fuzzer/fuzz.py @@ -152,16 +152,13 @@ class FuzzResult: def fuzz_code(seed: Seed, args: ResolvedCliArgs) -> FuzzResult: """Return a `FuzzResult` instance describing the fuzzing result from this seed.""" - # TODO debug slowness of these seeds - skip_check = seed in {32, 56, 208} - code = generate_random_code(seed) bug_found = False minimizer_callback: Callable[[str], bool] | None = None if args.baseline_executable_path is None: only_new_bugs = False - if not skip_check and contains_bug( + if contains_bug( code, executable=args.executable, executable_path=args.test_executable_path ): bug_found = True @@ -172,7 +169,7 @@ def fuzz_code(seed: Seed, args: ResolvedCliArgs) -> FuzzResult: ) else: only_new_bugs = True - if not skip_check and contains_new_bug( + if contains_new_bug( code, executable=args.executable, test_executable_path=args.test_executable_path, From fc3b341529fcc7aaac328a605fb3b1baffa588ff Mon Sep 17 00:00:00 2001 From: "Mark Z. Ding" Date: Fri, 17 Oct 2025 07:50:58 -0400 Subject: [PATCH 084/113] [ty] Truncate Literal type display in some situations (#20928) --- .../mdtest/diagnostics/union_call.md | 24 ++++ ..._Truncation_for_long_…_(ec94b5e857284ef3).snap | 56 +++++++++ .../ty_python_semantic/src/types/display.rs | 115 ++++++++++++------ 3 files changed, 158 insertions(+), 37 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f…_-_Try_to_cover_all_pos…_-_Truncation_for_long_…_(ec94b5e857284ef3).snap diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md index 31cafa14bf..a5e9b9370e 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md @@ -138,3 +138,27 @@ def _(n: int): # error: [unknown-argument] y = f("foo", name="bar", unknown="quux") ``` + +### Truncation for long unions and literals + +This test demonstrates a call where the expected type is a large mixed union. The diagnostic must +therefore truncate the long expected union type to avoid overwhelming output. + +```py +from typing import Literal, Union + +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... +class F: ... + +def f1(x: Union[Literal[1, 2, 3, 4, 5, 6, 7, 8], A, B, C, D, E, F]) -> int: + return 0 + +def _(n: int): + x = n + # error: [invalid-argument-type] + f1(x) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f…_-_Try_to_cover_all_pos…_-_Truncation_for_long_…_(ec94b5e857284ef3).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f…_-_Try_to_cover_all_pos…_-_Truncation_for_long_…_(ec94b5e857284ef3).snap new file mode 100644 index 0000000000..77582d963c --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/union_call.md_-_Calling_a_union_of_f…_-_Try_to_cover_all_pos…_-_Truncation_for_long_…_(ec94b5e857284ef3).snap @@ -0,0 +1,56 @@ +--- +source: crates/ty_test/src/lib.rs +assertion_line: 427 +expression: snapshot +--- +--- +mdtest name: union_call.md - Calling a union of function types - Try to cover all possible reasons - Truncation for long unions and literals +mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from typing import Literal, Union + 2 | + 3 | class A: ... + 4 | class B: ... + 5 | class C: ... + 6 | class D: ... + 7 | class E: ... + 8 | class F: ... + 9 | +10 | def f1(x: Union[Literal[1, 2, 3, 4, 5, 6, 7, 8], A, B, C, D, E, F]) -> int: +11 | return 0 +12 | +13 | def _(n: int): +14 | x = n +15 | # error: [invalid-argument-type] +16 | f1(x) +``` + +# Diagnostics + +``` +error[invalid-argument-type]: Argument to function `f1` is incorrect + --> src/mdtest_snippet.py:16:8 + | +14 | x = n +15 | # error: [invalid-argument-type] +16 | f1(x) + | ^ Expected `Literal[1, 2, 3, 4, 5, ... omitted 3 literals] | A | B | ... omitted 4 union elements`, found `int` + | +info: Function defined here + --> src/mdtest_snippet.py:10:5 + | + 8 | class F: ... + 9 | +10 | def f1(x: Union[Literal[1, 2, 3, 4, 5, 6, 7, 8], A, B, C, D, E, F]) -> int: + | ^^ ----------------------------------------------------------- Parameter declared here +11 | return 0 + | +info: rule `invalid-argument-type` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 73957453c9..5b2f69e811 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -38,7 +38,7 @@ pub struct DisplaySettings<'db> { /// Class names that should be displayed fully qualified /// (e.g., `module.ClassName` instead of just `ClassName`) pub qualified: Rc>, - /// Whether long unions are displayed in full + /// Whether long unions and literals are displayed in full pub preserve_full_unions: bool, } @@ -1328,6 +1328,44 @@ impl Display for DisplayParameter<'_> { } } +#[derive(Debug, Copy, Clone)] +struct TruncationPolicy { + max: usize, + max_when_elided: usize, +} + +impl TruncationPolicy { + fn display_limit(self, total: usize, preserve_full: bool) -> usize { + if preserve_full { + return total; + } + let limit = if total > self.max { + self.max_when_elided + } else { + self.max + }; + limit.min(total) + } +} + +#[derive(Debug)] +struct DisplayOmitted { + count: usize, + singular: &'static str, + plural: &'static str, +} + +impl Display for DisplayOmitted { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let noun = if self.count == 1 { + self.singular + } else { + self.plural + }; + write!(f, "... omitted {} {}", self.count, noun) + } +} + impl<'db> UnionType<'db> { fn display_with( &'db self, @@ -1348,8 +1386,10 @@ struct DisplayUnionType<'db> { settings: DisplaySettings<'db>, } -const MAX_DISPLAYED_UNION_ITEMS: usize = 5; -const MAX_DISPLAYED_UNION_ITEMS_WHEN_ELIDED: usize = 3; +const UNION_POLICY: TruncationPolicy = TruncationPolicy { + max: 5, + max_when_elided: 3, +}; impl Display for DisplayUnionType<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { @@ -1379,16 +1419,8 @@ impl Display for DisplayUnionType<'_> { let mut join = f.join(" | "); - let display_limit = if self.settings.preserve_full_unions { - total_entries - } else { - let limit = if total_entries > MAX_DISPLAYED_UNION_ITEMS { - MAX_DISPLAYED_UNION_ITEMS_WHEN_ELIDED - } else { - MAX_DISPLAYED_UNION_ITEMS - }; - limit.min(total_entries) - }; + let display_limit = + UNION_POLICY.display_limit(total_entries, self.settings.preserve_full_unions); let mut condensed_types = Some(condensed_types); let mut displayed_entries = 0usize; @@ -1420,8 +1452,10 @@ impl Display for DisplayUnionType<'_> { if !self.settings.preserve_full_unions { let omitted_entries = total_entries.saturating_sub(displayed_entries); if omitted_entries > 0 { - join.entry(&DisplayUnionOmitted { + join.entry(&DisplayOmitted { count: omitted_entries, + singular: "union element", + plural: "union elements", }); } } @@ -1437,38 +1471,45 @@ impl fmt::Debug for DisplayUnionType<'_> { Display::fmt(self, f) } } - -struct DisplayUnionOmitted { - count: usize, -} - -impl Display for DisplayUnionOmitted { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let plural = if self.count == 1 { - "element" - } else { - "elements" - }; - write!(f, "... omitted {} union {}", self.count, plural) - } -} - struct DisplayLiteralGroup<'db> { literals: Vec>, db: &'db dyn Db, settings: DisplaySettings<'db>, } +const LITERAL_POLICY: TruncationPolicy = TruncationPolicy { + max: 7, + max_when_elided: 5, +}; + impl Display for DisplayLiteralGroup<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { f.write_str("Literal[")?; - f.join(", ") - .entries( - self.literals - .iter() - .map(|ty| ty.representation(self.db, self.settings.singleline())), - ) - .finish()?; + + let total_entries = self.literals.len(); + + let display_limit = + LITERAL_POLICY.display_limit(total_entries, self.settings.preserve_full_unions); + + let mut join = f.join(", "); + + for lit in self.literals.iter().take(display_limit) { + let rep = lit.representation(self.db, self.settings.singleline()); + join.entry(&rep); + } + + if !self.settings.preserve_full_unions { + let omitted_entries = total_entries.saturating_sub(display_limit); + if omitted_entries > 0 { + join.entry(&DisplayOmitted { + count: omitted_entries, + singular: "literal", + plural: "literals", + }); + } + } + + join.finish()?; f.write_str("]") } } From cfbd42c22a3b7f15770febb24a7c3c5024841109 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 17 Oct 2025 14:04:31 +0200 Subject: [PATCH 085/113] [ty] Support `dataclass_transform` for base class models (#20783) ## Summary Support `dataclass_transform` when used on a (base) class. ## Typing conformance * The changes in `dataclasses_transform_class.py` look good, just a few mistakes due to missing `alias` support. * I didn't look closely at the changes in `dataclasses_transform_converter.py` since we don't support `converter` yet. ## Ecosystem impact The impact looks huge, but it's concentrated on a single project (ibis). Their setup looks more or less like this: * the real `Annotatable`: https://github.com/ibis-project/ibis/blob/d7083c2c96e12bb7b2a1e643a52b4725f4303fcb/ibis/common/grounds.py#L100-L101 * the real `DataType`: https://github.com/ibis-project/ibis/blob/d7083c2c96e12bb7b2a1e643a52b4725f4303fcb/ibis/expr/datatypes/core.py#L161-L179 * the real `Array`: https://github.com/ibis-project/ibis/blob/d7083c2c96e12bb7b2a1e643a52b4725f4303fcb/ibis/expr/datatypes/core.py#L1003-L1006 ```py from typing import dataclass_transform @dataclass_transform() class Annotatable: pass class DataType(Annotatable): nullable: bool = True class Array[T](DataType): value_type: T ``` They expect something like `Array([1, 2])` to work, but ty, pyright, mypy, and pyrefly would all expect there to be a first argument for the `nullable` field on `DataType`. I don't really understand on what grounds they expect the `nullable` field to be excluded from the signature, but this seems to be the main reason for the new diagnostics here. Not sure if related, but it looks like their typing setup is not really complete (https://github.com/ibis-project/ibis/issues/6844#issuecomment-1868274770, this thread also mentions `dataclass_transform`). ## Test Plan Update pre-existing tests. --- .../mdtest/dataclasses/dataclass_transform.md | 33 +++---------- crates/ty_python_semantic/src/types/class.rs | 46 +++++++++++++++---- .../src/types/ide_support.rs | 8 ++-- .../src/types/infer/builder.rs | 14 +++--- 4 files changed, 56 insertions(+), 45 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md index b9cd306a35..f8246c883b 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md @@ -122,9 +122,6 @@ class CustomerModel(ModelBase): id: int name: str -# TODO: this is not supported yet -# error: [unknown-argument] -# error: [unknown-argument] CustomerModel(id=1, name="Test") ``` @@ -216,11 +213,7 @@ class OrderedModelBase: ... class TestWithBase(OrderedModelBase): inner: int -# TODO: No errors here, should reveal `bool` -# error: [too-many-positional-arguments] -# error: [too-many-positional-arguments] -# error: [unsupported-operator] -reveal_type(TestWithBase(1) < TestWithBase(2)) # revealed: Unknown +reveal_type(TestWithBase(1) < TestWithBase(2)) # revealed: bool ``` ### `kw_only_default` @@ -277,8 +270,7 @@ class ModelBase: ... class TestBase(ModelBase): name: str -# TODO: This should be `(self: TestBase, *, name: str) -> None` -reveal_type(TestBase.__init__) # revealed: def __init__(self) -> None +reveal_type(TestBase.__init__) # revealed: (self: TestBase, *, name: str) -> None ``` ### `frozen_default` @@ -333,12 +325,9 @@ class ModelBase: ... class TestMeta(ModelBase): name: str -# TODO: no error here -# error: [unknown-argument] t = TestMeta(name="test") -# TODO: this should be an `invalid-assignment` error -t.name = "new" +t.name = "new" # error: [invalid-assignment] ``` ### Combining parameters @@ -437,19 +426,15 @@ class DefaultFrozenModel: class Frozen(DefaultFrozenModel): name: str -# TODO: no error here -# error: [unknown-argument] f = Frozen(name="test") -# TODO: this should be an `invalid-assignment` error -f.name = "new" +f.name = "new" # error: [invalid-assignment] class Mutable(DefaultFrozenModel, frozen=False): name: str -# TODO: no error here -# error: [unknown-argument] m = Mutable(name="test") -m.name = "new" # No error +# TODO: This should not be an error +m.name = "new" # error: [invalid-assignment] ``` ## `field_specifiers` @@ -532,12 +517,8 @@ class Person(FancyBase): name: str = fancy_field() age: int | None = fancy_field(kw_only=True) -# TODO: should be (self: Person, name: str = Unknown, *, age: int | None = Unknown) -> None -reveal_type(Person.__init__) # revealed: def __init__(self) -> None +reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None -# TODO: shouldn't be an error -# error: [too-many-positional-arguments] -# error: [unknown-argument] alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index d62ee839ce..c2a2ccd823 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -190,7 +190,11 @@ pub(crate) enum CodeGeneratorKind<'db> { } impl<'db> CodeGeneratorKind<'db> { - pub(crate) fn from_class(db: &'db dyn Db, class: ClassLiteral<'db>) -> Option { + pub(crate) fn from_class( + db: &'db dyn Db, + class: ClassLiteral<'db>, + specialization: Option>, + ) -> Option { #[salsa::tracked( cycle_fn=code_generator_of_class_recover, cycle_initial=code_generator_of_class_initial, @@ -199,11 +203,20 @@ impl<'db> CodeGeneratorKind<'db> { fn code_generator_of_class<'db>( db: &'db dyn Db, class: ClassLiteral<'db>, + specialization: Option>, ) -> Option> { if class.dataclass_params(db).is_some() { Some(CodeGeneratorKind::DataclassLike(None)) } else if let Ok((_, Some(transformer_params))) = class.try_metaclass(db) { Some(CodeGeneratorKind::DataclassLike(Some(transformer_params))) + } else if let Some(transformer_params) = + class.iter_mro(db, specialization).skip(1).find_map(|base| { + base.into_class().and_then(|class| { + class.class_literal(db).0.dataclass_transformer_params(db) + }) + }) + { + Some(CodeGeneratorKind::DataclassLike(Some(transformer_params))) } else if class .explicit_bases(db) .contains(&Type::SpecialForm(SpecialFormType::NamedTuple)) @@ -219,6 +232,7 @@ impl<'db> CodeGeneratorKind<'db> { fn code_generator_of_class_initial<'db>( _db: &'db dyn Db, _class: ClassLiteral<'db>, + _specialization: Option>, ) -> Option> { None } @@ -229,21 +243,37 @@ impl<'db> CodeGeneratorKind<'db> { _value: &Option>, _count: u32, _class: ClassLiteral<'db>, + _specialization: Option>, ) -> salsa::CycleRecoveryAction>> { salsa::CycleRecoveryAction::Iterate } - code_generator_of_class(db, class) + code_generator_of_class(db, class, specialization) } - pub(super) fn matches(self, db: &'db dyn Db, class: ClassLiteral<'db>) -> bool { + pub(super) fn matches( + self, + db: &'db dyn Db, + class: ClassLiteral<'db>, + specialization: Option>, + ) -> bool { matches!( - (CodeGeneratorKind::from_class(db, class), self), + ( + CodeGeneratorKind::from_class(db, class, specialization), + self + ), (Some(Self::DataclassLike(_)), Self::DataclassLike(_)) | (Some(Self::NamedTuple), Self::NamedTuple) | (Some(Self::TypedDict), Self::TypedDict) ) } + + pub(super) fn dataclass_transformer_params(self) -> Option> { + match self { + Self::DataclassLike(params) => params, + Self::NamedTuple | Self::TypedDict => None, + } + } } /// A specialization of a generic class with a particular assignment of types to typevars. @@ -2200,7 +2230,7 @@ impl<'db> ClassLiteral<'db> { }; } - if CodeGeneratorKind::NamedTuple.matches(db, self) { + if CodeGeneratorKind::NamedTuple.matches(db, self, specialization) { if let Some(field) = self .own_fields(db, specialization, CodeGeneratorKind::NamedTuple) .get(name) @@ -2262,7 +2292,7 @@ impl<'db> ClassLiteral<'db> { ) -> Option> { let dataclass_params = self.dataclass_params(db); - let field_policy = CodeGeneratorKind::from_class(db, self)?; + let field_policy = CodeGeneratorKind::from_class(db, self, specialization)?; let transformer_params = if let CodeGeneratorKind::DataclassLike(Some(transformer_params)) = field_policy { @@ -2808,7 +2838,7 @@ impl<'db> ClassLiteral<'db> { .filter_map(|superclass| { if let Some(class) = superclass.into_class() { let (class_literal, specialization) = class.class_literal(db); - if field_policy.matches(db, class_literal) { + if field_policy.matches(db, class_literal, specialization) { Some((class_literal, specialization)) } else { None @@ -3623,7 +3653,7 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { .map(|class| class.variance_of(db, typevar)); let default_attribute_variance = { - let is_namedtuple = CodeGeneratorKind::NamedTuple.matches(db, self); + let is_namedtuple = CodeGeneratorKind::NamedTuple.matches(db, self, None); // Python 3.13 introduced a synthesized `__replace__` method on dataclasses which uses // their field types in contravariant position, thus meaning a frozen dataclass must // still be invariant in its field types. Other synthesized methods on dataclasses are diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index c47a508d61..ea436b4163 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -122,7 +122,7 @@ impl<'db> AllMembers<'db> { self.extend_with_instance_members(db, ty, class_literal); // If this is a NamedTuple instance, include members from NamedTupleFallback - if CodeGeneratorKind::NamedTuple.matches(db, class_literal) { + if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); } } @@ -142,7 +142,7 @@ impl<'db> AllMembers<'db> { Type::ClassLiteral(class_literal) => { self.extend_with_class_members(db, ty, class_literal); - if CodeGeneratorKind::NamedTuple.matches(db, class_literal) { + if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); } @@ -153,7 +153,7 @@ impl<'db> AllMembers<'db> { Type::GenericAlias(generic_alias) => { let class_literal = generic_alias.origin(db); - if CodeGeneratorKind::NamedTuple.matches(db, class_literal) { + if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); } self.extend_with_class_members(db, ty, class_literal); @@ -164,7 +164,7 @@ impl<'db> AllMembers<'db> { let class_literal = class_type.class_literal(db).0; self.extend_with_class_members(db, ty, class_literal); - if CodeGeneratorKind::NamedTuple.matches(db, class_literal) { + if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { self.extend_with_type( db, KnownClass::NamedTupleFallback.to_class_literal(db), diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 5ebcfcda15..2dee39100e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -577,7 +577,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { continue; } - let is_named_tuple = CodeGeneratorKind::NamedTuple.matches(self.db(), class); + let is_named_tuple = CodeGeneratorKind::NamedTuple.matches(self.db(), class, None); // (2) If it's a `NamedTuple` class, check that no field without a default value // appears after a field with a default value. @@ -898,7 +898,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // (7) Check that a dataclass does not have more than one `KW_ONLY`. if let Some(field_policy @ CodeGeneratorKind::DataclassLike(_)) = - CodeGeneratorKind::from_class(self.db(), class) + CodeGeneratorKind::from_class(self.db(), class, None) { let specialization = None; @@ -4569,11 +4569,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .dataclass_params(db) .map(|params| SmallVec::from(params.field_specifiers(db))) .or_else(|| { - class_literal - .try_metaclass(db) - .ok() - .and_then(|(_, params)| params) - .map(|params| SmallVec::from(params.field_specifiers(db))) + Some(SmallVec::from( + CodeGeneratorKind::from_class(db, class_literal, None)? + .dataclass_transformer_params()? + .field_specifiers(db), + )) }) } From 0115fd37572d728f32ca97c0ea190fd8fd185916 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Fri, 17 Oct 2025 08:49:16 -0400 Subject: [PATCH 086/113] Avoid reusing nested, interpolated quotes before Python 3.12 (#20930) ## Summary Fixes #20774 by tracking whether an `InterpolatedStringState` element is nested inside of another interpolated element. This feels like kind of a naive fix, so I'm welcome to other ideas. But it resolves the problem in the issue and clears up the syntax error in the black compatibility test, without affecting many other cases. The other affected case is actually interesting too because the [input](https://github.com/astral-sh/ruff/blob/96b156303b81c5114e8375a6ffd467fb638c3963/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py#L707) is invalid, but the previous quote selection fixed the invalid syntax: ```pycon Python 3.11.13 (main, Sep 2 2025, 14:20:25) [Clang 20.1.4 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> f'{1: abcd "{'aa'}" }' # input File "", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' >>> f'{1: abcd "{"aa"}" }' # old output Traceback (most recent call last): File "", line 1, in ValueError: Invalid format specifier ' abcd "aa" ' for object of type 'int' >>> f'{1: abcd "{'aa'}" }' # new output File "", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' ``` We now preserve the invalid syntax in the input. Unfortunately, this also seems to be another edge case I didn't consider in https://github.com/astral-sh/ruff/pull/20867 because we don't flag this as a syntax error after 0.14.1:
Shell output

``` > uvx ruff@0.14.0 check --ignore ALL --target-version py311 - < -:1:14 | 1 | f'{1: abcd "{'aa'}" }' | ^ | Found 1 error. > uvx ruff@0.14.1 check --ignore ALL --target-version py311 - < uvx python@3.11 -m ast <", line 198, in _run_module_as_main File "", line 88, in _run_code File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 1752, in main() File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 1748, in main tree = parse(source, args.infile.name, args.mode, type_comments=args.no_type_comments) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 50, in parse return compile(source, filename, mode, flags, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "", line 1 f'{1: abcd "{'aa'}" }' ^^ SyntaxError: f-string: expecting '}' ```

I assumed that was the same `ParseError` as the one caused by `f"{1:""}"`, but this is a nested interpolation inside of the format spec. ## Test Plan New test copied from the black compatibility test. I guess this is a duplicate now, I started working on this branch before the new black tests were imported, so I could delete the separate test in our fixtures if that's preferable. --- .../test/fixtures/ruff/expression/fstring.py | 4 ++++ crates/ruff_python_formatter/src/context.rs | 14 ++++++++++++- .../src/other/interpolated_string_element.rs | 14 +++++++++---- .../src/string/normalize.rs | 9 +++++++- ...black_compatibility@cases__fstring.py.snap | 21 +++---------------- .../format@expression__fstring.py.snap | 16 ++++++++++++-- 6 files changed, 52 insertions(+), 26 deletions(-) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index fd658cc5ce..46322e38f2 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -748,3 +748,7 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1, }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" diff --git a/crates/ruff_python_formatter/src/context.rs b/crates/ruff_python_formatter/src/context.rs index bc88b987ae..528afc6c71 100644 --- a/crates/ruff_python_formatter/src/context.rs +++ b/crates/ruff_python_formatter/src/context.rs @@ -144,6 +144,12 @@ pub(crate) enum InterpolatedStringState { /// /// The containing `FStringContext` is the surrounding f-string context. InsideInterpolatedElement(InterpolatedStringContext), + /// The formatter is inside more than one nested f-string, such as in `nested` in: + /// + /// ```py + /// f"{f'''{'nested'} inner'''} outer" + /// ``` + NestedInterpolatedElement(InterpolatedStringContext), /// The formatter is outside an f-string. #[default] Outside, @@ -152,12 +158,18 @@ pub(crate) enum InterpolatedStringState { impl InterpolatedStringState { pub(crate) fn can_contain_line_breaks(self) -> Option { match self { - InterpolatedStringState::InsideInterpolatedElement(context) => { + InterpolatedStringState::InsideInterpolatedElement(context) + | InterpolatedStringState::NestedInterpolatedElement(context) => { Some(context.is_multiline()) } InterpolatedStringState::Outside => None, } } + + /// Returns `true` if the interpolated string state is [`NestedInterpolatedElement`]. + pub(crate) fn is_nested(self) -> bool { + matches!(self, Self::NestedInterpolatedElement(..)) + } } /// The position of a top-level statement in the module. diff --git a/crates/ruff_python_formatter/src/other/interpolated_string_element.rs b/crates/ruff_python_formatter/src/other/interpolated_string_element.rs index 13526c218f..49495aa646 100644 --- a/crates/ruff_python_formatter/src/other/interpolated_string_element.rs +++ b/crates/ruff_python_formatter/src/other/interpolated_string_element.rs @@ -181,10 +181,16 @@ impl Format> for FormatInterpolatedElement<'_> { let item = format_with(|f: &mut PyFormatter| { // Update the context to be inside the f-string expression element. - let f = &mut WithInterpolatedStringState::new( - InterpolatedStringState::InsideInterpolatedElement(self.context), - f, - ); + let state = match f.context().interpolated_string_state() { + InterpolatedStringState::InsideInterpolatedElement(_) + | InterpolatedStringState::NestedInterpolatedElement(_) => { + InterpolatedStringState::NestedInterpolatedElement(self.context) + } + InterpolatedStringState::Outside => { + InterpolatedStringState::InsideInterpolatedElement(self.context) + } + }; + let f = &mut WithInterpolatedStringState::new(state, f); write!(f, [bracket_spacing, expression.format()])?; diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index fa60c4a13f..fabd10a029 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -46,8 +46,15 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { .unwrap_or(self.context.options().quote_style()); let supports_pep_701 = self.context.options().target_version().supports_pep_701(); + // Preserve the existing quote style for nested interpolations more than one layer deep, if + // PEP 701 isn't supported. + if !supports_pep_701 && self.context.interpolated_string_state().is_nested() { + return QuoteStyle::Preserve; + } + // For f-strings and t-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't. - if let InterpolatedStringState::InsideInterpolatedElement(parent_context) = + if let InterpolatedStringState::InsideInterpolatedElement(parent_context) + | InterpolatedStringState::NestedInterpolatedElement(parent_context) = self.context.interpolated_string_state() { let parent_flags = parent_context.flags(); diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap index 6ff1f73fd7..f4140291a5 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__fstring.py.snap @@ -28,12 +28,11 @@ but none started with prefix {parentdir_prefix}" f'{{NOT \'a\' "formatted" "value"}}' f"some f-string with {a} {few():.2f} {formatted.values!r}" -f'some f-string with {a} {few(""):.2f} {formatted.values!r}' --f"{f'''{'nested'} inner'''} outer" ++f"some f-string with {a} {few(''):.2f} {formatted.values!r}" + f"{f'''{'nested'} inner'''} outer" -f"\"{f'{nested} inner'}\" outer" -f"space between opening braces: { {a for a in (1, 2, 3)}}" -f'Hello \'{tricky + "example"}\'' -+f"some f-string with {a} {few(''):.2f} {formatted.values!r}" -+f"{f'''{"nested"} inner'''} outer" +f'"{f"{nested} inner"}" outer' +f"space between opening braces: { {a for a in (1, 2, 3)} }" +f"Hello '{tricky + 'example'}'" @@ -49,7 +48,7 @@ f"{{NOT a formatted value}}" f'{{NOT \'a\' "formatted" "value"}}' f"some f-string with {a} {few():.2f} {formatted.values!r}" f"some f-string with {a} {few(''):.2f} {formatted.values!r}" -f"{f'''{"nested"} inner'''} outer" +f"{f'''{'nested'} inner'''} outer" f'"{f"{nested} inner"}" outer' f"space between opening braces: { {a for a in (1, 2, 3)} }" f"Hello '{tricky + 'example'}'" @@ -72,17 +71,3 @@ f'Hello \'{tricky + "example"}\'' f"Tried directories {str(rootdirs)} \ but none started with prefix {parentdir_prefix}" ``` - -## New Unsupported Syntax Errors - -error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12) - --> fstring.py:6:9 - | -4 | f"some f-string with {a} {few():.2f} {formatted.values!r}" -5 | f"some f-string with {a} {few(''):.2f} {formatted.values!r}" -6 | f"{f'''{"nested"} inner'''} outer" - | ^ -7 | f'"{f"{nested} inner"}" outer' -8 | f"space between opening braces: { {a for a in (1, 2, 3)} }" - | -warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors. diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index c9d90b1764..4460667feb 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -754,6 +754,10 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1, }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" ``` ## Outputs @@ -1532,7 +1536,7 @@ f'{f"""other " """}' f'{1: hy "user"}' f'{1:hy "user"}' f'{1: abcd "{1}" }' -f'{1: abcd "{"aa"}" }' +f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' f"{x:a{z:hy \"user\"}} '''" @@ -1581,6 +1585,10 @@ print( # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1 }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" ``` @@ -2359,7 +2367,7 @@ f'{f"""other " """}' f'{1: hy "user"}' f'{1:hy "user"}' f'{1: abcd "{1}" }' -f'{1: abcd "{"aa"}" }' +f'{1: abcd "{'aa'}" }' f'{1=: "abcd {'aa'}}' f"{x:a{z:hy \"user\"}} '''" @@ -2408,6 +2416,10 @@ print( # Regression tests for https://github.com/astral-sh/ruff/issues/15536 print(f"{ {}, 1 }") + + +# The inner quotes should not be changed to double quotes before Python 3.12 +f"{f'''{'nested'} inner'''} outer" ``` From c4240076459bc9128fee6f83cbf0f51d134b663b Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 17 Oct 2025 15:57:17 +0100 Subject: [PATCH 087/113] Update usage instructions and lockfile for py-fuzzer script (#20940) --- .github/workflows/ci.yaml | 10 +- .github/workflows/daily_fuzz.yaml | 7 +- crates/ruff_python_parser/CONTRIBUTING.md | 2 +- python/py-fuzzer/README.md | 2 +- python/py-fuzzer/fuzz.py | 15 +-- python/py-fuzzer/uv.lock | 141 ++++++++++++---------- 6 files changed, 98 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7424c3a7a4..0a52349001 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -498,9 +498,10 @@ jobs: chmod +x "${DOWNLOAD_PATH}/ruff" ( - uvx \ + uv run \ --python="${PYTHON_VERSION}" \ - --from=./python/py-fuzzer \ + --project=./python/py-fuzzer \ + --locked \ fuzz \ --test-executable="${DOWNLOAD_PATH}/ruff" \ --bin=ruff \ @@ -694,9 +695,10 @@ jobs: chmod +x "${PWD}/ty" "${NEW_TY}/ty" ( - uvx \ + uv run \ --python="${PYTHON_VERSION}" \ - --from=./python/py-fuzzer \ + --project=./python/py-fuzzer \ + --locked \ fuzz \ --test-executable="${NEW_TY}/ty" \ --baseline-executable="${PWD}/ty" \ diff --git a/.github/workflows/daily_fuzz.yaml b/.github/workflows/daily_fuzz.yaml index 2f54dd1082..aeeae1d88c 100644 --- a/.github/workflows/daily_fuzz.yaml +++ b/.github/workflows/daily_fuzz.yaml @@ -48,9 +48,10 @@ jobs: run: | # shellcheck disable=SC2046 ( - uvx \ - --python=3.12 \ - --from=./python/py-fuzzer \ + uv run \ + --python=3.13 \ + --project=./python/py-fuzzer \ + --locked \ fuzz \ --test-executable=target/debug/ruff \ --bin=ruff \ diff --git a/crates/ruff_python_parser/CONTRIBUTING.md b/crates/ruff_python_parser/CONTRIBUTING.md index 90209ada78..ef328e01ce 100644 --- a/crates/ruff_python_parser/CONTRIBUTING.md +++ b/crates/ruff_python_parser/CONTRIBUTING.md @@ -62,7 +62,7 @@ To run the fuzzer, execute the following command (requires [`uv`](https://github.com/astral-sh/uv) to be installed): ```sh -uvx --from ./python/py-fuzzer fuzz +uv run --project=./python/py-fuzzer fuzz ``` Refer to the [py-fuzzer](https://github.com/astral-sh/ruff/blob/main/python/py-fuzzer/fuzz.py) diff --git a/python/py-fuzzer/README.md b/python/py-fuzzer/README.md index 5e6c713405..a4c54b0bdc 100644 --- a/python/py-fuzzer/README.md +++ b/python/py-fuzzer/README.md @@ -3,6 +3,6 @@ A fuzzer script to run Ruff executables on randomly generated (but syntactically valid) Python source-code files. -Run `uvx --from ./python/py-fuzzer fuzz -h` from the repository root +Run `uv run --project=./python/py-fuzzer fuzz -h` from the repository root for more information and example invocations (requires [`uv`](https://github.com/astral-sh/uv) to be installed). diff --git a/python/py-fuzzer/fuzz.py b/python/py-fuzzer/fuzz.py index 895e74dad8..22f6965244 100644 --- a/python/py-fuzzer/fuzz.py +++ b/python/py-fuzzer/fuzz.py @@ -4,24 +4,21 @@ Python source-code files. This script can be installed into a virtual environment using `uv pip install -e ./python/py-fuzzer` from the Ruff repository root, -or can be run using `uvx --from ./python/py-fuzzer fuzz` +or can be run using `uv run --project=./python/py-fuzzer fuzz` (in which case the virtual environment does not need to be activated). +Note that using `uv run --project` rather than `uvx --from` means that +uv will respect the script's lockfile. Example invocations of the script using `uv`: - Run the fuzzer on Ruff's parser using seeds 0, 1, 2, 78 and 93 to generate the code: - `uvx --from ./python/py-fuzzer fuzz --bin ruff 0-2 78 93` + `uv run --project=./python/py-fuzzer fuzz --bin ruff 0-2 78 93` - Run the fuzzer concurrently using seeds in range 0-10 inclusive, but only reporting bugs that are new on your branch: - `uvx --from ./python/py-fuzzer fuzz --bin ruff 0-10 --only-new-bugs` + `uv run --project=./python/py-fuzzer fuzz --bin ruff 0-10 --only-new-bugs` - Run the fuzzer concurrently on 10,000 different Python source-code files, using a random selection of seeds, and only print a summary at the end (the `shuf` command is Unix-specific): - `uvx --from ./python/py-fuzzer fuzz --bin ruff $(shuf -i 0-1000000 -n 10000) --quiet - -If you make local modifications to this script, you'll need to run the above -with `--reinstall` to get your changes reflected in the uv-cached installed -package. Alternatively, if iterating quickly on changes, you can add -`--with-editable ./python/py-fuzzer`. + `uv run --project=./python/py-fuzzer fuzz --bin ruff $(shuf -i 0-1000000 -n 10000) --quiet """ from __future__ import annotations diff --git a/python/py-fuzzer/uv.lock b/python/py-fuzzer/uv.lock index 1be4ceed23..7716e16da9 100644 --- a/python/py-fuzzer/uv.lock +++ b/python/py-fuzzer/uv.lock @@ -1,58 +1,76 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.12" [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mypy" -version = "1.13.0" +version = "1.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, - { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, - { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, - { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, - { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, - { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, - { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, - { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, - { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, - { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, - { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, ] [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] @@ -90,98 +108,99 @@ dev = [ [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pysource-codegen" -version = "0.6.0" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/5d/1ab81f5f4eb3fb9203a91ea3cdb7da7b19b38993d669e39000a6454128e1/pysource_codegen-0.6.0.tar.gz", hash = "sha256:0337e3cf3639f017567ab298684c78eb15c877b093965259c725a13a4917be4e", size = 157342 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/a8/2bbac069fef2361eaef2c23706fc0de051034c14cc1c4ab3d88b095cfe5f/pysource_codegen-0.7.1.tar.gz", hash = "sha256:1a72d29591a9732fa9a66ee4976307081e56dd8991231b19a0bddf87a31d4c8e", size = 71073, upload-time = "2025-08-29T19:27:58.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/e4/c3a105d7d43bf237b18169eb1a06e1b93dc0daa96e74efa14c6d908979b4/pysource_codegen-0.6.0-py3-none-any.whl", hash = "sha256:858f2bbed6de7a7b7e9bbea5cb8212ec7e66fa7f2a7ba433fe05f72438017b30", size = 19489 }, + { url = "https://files.pythonhosted.org/packages/63/a5/c851b7fac516d7ffb8943991c3ac46d7d29808b773d270d8b6b820cb2e51/pysource_codegen-0.7.1-py3-none-any.whl", hash = "sha256:099e00d587a59babacaff902ad0b6d5b2f7344648c1e0baa981b30cc11a5c363", size = 20671, upload-time = "2025-08-29T19:27:57.756Z" }, ] [[package]] name = "pysource-minimize" -version = "0.8.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/d3/6786a52121987875b2e9d273399504e2bdb868e7b80b603ecb29c900846f/pysource_minimize-0.8.0.tar.gz", hash = "sha256:e9a88c68717891dc7dc74beab769ef4c2e397599e1620b2046b89783fb500652", size = 715267 } +sdist = { url = "https://files.pythonhosted.org/packages/03/e6/14136d4868c3ea2e46d7f83faef26d07fd9231b7bd3fc811b6b6f8688cf2/pysource_minimize-0.10.1.tar.gz", hash = "sha256:05000b5174207d10dbb6da1a67a6f3a6f61d295efa17e3c74283f0d9ece6401c", size = 9254623, upload-time = "2025-08-30T14:41:16.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7d/4e9ed2a376bb7372d74fdec557f35f70c2bf5373f2c67e05535555d0a6d4/pysource_minimize-0.8.0-py3-none-any.whl", hash = "sha256:edee433c24a2e8f81701aa7e01ba4c1e63f481f683dd3a561610762bc03ed6c3", size = 14635 }, + { url = "https://files.pythonhosted.org/packages/f5/de/847456f142124b242933a1cea17aea7041d297f1ac99b411c5dee69ac250/pysource_minimize-0.10.1-py3-none-any.whl", hash = "sha256:69b41fd2f0c1840ca281ec788c6ffb4e79680b06c7cc0e7c4d9a75321654b709", size = 18631, upload-time = "2025-08-30T14:41:15.588Z" }, ] [[package]] name = "rich" -version = "13.9.4" +version = "14.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] [[package]] name = "rich-argparse" -version = "1.7.0" +version = "1.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/b9/ff53663ee7fa6a4195fa96d91da499f2e00ca067541e016d345cce1c9ad2/rich_argparse-1.7.0.tar.gz", hash = "sha256:f31d809c465ee43f367d599ccaf88b73bc2c4d75d74ed43f2d538838c53544ba", size = 38009 } +sdist = { url = "https://files.pythonhosted.org/packages/71/a6/34460d81e5534f6d2fc8e8d91ff99a5835fdca53578eac89e4f37b3a7c6d/rich_argparse-1.7.1.tar.gz", hash = "sha256:d7a493cde94043e41ea68fb43a74405fa178de981bf7b800f7a3bd02ac5c27be", size = 38094, upload-time = "2025-05-25T20:20:35.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/9c/dc7cbeb99a7b7422392ed7f327efdbb958bc0faf424aef5f130309320bda/rich_argparse-1.7.0-py3-none-any.whl", hash = "sha256:b8ec8943588e9731967f4f97b735b03dc127c416f480a083060433a97baf2fd3", size = 25339 }, + { url = "https://files.pythonhosted.org/packages/31/f6/5fc0574af5379606ffd57a4b68ed88f9b415eb222047fe023aefcc00a648/rich_argparse-1.7.1-py3-none-any.whl", hash = "sha256:a8650b42e4a4ff72127837632fba6b7da40784842f08d7395eb67a9cbd7b4bf9", size = 25357, upload-time = "2025-05-25T20:20:33.793Z" }, ] [[package]] name = "ruff" -version = "0.11.9" +version = "0.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/e7/e55dda1c92cdcf34b677ebef17486669800de01e887b7831a1b8fdf5cb08/ruff-0.11.9.tar.gz", hash = "sha256:ebd58d4f67a00afb3a30bf7d383e52d0e036e6195143c6db7019604a05335517", size = 4132134 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429, upload-time = "2025-10-16T18:05:41.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/71/75dfb7194fe6502708e547941d41162574d1f579c4676a8eb645bf1a6842/ruff-0.11.9-py3-none-linux_armv6l.whl", hash = "sha256:a31a1d143a5e6f499d1fb480f8e1e780b4dfdd580f86e05e87b835d22c5c6f8c", size = 10335453 }, - { url = "https://files.pythonhosted.org/packages/74/fc/ad80c869b1732f53c4232bbf341f33c5075b2c0fb3e488983eb55964076a/ruff-0.11.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:66bc18ca783b97186a1f3100e91e492615767ae0a3be584e1266aa9051990722", size = 11072566 }, - { url = "https://files.pythonhosted.org/packages/87/0d/0ccececef8a0671dae155cbf7a1f90ea2dd1dba61405da60228bbe731d35/ruff-0.11.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd576cd06962825de8aece49f28707662ada6a1ff2db848d1348e12c580acbf1", size = 10435020 }, - { url = "https://files.pythonhosted.org/packages/52/01/e249e1da6ad722278094e183cbf22379a9bbe5f21a3e46cef24ccab76e22/ruff-0.11.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1d18b4be8182cc6fddf859ce432cc9631556e9f371ada52f3eaefc10d878de", size = 10593935 }, - { url = "https://files.pythonhosted.org/packages/ed/9a/40cf91f61e3003fe7bd43f1761882740e954506c5a0f9097b1cff861f04c/ruff-0.11.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f3f46f759ac623e94824b1e5a687a0df5cd7f5b00718ff9c24f0a894a683be7", size = 10172971 }, - { url = "https://files.pythonhosted.org/packages/61/12/d395203de1e8717d7a2071b5a340422726d4736f44daf2290aad1085075f/ruff-0.11.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f34847eea11932d97b521450cf3e1d17863cfa5a94f21a056b93fb86f3f3dba2", size = 11748631 }, - { url = "https://files.pythonhosted.org/packages/66/d6/ef4d5eba77677eab511644c37c55a3bb8dcac1cdeb331123fe342c9a16c9/ruff-0.11.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f33b15e00435773df97cddcd263578aa83af996b913721d86f47f4e0ee0ff271", size = 12409236 }, - { url = "https://files.pythonhosted.org/packages/c5/8f/5a2c5fc6124dd925a5faf90e1089ee9036462118b619068e5b65f8ea03df/ruff-0.11.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b27613a683b086f2aca8996f63cb3dd7bc49e6eccf590563221f7b43ded3f65", size = 11881436 }, - { url = "https://files.pythonhosted.org/packages/39/d1/9683f469ae0b99b95ef99a56cfe8c8373c14eba26bd5c622150959ce9f64/ruff-0.11.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e0d88756e63e8302e630cee3ce2ffb77859797cc84a830a24473939e6da3ca6", size = 13982759 }, - { url = "https://files.pythonhosted.org/packages/4e/0b/c53a664f06e0faab596397867c6320c3816df479e888fe3af63bc3f89699/ruff-0.11.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:537c82c9829d7811e3aa680205f94c81a2958a122ac391c0eb60336ace741a70", size = 11541985 }, - { url = "https://files.pythonhosted.org/packages/23/a0/156c4d7e685f6526a636a60986ee4a3c09c8c4e2a49b9a08c9913f46c139/ruff-0.11.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:440ac6a7029f3dee7d46ab7de6f54b19e34c2b090bb4f2480d0a2d635228f381", size = 10465775 }, - { url = "https://files.pythonhosted.org/packages/43/d5/88b9a6534d9d4952c355e38eabc343df812f168a2c811dbce7d681aeb404/ruff-0.11.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:71c539bac63d0788a30227ed4d43b81353c89437d355fdc52e0cda4ce5651787", size = 10170957 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/2bd533bdaf469dc84b45815ab806784d561fab104d993a54e1852596d581/ruff-0.11.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c67117bc82457e4501473c5f5217d49d9222a360794bfb63968e09e70f340abd", size = 11143307 }, - { url = "https://files.pythonhosted.org/packages/2f/d9/43cfba291788459b9bfd4e09a0479aa94d05ab5021d381a502d61a807ec1/ruff-0.11.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e4b78454f97aa454586e8a5557facb40d683e74246c97372af3c2d76901d697b", size = 11603026 }, - { url = "https://files.pythonhosted.org/packages/22/e6/7ed70048e89b01d728ccc950557a17ecf8df4127b08a56944b9d0bae61bc/ruff-0.11.9-py3-none-win32.whl", hash = "sha256:7fe1bc950e7d7b42caaee2a8a3bc27410547cc032c9558ee2e0f6d3b209e845a", size = 10548627 }, - { url = "https://files.pythonhosted.org/packages/90/36/1da5d566271682ed10f436f732e5f75f926c17255c9c75cefb77d4bf8f10/ruff-0.11.9-py3-none-win_amd64.whl", hash = "sha256:52edaa4a6d70f8180343a5b7f030c7edd36ad180c9f4d224959c2d689962d964", size = 11634340 }, - { url = "https://files.pythonhosted.org/packages/40/f7/70aad26e5877c8f7ee5b161c4c9fa0100e63fc4c944dc6d97b9c7e871417/ruff-0.11.9-py3-none-win_arm64.whl", hash = "sha256:bcf42689c22f2e240f496d0c183ef2c6f7b35e809f12c1db58f75d9aa8d630ca", size = 10741080 }, + { url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415, upload-time = "2025-10-16T18:04:48.227Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267, upload-time = "2025-10-16T18:04:52.515Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872, upload-time = "2025-10-16T18:04:55.396Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558, upload-time = "2025-10-16T18:04:58.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898, upload-time = "2025-10-16T18:05:01.455Z" }, + { url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168, upload-time = "2025-10-16T18:05:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942, upload-time = "2025-10-16T18:05:07.102Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622, upload-time = "2025-10-16T18:05:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143, upload-time = "2025-10-16T18:05:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844, upload-time = "2025-10-16T18:05:16.1Z" }, + { url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241, upload-time = "2025-10-16T18:05:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476, upload-time = "2025-10-16T18:05:22.163Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749, upload-time = "2025-10-16T18:05:25.162Z" }, + { url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758, upload-time = "2025-10-16T18:05:28.018Z" }, + { url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811, upload-time = "2025-10-16T18:05:30.707Z" }, + { url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467, upload-time = "2025-10-16T18:05:33.261Z" }, + { url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123, upload-time = "2025-10-16T18:05:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" }, ] [[package]] name = "termcolor" version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684 }, + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] From c7e2bfd7592d765df07b87ad64755d5fa9037fa4 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 17 Oct 2025 18:13:40 +0100 Subject: [PATCH 088/113] [ty] `continue` and `break` statements outside loops are syntax errors (#20944) Co-authored-by: Brent Westbrook --- .../diagnostics/semantic_syntax_errors.md | 22 ++++ ...-_`break`_and_`continu…_(3143ba0a999d644).snap | 107 ++++++++++++++++++ .../src/semantic_index/builder.rs | 2 +- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`break`_and_`continu…_(3143ba0a999d644).snap diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md index 5e77445b07..e3c830a1e8 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md @@ -354,3 +354,25 @@ def f(): x = 1 global x # error: [invalid-syntax] "name `x` is used prior to global declaration" ``` + +## `break` and `continue` outside a loop + + + +```py +break # error: [invalid-syntax] +continue # error: [invalid-syntax] + +for x in range(42): + break # fine + continue # fine + + def _(): + break # error: [invalid-syntax] + continue # error: [invalid-syntax] + + class Fine: + # this is invalid syntax despite it being in an eager-nested scope! + break # error: [invalid-syntax] + continue # error: [invalid-syntax] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`break`_and_`continu…_(3143ba0a999d644).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`break`_and_`continu…_(3143ba0a999d644).snap new file mode 100644 index 0000000000..cd814dd61a --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/semantic_syntax_erro…_-_Semantic_syntax_erro…_-_`break`_and_`continu…_(3143ba0a999d644).snap @@ -0,0 +1,107 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: semantic_syntax_errors.md - Semantic syntax error diagnostics - `break` and `continue` outside a loop +mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | break # error: [invalid-syntax] + 2 | continue # error: [invalid-syntax] + 3 | + 4 | for x in range(42): + 5 | break # fine + 6 | continue # fine + 7 | + 8 | def _(): + 9 | break # error: [invalid-syntax] +10 | continue # error: [invalid-syntax] +11 | +12 | class Fine: +13 | # this is invalid syntax despite it being in an eager-nested scope! +14 | break # error: [invalid-syntax] +15 | continue # error: [invalid-syntax] +``` + +# Diagnostics + +``` +error[invalid-syntax]: `break` outside loop + --> src/mdtest_snippet.py:1:1 + | +1 | break # error: [invalid-syntax] + | ^^^^^ +2 | continue # error: [invalid-syntax] + | + +``` + +``` +error[invalid-syntax]: `continue` outside loop + --> src/mdtest_snippet.py:2:1 + | +1 | break # error: [invalid-syntax] +2 | continue # error: [invalid-syntax] + | ^^^^^^^^ +3 | +4 | for x in range(42): + | + +``` + +``` +error[invalid-syntax]: `break` outside loop + --> src/mdtest_snippet.py:9:9 + | + 8 | def _(): + 9 | break # error: [invalid-syntax] + | ^^^^^ +10 | continue # error: [invalid-syntax] + | + +``` + +``` +error[invalid-syntax]: `continue` outside loop + --> src/mdtest_snippet.py:10:9 + | + 8 | def _(): + 9 | break # error: [invalid-syntax] +10 | continue # error: [invalid-syntax] + | ^^^^^^^^ +11 | +12 | class Fine: + | + +``` + +``` +error[invalid-syntax]: `break` outside loop + --> src/mdtest_snippet.py:14:9 + | +12 | class Fine: +13 | # this is invalid syntax despite it being in an eager-nested scope! +14 | break # error: [invalid-syntax] + | ^^^^^ +15 | continue # error: [invalid-syntax] + | + +``` + +``` +error[invalid-syntax]: `continue` outside loop + --> src/mdtest_snippet.py:15:9 + | +13 | # this is invalid syntax despite it being in an eager-nested scope! +14 | break # error: [invalid-syntax] +15 | continue # error: [invalid-syntax] + | ^^^^^^^^ + | + +``` diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index 01de16b8a6..5bf8cbb3e7 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -2787,7 +2787,7 @@ impl SemanticSyntaxContext for SemanticIndexBuilder<'_, '_> { } fn in_loop_context(&self) -> bool { - true + self.current_scope_info().current_loop.is_some() } } From 6e7ff0706564cb938c154b5a5f9993c1118085e0 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 17 Oct 2025 20:05:20 +0200 Subject: [PATCH 089/113] [ty] Provide completions on `TypeVar`s (#20943) ## Summary closes https://github.com/astral-sh/ty/issues/1370 ## Test Plan New snapshot tests --- crates/ty_ide/src/completion.rs | 49 +++++++++++++++++++ .../src/types/ide_support.rs | 26 +++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 1599a2bed5..8cfdb18099 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -3896,6 +3896,55 @@ print(t'''{Foo} and Foo.zqzq assert_snapshot!(test.completions_without_builtins(), @""); } + #[test] + fn typevar_with_upper_bound() { + let test = cursor_test( + "\ +def f[T: str](msg: T): + msg. +", + ); + test.assert_completions_include("upper"); + test.assert_completions_include("capitalize"); + } + + #[test] + fn typevar_with_constraints() { + // Test TypeVar with constraints + let test = cursor_test( + "\ +from typing import TypeVar + +class A: + only_on_a: int + on_a_and_b: str + +class B: + only_on_b: float + on_a_and_b: str + +T = TypeVar('T', A, B) + +def f(x: T): + x. +", + ); + test.assert_completions_include("on_a_and_b"); + test.assert_completions_do_not_include("only_on_a"); + test.assert_completions_do_not_include("only_on_b"); + } + + #[test] + fn typevar_without_bounds_or_constraints() { + let test = cursor_test( + "\ +def f[T](x: T): + x. +", + ); + test.assert_completions_include("__repr__"); + } + // NOTE: The methods below are getting somewhat ridiculous. // We should refactor this by converting to using a builder // to set different modes. ---AG diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index ea436b4163..d02011390b 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -14,7 +14,7 @@ use crate::types::call::{CallArguments, MatchedArgument}; use crate::types::signatures::Signature; use crate::types::{ ClassBase, ClassLiteral, DynamicType, KnownClass, KnownInstanceType, Type, - class::CodeGeneratorKind, + TypeVarBoundOrConstraints, class::CodeGeneratorKind, }; use crate::{Db, HasType, NameKind, SemanticModel}; use ruff_db::files::{File, FileRange}; @@ -177,6 +177,29 @@ impl<'db> AllMembers<'db> { Type::TypeAlias(alias) => self.extend_with_type(db, alias.value_type(db)), + Type::TypeVar(bound_typevar) => { + match bound_typevar.typevar(db).bound_or_constraints(db) { + None => { + self.extend_with_type(db, Type::object()); + } + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + self.extend_with_type(db, bound); + } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + self.members.extend( + constraints + .elements(db) + .iter() + .map(|ty| AllMembers::of(db, *ty).members) + .reduce(|acc, members| { + acc.intersection(&members).cloned().collect() + }) + .unwrap_or_default(), + ); + } + } + } + Type::IntLiteral(_) | Type::BooleanLiteral(_) | Type::StringLiteral(_) @@ -194,7 +217,6 @@ impl<'db> AllMembers<'db> { | Type::ProtocolInstance(_) | Type::SpecialForm(_) | Type::KnownInstance(_) - | Type::TypeVar(_) | Type::BoundSuper(_) | Type::TypeIs(_) => match ty.to_meta_type(db) { Type::ClassLiteral(class_literal) => { From e4384fc212df5ea5e70ad939dc18ba47454e5bbf Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama <45118249+mtshiba@users.noreply.github.com> Date: Sat, 18 Oct 2025 04:12:19 +0900 Subject: [PATCH 090/113] [ty] impl `VarianceInferable` for `KnownInstanceType` (#20924) ## Summary Derived from #20900 Implement `VarianceInferable` for `KnownInstanceType` (especially for `KnownInstanceType::TypeAliasType`). The variance of a type alias matches its value type. In normal usage, type aliases are expanded to value types, so the variance of a type alias can be obtained without implementing this. However, for example, if we want to display the variance when hovering over a type alias, we need to be able to obtain the variance of the type alias itself (cf. #20900). ## Test Plan I couldn't come up with a way to test this in mdtest, so I'm testing it in a test submodule at the end of `types.rs`. I also added a test to `mdtest/generics/pep695/variance.md`, but it passes without the changes in this PR. --- .../mdtest/generics/pep695/variance.md | 59 ++++++++ crates/ty_python_semantic/src/types.rs | 133 ++++++++++++++++-- 2 files changed, 183 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md index 4c96a3c4f4..7dc9392b21 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md @@ -790,6 +790,65 @@ static_assert(not is_assignable_to(C[B], C[A])) static_assert(not is_assignable_to(C[A], C[B])) ``` +## Type aliases + +The variance of the type alias matches the variance of the value type (RHS type). + +```py +from ty_extensions import static_assert, is_subtype_of +from typing import Literal + +class Covariant[T]: + def get(self) -> T: + raise ValueError + +type CovariantLiteral1 = Covariant[Literal[1]] +type CovariantInt = Covariant[int] +type MyCovariant[T] = Covariant[T] + +static_assert(is_subtype_of(CovariantLiteral1, CovariantInt)) +static_assert(is_subtype_of(MyCovariant[Literal[1]], MyCovariant[int])) + +class Contravariant[T]: + def set(self, value: T): + pass + +type ContravariantLiteral1 = Contravariant[Literal[1]] +type ContravariantInt = Contravariant[int] +type MyContravariant[T] = Contravariant[T] + +static_assert(is_subtype_of(ContravariantInt, ContravariantLiteral1)) +static_assert(is_subtype_of(MyContravariant[int], MyContravariant[Literal[1]])) + +class Invariant[T]: + def get(self) -> T: + raise ValueError + + def set(self, value: T): + pass + +type InvariantLiteral1 = Invariant[Literal[1]] +type InvariantInt = Invariant[int] +type MyInvariant[T] = Invariant[T] + +static_assert(not is_subtype_of(InvariantInt, InvariantLiteral1)) +static_assert(not is_subtype_of(InvariantLiteral1, InvariantInt)) +static_assert(not is_subtype_of(MyInvariant[Literal[1]], MyInvariant[int])) +static_assert(not is_subtype_of(MyInvariant[int], MyInvariant[Literal[1]])) + +class Bivariant[T]: + pass + +type BivariantLiteral1 = Bivariant[Literal[1]] +type BivariantInt = Bivariant[int] +type MyBivariant[T] = Bivariant[T] + +static_assert(is_subtype_of(BivariantInt, BivariantLiteral1)) +static_assert(is_subtype_of(BivariantLiteral1, BivariantInt)) +static_assert(is_subtype_of(MyBivariant[Literal[1]], MyBivariant[int])) +static_assert(is_subtype_of(MyBivariant[int], MyBivariant[Literal[1]])) +``` + ## Inheriting from generic classes with inferred variance When inheriting from a generic class with our type variable substituted in, we count its occurrences diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 53c4f427aa..477bf9aa11 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -55,9 +55,10 @@ use crate::types::function::{ DataclassTransformerFlags, DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction, }; +pub(crate) use crate::types::generics::GenericContext; use crate::types::generics::{ - GenericContext, InferableTypeVars, PartialSpecialization, Specialization, bind_typevar, - typing_self, walk_generic_context, + InferableTypeVars, PartialSpecialization, Specialization, bind_typevar, typing_self, + walk_generic_context, }; use crate::types::infer::infer_unpack_types; use crate::types::mro::{Mro, MroError, MroIterator}; @@ -7274,6 +7275,7 @@ impl<'db> VarianceInferable<'db> for Type<'db> { .collect(), Type::SubclassOf(subclass_of_type) => subclass_of_type.variance_of(db, typevar), Type::TypeIs(type_is_type) => type_is_type.variance_of(db, typevar), + Type::KnownInstance(known_instance) => known_instance.variance_of(db, typevar), Type::Dynamic(_) | Type::Never | Type::WrapperDescriptor(_) @@ -7288,7 +7290,6 @@ impl<'db> VarianceInferable<'db> for Type<'db> { | Type::LiteralString | Type::BytesLiteral(_) | Type::SpecialForm(_) - | Type::KnownInstance(_) | Type::AlwaysFalsy | Type::AlwaysTruthy | Type::BoundSuper(_) @@ -7495,6 +7496,17 @@ fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( } } +impl<'db> VarianceInferable<'db> for KnownInstanceType<'db> { + fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { + match self { + KnownInstanceType::TypeAliasType(type_alias) => { + type_alias.raw_value_type(db).variance_of(db, typevar) + } + _ => TypeVarVariance::Bivariant, + } + } +} + impl<'db> KnownInstanceType<'db> { fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { match self { @@ -10693,14 +10705,10 @@ impl<'db> PEP695TypeAliasType<'db> { semantic_index(db, scope.file(db)).expect_single_definition(type_alias_stmt_node) } + /// The RHS type of a PEP-695 style type alias with specialization applied. #[salsa::tracked(cycle_fn=value_type_cycle_recover, cycle_initial=value_type_cycle_initial, heap_size=ruff_memory_usage::heap_size)] pub(crate) fn value_type(self, db: &'db dyn Db) -> Type<'db> { - let scope = self.rhs_scope(db); - let module = parsed_module(db, scope.file(db)).load(db); - let type_alias_stmt_node = scope.node(db).expect_type_alias(); - let definition = self.definition(db); - let value_type = - definition_expression_type(db, definition, &type_alias_stmt_node.node(&module).value); + let value_type = self.raw_value_type(db); if let Some(generic_context) = self.generic_context(db) { let specialization = self @@ -10713,6 +10721,25 @@ impl<'db> PEP695TypeAliasType<'db> { } } + /// The RHS type of a PEP-695 style type alias with *no* specialization applied. + /// + /// ## Warning + /// + /// This uses the semantic index to find the definition of the type alias. This means that if the + /// calling query is not in the same file as this type alias is defined in, then this will create + /// a cross-module dependency directly on the full AST which will lead to cache + /// over-invalidation. + /// This method also calls the type inference functions, and since type aliases can have recursive structures, + /// we should be careful not to create infinite recursions in this method (or make it tracked if necessary). + pub(crate) fn raw_value_type(self, db: &'db dyn Db) -> Type<'db> { + let scope = self.rhs_scope(db); + let module = parsed_module(db, scope.file(db)).load(db); + let type_alias_stmt_node = scope.node(db).expect_type_alias(); + let definition = self.definition(db); + + definition_expression_type(db, definition, &type_alias_stmt_node.node(&module).value) + } + pub(crate) fn apply_specialization( self, db: &'db dyn Db, @@ -10892,6 +10919,13 @@ impl<'db> TypeAliasType<'db> { } } + pub(crate) fn raw_value_type(self, db: &'db dyn Db) -> Type<'db> { + match self { + TypeAliasType::PEP695(type_alias) => type_alias.raw_value_type(db), + TypeAliasType::ManualPEP695(type_alias) => type_alias.value(db), + } + } + pub(crate) fn as_pep_695_type_alias(self) -> Option> { match self { TypeAliasType::PEP695(type_alias) => Some(type_alias), @@ -11724,4 +11758,85 @@ pub(crate) mod tests { .build(); assert_eq!(intersection.display(&db).to_string(), "Never"); } + + #[test] + fn type_alias_variance() { + use crate::db::tests::TestDb; + use crate::place::global_symbol; + + fn get_type_alias<'db>(db: &'db TestDb, name: &str) -> PEP695TypeAliasType<'db> { + let module = ruff_db::files::system_path_to_file(db, "/src/a.py").unwrap(); + let ty = global_symbol(db, module, name).place.expect_type(); + let Type::KnownInstance(KnownInstanceType::TypeAliasType(TypeAliasType::PEP695( + type_alias, + ))) = ty + else { + panic!("Expected `{name}` to be a type alias"); + }; + type_alias + } + fn get_bound_typevar<'db>( + db: &'db TestDb, + type_alias: PEP695TypeAliasType<'db>, + ) -> BoundTypeVarInstance<'db> { + let generic_context = type_alias.generic_context(db).unwrap(); + generic_context.variables(db).next().unwrap() + } + + let mut db = setup_db(); + db.write_dedented( + "/src/a.py", + r#" +class Covariant[T]: + def get(self) -> T: + raise ValueError + +class Contravariant[T]: + def set(self, value: T): + pass + +class Invariant[T]: + def get(self) -> T: + raise ValueError + def set(self, value: T): + pass + +class Bivariant[T]: + pass + +type CovariantAlias[T] = Covariant[T] +type ContravariantAlias[T] = Contravariant[T] +type InvariantAlias[T] = Invariant[T] +type BivariantAlias[T] = Bivariant[T] +"#, + ) + .unwrap(); + let covariant = get_type_alias(&db, "CovariantAlias"); + assert_eq!( + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(covariant)) + .variance_of(&db, get_bound_typevar(&db, covariant)), + TypeVarVariance::Covariant + ); + + let contravariant = get_type_alias(&db, "ContravariantAlias"); + assert_eq!( + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(contravariant)) + .variance_of(&db, get_bound_typevar(&db, contravariant)), + TypeVarVariance::Contravariant + ); + + let invariant = get_type_alias(&db, "InvariantAlias"); + assert_eq!( + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(invariant)) + .variance_of(&db, get_bound_typevar(&db, invariant)), + TypeVarVariance::Invariant + ); + + let bivariant = get_type_alias(&db, "BivariantAlias"); + assert_eq!( + KnownInstanceType::TypeAliasType(TypeAliasType::PEP695(bivariant)) + .variance_of(&db, get_bound_typevar(&db, bivariant)), + TypeVarVariance::Bivariant + ); + } } From 6d2cf3475f0e9fc284722ad6a51aa3cf56499f5f Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 17 Oct 2025 21:14:04 +0200 Subject: [PATCH 091/113] Only add the actual schema in schemastore PRs (#20947) Same as https://github.com/astral-sh/ty/pull/1391: > Last time I ran this script, due to what I assume was a `npm` version mismatch, the `package-lock.json` file was updated while running `npm install` in the `schemastore`. Due to the use of `git commit -a`, it was accidentally included in the commit for the semi-automated schemastore PR. The solution here is to only add the actual file that we want to commit. --- scripts/update_schemastore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update_schemastore.py b/scripts/update_schemastore.py index 5ec1ff9d3b..5394e571be 100644 --- a/scripts/update_schemastore.py +++ b/scripts/update_schemastore.py @@ -117,11 +117,11 @@ def update_schemastore( f"This updates ruff's JSON schema to [{current_sha}]({commit_url})" ) # https://stackoverflow.com/a/22909204/3549270 + check_call(["git", "add", (src / RUFF_JSON).as_posix()], cwd=schemastore_path) check_call( [ "git", "commit", - "-a", "-m", "Update ruff's JSON schema", "-m", From 8ca2b5555d47e211c04136637477c5227172d885 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 17 Oct 2025 20:30:17 +0100 Subject: [PATCH 092/113] Dogfood ty on py-fuzzer in CI (#20946) --- .github/workflows/ci.yaml | 9 ++++++++- python/py-fuzzer/fuzz.py | 2 +- python/py-fuzzer/pyproject.toml | 4 ++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0a52349001..f4f0b9b607 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -277,7 +277,8 @@ jobs: run: cargo test -p ty_python_semantic --test mdtest || true - name: "Run tests" run: cargo insta test --all-features --unreferenced reject --test-runner nextest - + # Dogfood ty on py-fuzzer + - run: uv run --project=./python/py-fuzzer cargo run -p ty check --project=./python/py-fuzzer # Check for broken links in the documentation. - run: cargo doc --all --no-deps env: @@ -519,6 +520,7 @@ jobs: with: persist-credentials: false - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 - name: "Install Rust toolchain" run: rustup component add rustfmt # Run all code generation scripts, and verify that the current output is @@ -533,6 +535,11 @@ jobs: ./scripts/add_plugin.py test --url https://pypi.org/project/-test/0.1.0/ --prefix TST ./scripts/add_rule.py --name FirstRule --prefix TST --code 001 --linter test - run: cargo check + # Lint/format/type-check py-fuzzer + # (dogfooding with ty is done in a separate job) + - run: uv run --directory=./python/py-fuzzer mypy + - run: uv run --directory=./python/py-fuzzer ruff format --check + - run: uv run --directory=./python/py-fuzzer ruff check ecosystem: name: "ecosystem" diff --git a/python/py-fuzzer/fuzz.py b/python/py-fuzzer/fuzz.py index 22f6965244..eacd7587b2 100644 --- a/python/py-fuzzer/fuzz.py +++ b/python/py-fuzzer/fuzz.py @@ -139,7 +139,7 @@ class FuzzResult: case Executable.TY: panic_message = f"The following code triggers a {new}ty panic:" case _ as unreachable: - assert_never(unreachable) + assert_never(unreachable) # ty: ignore[type-assertion-failure] print(colored(panic_message, "red")) print() diff --git a/python/py-fuzzer/pyproject.toml b/python/py-fuzzer/pyproject.toml index 020f91b899..52bc17f25a 100644 --- a/python/py-fuzzer/pyproject.toml +++ b/python/py-fuzzer/pyproject.toml @@ -32,6 +32,10 @@ warn_unreachable = true local_partial_types = true enable_error_code = "ignore-without-code,redundant-expr,truthy-bool" +[tool.ty.rules] +possibly-unresolved-reference = "error" +unused-ignore-comment = "error" + [tool.ruff] fix = true preview = true From 7198e531827a8376531c85080f0c593f9dd1635a Mon Sep 17 00:00:00 2001 From: Bhuminjay Soni Date: Sat, 18 Oct 2025 03:05:48 +0530 Subject: [PATCH 093/113] [syntax-errors] Alternative `match` patterns bind different names (#20682) ## Summary This PR implements semantic syntax error where alternative patterns bind different names ## Test Plan I have written inline tests as directed in #17412 --------- Signed-off-by: 11happy Co-authored-by: Brent Westbrook --- crates/ruff_linter/src/checkers/ast/mod.rs | 1 + .../err/different_match_pattern_bindings.py | 13 + .../ok/different_match_pattern_bindings.py | 6 + .../ruff_python_parser/src/semantic_errors.rs | 57 +- ...x@different_match_pattern_bindings.py.snap | 1233 +++++++++++++++++ ...id_syntax@irrefutable_case_pattern.py.snap | 9 + ...tements__match__star_pattern_usage.py.snap | 22 + ...x@different_match_pattern_bindings.py.snap | 458 ++++++ .../resources/mdtest/import/star.md | 10 +- 9 files changed, 1805 insertions(+), 4 deletions(-) create mode 100644 crates/ruff_python_parser/resources/inline/err/different_match_pattern_bindings.py create mode 100644 crates/ruff_python_parser/resources/inline/ok/different_match_pattern_bindings.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@different_match_pattern_bindings.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@different_match_pattern_bindings.py.snap diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index cdae2bb37d..c6d4a5bf3d 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -723,6 +723,7 @@ impl SemanticSyntaxContext for Checker<'_> { | SemanticSyntaxErrorKind::IrrefutableCasePattern(_) | SemanticSyntaxErrorKind::SingleStarredAssignment | SemanticSyntaxErrorKind::WriteToDebug(_) + | SemanticSyntaxErrorKind::DifferentMatchPatternBindings | SemanticSyntaxErrorKind::InvalidExpression(..) | SemanticSyntaxErrorKind::DuplicateMatchKey(_) | SemanticSyntaxErrorKind::DuplicateMatchClassAttribute(_) diff --git a/crates/ruff_python_parser/resources/inline/err/different_match_pattern_bindings.py b/crates/ruff_python_parser/resources/inline/err/different_match_pattern_bindings.py new file mode 100644 index 0000000000..f489953590 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/different_match_pattern_bindings.py @@ -0,0 +1,13 @@ +match x: + case [a] | [b]: ... + case [a] | []: ... + case (x, y) | (x,): ... + case [a, _] | [a, b]: ... + case (x, (y | z)): ... + case [a] | [b] | [c]: ... + case [] | [a]: ... + case [a] | [C(x)]: ... + case [[a] | [b]]: ... + case [C(a)] | [C(b)]: ... + case [C(D(a))] | [C(D(b))]: ... + case [(a, b)] | [(c, d)]: ... diff --git a/crates/ruff_python_parser/resources/inline/ok/different_match_pattern_bindings.py b/crates/ruff_python_parser/resources/inline/ok/different_match_pattern_bindings.py new file mode 100644 index 0000000000..b7fff1008e --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/different_match_pattern_bindings.py @@ -0,0 +1,6 @@ +match x: + case [a] | [a]: ... + case (x, y) | (x, y): ... + case (x, (y | y)): ... + case [a, _] | [a, _]: ... + case [a] | [C(a)]: ... diff --git a/crates/ruff_python_parser/src/semantic_errors.rs b/crates/ruff_python_parser/src/semantic_errors.rs index 59c5b30b29..ac57426915 100644 --- a/crates/ruff_python_parser/src/semantic_errors.rs +++ b/crates/ruff_python_parser/src/semantic_errors.rs @@ -1137,6 +1137,9 @@ impl Display for SemanticSyntaxError { } SemanticSyntaxErrorKind::BreakOutsideLoop => f.write_str("`break` outside loop"), SemanticSyntaxErrorKind::ContinueOutsideLoop => f.write_str("`continue` outside loop"), + SemanticSyntaxErrorKind::DifferentMatchPatternBindings => { + write!(f, "alternative patterns bind different names") + } } } } @@ -1516,6 +1519,20 @@ pub enum SemanticSyntaxErrorKind { /// Represents the use of a `continue` statement outside of a loop. ContinueOutsideLoop, + + /// Represents the use of alternative patterns in a `match` statement that bind different names. + /// + /// Python requires all alternatives in an OR pattern (`|`) to bind the same set of names. + /// Using different names results in a `SyntaxError`. + /// + /// ## Example: + /// + /// ```python + /// match 5: + /// case [x] | [y]: # error + /// ... + /// ``` + DifferentMatchPatternBindings, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] @@ -1758,7 +1775,9 @@ impl<'a, Ctx: SemanticSyntaxContext> MatchPatternVisitor<'a, Ctx> { self.insert(name); } } - Pattern::MatchOr(ast::PatternMatchOr { patterns, .. }) => { + Pattern::MatchOr(ast::PatternMatchOr { + patterns, range, .. + }) => { // each of these patterns should be visited separately because patterns can only be // duplicated within a single arm of the or pattern. For example, the case below is // a valid pattern. @@ -1766,12 +1785,48 @@ impl<'a, Ctx: SemanticSyntaxContext> MatchPatternVisitor<'a, Ctx> { // test_ok multiple_assignment_in_case_pattern // match 2: // case Class(x) | [x] | x: ... + + let mut previous_names: Option> = None; for pattern in patterns { let mut visitor = Self { names: FxHashSet::default(), ctx: self.ctx, }; visitor.visit_pattern(pattern); + let Some(prev) = &previous_names else { + previous_names = Some(visitor.names); + continue; + }; + if prev.symmetric_difference(&visitor.names).next().is_some() { + // test_err different_match_pattern_bindings + // match x: + // case [a] | [b]: ... + // case [a] | []: ... + // case (x, y) | (x,): ... + // case [a, _] | [a, b]: ... + // case (x, (y | z)): ... + // case [a] | [b] | [c]: ... + // case [] | [a]: ... + // case [a] | [C(x)]: ... + // case [[a] | [b]]: ... + // case [C(a)] | [C(b)]: ... + // case [C(D(a))] | [C(D(b))]: ... + // case [(a, b)] | [(c, d)]: ... + + // test_ok different_match_pattern_bindings + // match x: + // case [a] | [a]: ... + // case (x, y) | (x, y): ... + // case (x, (y | y)): ... + // case [a, _] | [a, _]: ... + // case [a] | [C(a)]: ... + SemanticSyntaxChecker::add_error( + self.ctx, + SemanticSyntaxErrorKind::DifferentMatchPatternBindings, + *range, + ); + break; + } } } } diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@different_match_pattern_bindings.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@different_match_pattern_bindings.py.snap new file mode 100644 index 0000000000..a2ec2aa024 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@different_match_pattern_bindings.py.snap @@ -0,0 +1,1233 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/different_match_pattern_bindings.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..347, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 0..346, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 6..7, + id: Name("x"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 13..32, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 18..27, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 18..21, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 19..20, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 19..20, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 24..27, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 25..26, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 25..26, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 29..32, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 29..32, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 37..55, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 42..50, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 42..45, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 43..44, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 43..44, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 48..50, + node_index: NodeIndex(None), + patterns: [], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 52..55, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 52..55, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 60..83, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 65..78, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 65..71, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 66..67, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 66..67, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 69..70, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("y"), + range: 69..70, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 74..78, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 75..76, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 75..76, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 80..83, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 80..83, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 88..113, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 93..108, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 93..99, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 94..95, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 94..95, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 97..98, + node_index: NodeIndex(None), + pattern: None, + name: None, + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 102..108, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 103..104, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 103..104, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 106..107, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 106..107, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 110..113, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 110..113, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 118..140, + node_index: NodeIndex(None), + pattern: MatchSequence( + PatternMatchSequence { + range: 123..135, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 124..125, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 124..125, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchOr( + PatternMatchOr { + range: 128..133, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 128..129, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("y"), + range: 128..129, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 132..133, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("z"), + range: 132..133, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 137..140, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 137..140, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 145..170, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 150..165, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 150..153, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 151..152, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 151..152, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 156..159, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 157..158, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 157..158, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 162..165, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 163..164, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("c"), + range: 163..164, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 167..170, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 167..170, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 175..193, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 180..188, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 180..182, + node_index: NodeIndex(None), + patterns: [], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 185..188, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 186..187, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 186..187, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 190..193, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 190..193, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 198..220, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 203..215, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 203..206, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 204..205, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 204..205, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 209..215, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 210..214, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 210..211, + id: Name("C"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 211..214, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 212..213, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 212..213, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + keywords: [], + }, + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 217..220, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 217..220, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 225..246, + node_index: NodeIndex(None), + pattern: MatchSequence( + PatternMatchSequence { + range: 230..241, + node_index: NodeIndex(None), + patterns: [ + MatchOr( + PatternMatchOr { + range: 231..240, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 231..234, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 232..233, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 232..233, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 237..240, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 238..239, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 238..239, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 243..246, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 243..246, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 251..276, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 256..271, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 256..262, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 257..261, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 257..258, + id: Name("C"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 258..261, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 259..260, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 259..260, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + keywords: [], + }, + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 265..271, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 266..270, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 266..267, + id: Name("C"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 267..270, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 268..269, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 268..269, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + keywords: [], + }, + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 273..276, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 273..276, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 281..312, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 286..307, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 286..295, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 287..294, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 287..288, + id: Name("C"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 288..294, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 289..293, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 289..290, + id: Name("D"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 290..293, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 291..292, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 291..292, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + keywords: [], + }, + }, + ), + ], + keywords: [], + }, + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 298..307, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 299..306, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 299..300, + id: Name("C"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 300..306, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 301..305, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 301..302, + id: Name("D"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 302..305, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 303..304, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 303..304, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + keywords: [], + }, + }, + ), + ], + keywords: [], + }, + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 309..312, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 309..312, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 317..346, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 322..341, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 322..330, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 323..329, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 324..325, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 324..325, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 327..328, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("b"), + range: 327..328, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 333..341, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 334..340, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 335..336, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("c"), + range: 335..336, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 338..339, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("d"), + range: 338..339, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 343..346, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 343..346, + }, + ), + }, + ), + ], + }, + ], + }, + ), + ], + }, +) +``` +## Semantic Syntax Errors + + | +1 | match x: +2 | case [a] | [b]: ... + | ^^^^^^^^^ Syntax Error: alternative patterns bind different names +3 | case [a] | []: ... +4 | case (x, y) | (x,): ... + | + + + | +1 | match x: +2 | case [a] | [b]: ... +3 | case [a] | []: ... + | ^^^^^^^^ Syntax Error: alternative patterns bind different names +4 | case (x, y) | (x,): ... +5 | case [a, _] | [a, b]: ... + | + + + | +2 | case [a] | [b]: ... +3 | case [a] | []: ... +4 | case (x, y) | (x,): ... + | ^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +5 | case [a, _] | [a, b]: ... +6 | case (x, (y | z)): ... + | + + + | +3 | case [a] | []: ... +4 | case (x, y) | (x,): ... +5 | case [a, _] | [a, b]: ... + | ^^^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +6 | case (x, (y | z)): ... +7 | case [a] | [b] | [c]: ... + | + + + | +4 | case (x, y) | (x,): ... +5 | case [a, _] | [a, b]: ... +6 | case (x, (y | z)): ... + | ^^^^^ Syntax Error: alternative patterns bind different names +7 | case [a] | [b] | [c]: ... +8 | case [] | [a]: ... + | + + + | +5 | case [a, _] | [a, b]: ... +6 | case (x, (y | z)): ... +7 | case [a] | [b] | [c]: ... + | ^^^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +8 | case [] | [a]: ... +9 | case [a] | [C(x)]: ... + | + + + | + 6 | case (x, (y | z)): ... + 7 | case [a] | [b] | [c]: ... + 8 | case [] | [a]: ... + | ^^^^^^^^ Syntax Error: alternative patterns bind different names + 9 | case [a] | [C(x)]: ... +10 | case [[a] | [b]]: ... + | + + + | + 7 | case [a] | [b] | [c]: ... + 8 | case [] | [a]: ... + 9 | case [a] | [C(x)]: ... + | ^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +10 | case [[a] | [b]]: ... +11 | case [C(a)] | [C(b)]: ... + | + + + | + 8 | case [] | [a]: ... + 9 | case [a] | [C(x)]: ... +10 | case [[a] | [b]]: ... + | ^^^^^^^^^ Syntax Error: alternative patterns bind different names +11 | case [C(a)] | [C(b)]: ... +12 | case [C(D(a))] | [C(D(b))]: ... + | + + + | + 9 | case [a] | [C(x)]: ... +10 | case [[a] | [b]]: ... +11 | case [C(a)] | [C(b)]: ... + | ^^^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +12 | case [C(D(a))] | [C(D(b))]: ... +13 | case [(a, b)] | [(c, d)]: ... + | + + + | +10 | case [[a] | [b]]: ... +11 | case [C(a)] | [C(b)]: ... +12 | case [C(D(a))] | [C(D(b))]: ... + | ^^^^^^^^^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +13 | case [(a, b)] | [(c, d)]: ... + | + + + | +11 | case [C(a)] | [C(b)]: ... +12 | case [C(D(a))] | [C(D(b))]: ... +13 | case [(a, b)] | [(c, d)]: ... + | ^^^^^^^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@irrefutable_case_pattern.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@irrefutable_case_pattern.py.snap index 678ef97091..00d25b2f52 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@irrefutable_case_pattern.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@irrefutable_case_pattern.py.snap @@ -427,3 +427,12 @@ Module( | ^^^ Syntax Error: name capture `var` makes remaining patterns unreachable 12 | case 2: ... | + + + | + 9 | case 2: ... +10 | match x: +11 | case enum.variant | var: ... # or pattern with irrefutable part + | ^^^^^^^^^^^^^^^^^^ Syntax Error: alternative patterns bind different names +12 | case 2: ... + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__star_pattern_usage.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__star_pattern_usage.py.snap index 121bc2d2a5..8db3ef5b5b 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__star_pattern_usage.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__star_pattern_usage.py.snap @@ -613,3 +613,25 @@ Module( | ^^ Syntax Error: Star pattern cannot be used here 24 | pass | + + +## Semantic Syntax Errors + + | + 7 | case *foo: + 8 | pass + 9 | case *foo | 1: + | ^^^^^^^^ Syntax Error: alternative patterns bind different names +10 | pass +11 | case 1 | *foo: + | + + + | + 9 | case *foo | 1: +10 | pass +11 | case 1 | *foo: + | ^^^^^^^^ Syntax Error: alternative patterns bind different names +12 | pass +13 | case Foo(*_): + | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@different_match_pattern_bindings.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@different_match_pattern_bindings.py.snap new file mode 100644 index 0000000000..8f7ec2412e --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@different_match_pattern_bindings.py.snap @@ -0,0 +1,458 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/different_match_pattern_bindings.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..147, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 0..146, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 6..7, + id: Name("x"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 13..32, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 18..27, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 18..21, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 19..20, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 19..20, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 24..27, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 25..26, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 25..26, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 29..32, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 29..32, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 37..62, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 42..57, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 42..48, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 43..44, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 43..44, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 46..47, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("y"), + range: 46..47, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 51..57, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 52..53, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 52..53, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 55..56, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("y"), + range: 55..56, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 59..62, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 59..62, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 67..89, + node_index: NodeIndex(None), + pattern: MatchSequence( + PatternMatchSequence { + range: 72..84, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 73..74, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("x"), + range: 73..74, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchOr( + PatternMatchOr { + range: 77..82, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 77..78, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("y"), + range: 77..78, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 81..82, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("y"), + range: 81..82, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 86..89, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 86..89, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 94..119, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 99..114, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 99..105, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 100..101, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 100..101, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 103..104, + node_index: NodeIndex(None), + pattern: None, + name: None, + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 108..114, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 109..110, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 109..110, + node_index: NodeIndex(None), + }, + ), + }, + ), + MatchAs( + PatternMatchAs { + range: 112..113, + node_index: NodeIndex(None), + pattern: None, + name: None, + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 116..119, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 116..119, + }, + ), + }, + ), + ], + }, + MatchCase { + range: 124..146, + node_index: NodeIndex(None), + pattern: MatchOr( + PatternMatchOr { + range: 129..141, + node_index: NodeIndex(None), + patterns: [ + MatchSequence( + PatternMatchSequence { + range: 129..132, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 130..131, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 130..131, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + }, + ), + MatchSequence( + PatternMatchSequence { + range: 135..141, + node_index: NodeIndex(None), + patterns: [ + MatchClass( + PatternMatchClass { + range: 136..140, + node_index: NodeIndex(None), + cls: Name( + ExprName { + node_index: NodeIndex(None), + range: 136..137, + id: Name("C"), + ctx: Load, + }, + ), + arguments: PatternArguments { + range: 137..140, + node_index: NodeIndex(None), + patterns: [ + MatchAs( + PatternMatchAs { + range: 138..139, + node_index: NodeIndex(None), + pattern: None, + name: Some( + Identifier { + id: Name("a"), + range: 138..139, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + keywords: [], + }, + }, + ), + ], + }, + ), + ], + }, + ), + guard: None, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 143..146, + value: EllipsisLiteral( + ExprEllipsisLiteral { + node_index: NodeIndex(None), + range: 143..146, + }, + ), + }, + ), + ], + }, + ], + }, + ), + ], + }, +) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/import/star.md b/crates/ty_python_semantic/resources/mdtest/import/star.md index f944ba2172..adef7f91d3 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/star.md +++ b/crates/ty_python_semantic/resources/mdtest/import/star.md @@ -194,7 +194,7 @@ match get_object(): ... case I(foo=R): ... - case P | Q: + case P | Q: # error: [invalid-syntax] "alternative patterns bind different names" ... match 56: @@ -292,7 +292,9 @@ match 42: ... case [D]: ... - case E | F: # error: [invalid-syntax] "name capture `E` makes remaining patterns unreachable" + # error: [invalid-syntax] "name capture `E` makes remaining patterns unreachable" + # error: [invalid-syntax] "alternative patterns bind different names" + case E | F: ... case object(foo=G): ... @@ -360,7 +362,9 @@ match 42: ... case [D]: ... - case E | F: # error: [invalid-syntax] "name capture `E` makes remaining patterns unreachable" + # error: [invalid-syntax] "name capture `E` makes remaining patterns unreachable" + # error: [invalid-syntax] "alternative patterns bind different names" + case E | F: ... case object(foo=G): ... From 7532155c9bdd5945f5b352e5df3aa39f95849758 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sat, 18 Oct 2025 12:44:21 +0200 Subject: [PATCH 094/113] [ty] Add suggestion to unknown rule diagnostics, rename `unknown-rule` lint to `ignore-comment-unknown-rule` (#20948) --- crates/ty/docs/rules.md | 194 +++++++++--------- crates/ty/tests/cli/rule_selection.rs | 18 +- crates/ty_project/src/metadata/options.rs | 29 +-- .../mdtest/suppressions/ty_ignore.md | 10 +- .../{util/diagnostics.rs => diagnostic.rs} | 26 +++ crates/ty_python_semantic/src/lib.rs | 11 +- crates/ty_python_semantic/src/lint.rs | 40 +++- .../src/{util => }/subscript.rs | 2 +- crates/ty_python_semantic/src/suppression.rs | 38 +--- crates/ty_python_semantic/src/types.rs | 2 +- .../src/types/diagnostic.rs | 29 +-- .../src/types/infer/builder.rs | 4 +- crates/ty_python_semantic/src/types/tuple.rs | 2 +- crates/ty_python_semantic/src/util/mod.rs | 2 - .../e2e__commands__debug_command.snap | 2 +- ty.schema.json | 20 +- 16 files changed, 207 insertions(+), 222 deletions(-) rename crates/ty_python_semantic/src/{util/diagnostics.rs => diagnostic.rs} (86%) rename crates/ty_python_semantic/src/{util => }/subscript.rs (99%) delete mode 100644 crates/ty_python_semantic/src/util/mod.rs diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 7687ea2a7c..858f1f0c7c 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -39,7 +39,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -95,7 +95,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -126,7 +126,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -158,7 +158,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -190,7 +190,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -217,7 +217,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -329,7 +329,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -359,7 +359,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -385,7 +385,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -474,7 +474,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -501,7 +501,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -563,7 +563,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -599,7 +599,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -623,7 +623,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -650,7 +650,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -679,7 +679,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -723,7 +723,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -756,7 +756,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -787,7 +787,7 @@ alice["height"] # KeyError: 'height' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -822,7 +822,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -856,7 +856,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -888,7 +888,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -938,7 +938,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -964,7 +964,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -998,7 +998,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1047,7 +1047,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1072,7 +1072,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1130,7 +1130,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1157,7 +1157,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1187,7 +1187,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1217,7 +1217,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1251,7 +1251,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1285,7 +1285,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1320,7 +1320,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1345,7 +1345,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1378,7 +1378,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1407,7 +1407,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1431,7 +1431,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1457,7 +1457,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1484,7 +1484,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1542,7 +1542,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1572,7 +1572,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1601,7 +1601,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1628,7 +1628,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1656,7 +1656,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1702,7 +1702,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1729,7 +1729,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1757,7 +1757,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1782,7 +1782,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1807,7 +1807,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1844,7 +1844,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1872,7 +1872,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1897,7 +1897,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1938,7 +1938,7 @@ class SubProto(BaseProto, Protocol): Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -1959,6 +1959,37 @@ def old_func(): ... old_func() # emits [deprecated] diagnostic ``` +## `ignore-comment-unknown-rule` + + +Default level: warn · +Added in 0.0.1-alpha.1 · +Related issues · +View source + + + +**What it does** + +Checks for `ty: ignore[code]` where `code` isn't a known lint rule. + +**Why is this bad?** + +A `ty: ignore[code]` directive with a `code` that doesn't match +any known rule will not suppress any type errors, and is probably a mistake. + +**Examples** + +```py +a = 20 / 0 # ty: ignore[division-by-zer] +``` + +Use instead: + +```py +a = 20 / 0 # ty: ignore[division-by-zero] +``` + ## `invalid-ignore-comment` @@ -1995,7 +2026,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2023,7 +2054,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2055,7 +2086,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2087,7 +2118,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2114,7 +2145,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2132,44 +2163,13 @@ Using `reveal_type` without importing it will raise a `NameError` at runtime. reveal_type(1) # NameError: name 'reveal_type' is not defined ``` -## `unknown-rule` - - -Default level: warn · -Added in 0.0.1-alpha.1 · -Related issues · -View source - - - -**What it does** - -Checks for `ty: ignore[code]` where `code` isn't a known lint rule. - -**Why is this bad?** - -A `ty: ignore[code]` directive with a `code` that doesn't match -any known rule will not suppress any type errors, and is probably a mistake. - -**Examples** - -```py -a = 20 / 0 # ty: ignore[division-by-zer] -``` - -Use instead: - -```py -a = 20 / 0 # ty: ignore[division-by-zero] -``` - ## `unresolved-global` Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2227,7 +2227,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2266,7 +2266,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2329,7 +2329,7 @@ def foo(x: int | str) -> int | str: Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -2353,7 +2353,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty/tests/cli/rule_selection.rs b/crates/ty/tests/cli/rule_selection.rs index 8e9ee8e3af..4a4c26b69c 100644 --- a/crates/ty/tests/cli/rule_selection.rs +++ b/crates/ty/tests/cli/rule_selection.rs @@ -250,11 +250,11 @@ fn configuration_unknown_rules() -> anyhow::Result<()> { ("test.py", "print(10)"), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: true exit_code: 0 ----- stdout ----- - warning[unknown-rule]: Unknown lint rule `division-by-zer` + warning[unknown-rule]: Unknown rule `division-by-zer`. Did you mean `division-by-zero`? --> pyproject.toml:3:1 | 2 | [tool.ty.rules] @@ -265,7 +265,7 @@ fn configuration_unknown_rules() -> anyhow::Result<()> { Found 1 diagnostic ----- stderr ----- - "###); + "#); Ok(()) } @@ -275,16 +275,16 @@ fn configuration_unknown_rules() -> anyhow::Result<()> { fn cli_unknown_rules() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", "print(10)")?; - assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r###" + assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r" success: true exit_code: 0 ----- stdout ----- - warning[unknown-rule]: Unknown lint rule `division-by-zer` + warning[unknown-rule]: Unknown rule `division-by-zer`. Did you mean `division-by-zero`? Found 1 diagnostic ----- stderr ----- - "###); + "); Ok(()) } @@ -852,7 +852,7 @@ fn overrides_unknown_rules() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- @@ -864,7 +864,7 @@ fn overrides_unknown_rules() -> anyhow::Result<()> { | info: rule `division-by-zero` was selected in the configuration file - warning[unknown-rule]: Unknown lint rule `division-by-zer` + warning[unknown-rule]: Unknown rule `division-by-zer`. Did you mean `division-by-zero`? --> pyproject.toml:10:1 | 8 | [tool.ty.overrides.rules] @@ -884,7 +884,7 @@ fn overrides_unknown_rules() -> anyhow::Result<()> { Found 3 diagnostics ----- stderr ----- - "###); + "#); Ok(()) } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 12b76affc1..08a1582f93 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -28,7 +28,7 @@ use std::ops::Deref; use std::sync::Arc; use thiserror::Error; use ty_combine::Combine; -use ty_python_semantic::lint::{GetLintError, Level, LintSource, RuleSelection}; +use ty_python_semantic::lint::{Level, LintSource, RuleSelection}; use ty_python_semantic::{ ProgramSettings, PythonEnvironment, PythonPlatform, PythonVersionFileSource, PythonVersionSource, PythonVersionWithSource, SearchPathSettings, SearchPathValidationError, @@ -840,28 +840,11 @@ impl Rules { .and_then(|path| system_path_to_file(db, path).ok()); // TODO: Add a note if the value was configured on the CLI - let diagnostic = match error { - GetLintError::Unknown(_) => OptionDiagnostic::new( - DiagnosticId::UnknownRule, - format!("Unknown lint rule `{rule_name}`"), - Severity::Warning, - ), - GetLintError::PrefixedWithCategory { suggestion, .. } => { - OptionDiagnostic::new( - DiagnosticId::UnknownRule, - format!( - "Unknown lint rule `{rule_name}`. Did you mean `{suggestion}`?" - ), - Severity::Warning, - ) - } - - GetLintError::Removed(_) => OptionDiagnostic::new( - DiagnosticId::UnknownRule, - format!("Unknown lint rule `{rule_name}`"), - Severity::Warning, - ), - }; + let diagnostic = OptionDiagnostic::new( + DiagnosticId::UnknownRule, + error.to_string(), + Severity::Warning, + ); let annotation = file.map(Span::from).map(|span| { Annotation::primary(span.with_optional_range(rule_name.range())) diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md index 9a1930f015..765d4f9e65 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md @@ -88,7 +88,7 @@ def test($): # ty: ignore ```py a = 10 # revealed: Literal[10] -# error: [unknown-rule] "Unknown rule `revealed-type`" +# error: [ignore-comment-unknown-rule] "Unknown rule `revealed-type`" reveal_type(a) # ty: ignore[revealed-type] ``` @@ -127,7 +127,7 @@ a = 10 / 0 # ty: ignore[*-*] ```py -a = 10 / 0 # ty: ignore[division-by-zero] +a = 10 / 0 # ty: ignore[division-by-zero] # ^^^^^^ trailing whitespace ``` @@ -178,14 +178,14 @@ a = 4 / 0 # error: [division-by-zero] ## Unknown rule ```py -# error: [unknown-rule] "Unknown rule `is-equal-14`" -a = 10 + 4 # ty: ignore[is-equal-14] +# error: [ignore-comment-unknown-rule] "Unknown rule `division-by-zer`. Did you mean `division-by-zero`?" +a = 10 + 4 # ty: ignore[division-by-zer] ``` ## Code with `lint:` prefix ```py -# error:[unknown-rule] "Unknown rule `lint:division-by-zero`. Did you mean `division-by-zero`?" +# error:[ignore-comment-unknown-rule] "Unknown rule `lint:division-by-zero`. Did you mean `division-by-zero`?" # error: [division-by-zero] a = 10 / 0 # ty: ignore[lint:division-by-zero] ``` diff --git a/crates/ty_python_semantic/src/util/diagnostics.rs b/crates/ty_python_semantic/src/diagnostic.rs similarity index 86% rename from crates/ty_python_semantic/src/util/diagnostics.rs rename to crates/ty_python_semantic/src/diagnostic.rs index 82ce6b1c3a..5936d0874d 100644 --- a/crates/ty_python_semantic/src/util/diagnostics.rs +++ b/crates/ty_python_semantic/src/diagnostic.rs @@ -1,3 +1,29 @@ +/// Suggest a name from `existing_names` that is similar to `wrong_name`. +pub(crate) fn did_you_mean, T: AsRef>( + existing_names: impl Iterator, + wrong_name: T, +) -> Option { + if wrong_name.as_ref().len() < 3 { + return None; + } + + existing_names + .filter(|ref id| id.as_ref().len() >= 2) + .map(|ref id| { + ( + id.as_ref().to_string(), + strsim::damerau_levenshtein( + &id.as_ref().to_lowercase(), + &wrong_name.as_ref().to_lowercase(), + ), + ) + }) + .min_by_key(|(_, dist)| *dist) + // Heuristic to filter out bad matches + .filter(|(_, dist)| *dist <= 3) + .map(|(id, _)| id) +} + use crate::{Db, Program, PythonVersionWithSource}; use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity}; use std::fmt::Write; diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index dbe07aa600..5f41200522 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -5,8 +5,11 @@ use std::hash::BuildHasherDefault; use crate::lint::{LintRegistry, LintRegistryBuilder}; -use crate::suppression::{INVALID_IGNORE_COMMENT, UNKNOWN_RULE, UNUSED_IGNORE_COMMENT}; +use crate::suppression::{ + IGNORE_COMMENT_UNKNOWN_RULE, INVALID_IGNORE_COMMENT, UNUSED_IGNORE_COMMENT, +}; pub use db::Db; +pub use diagnostic::add_inferred_python_version_hint_to_diagnostic; pub use module_name::{ModuleName, ModuleNameResolutionError}; pub use module_resolver::{ Module, SearchPath, SearchPathValidationError, SearchPaths, all_modules, list_modules, @@ -27,7 +30,6 @@ pub use types::ide_support::{ ImportAliasResolution, ResolvedDefinition, definitions_for_attribute, definitions_for_imported_symbol, definitions_for_name, map_stub_definition, }; -pub use util::diagnostics::add_inferred_python_version_hint_to_diagnostic; pub mod ast_node_ref; mod db; @@ -44,11 +46,12 @@ mod rank; pub mod semantic_index; mod semantic_model; pub(crate) mod site_packages; +mod subscript; mod suppression; pub mod types; mod unpack; -mod util; +mod diagnostic; #[cfg(feature = "testing")] pub mod pull_types; @@ -72,6 +75,6 @@ pub fn default_lint_registry() -> &'static LintRegistry { pub fn register_lints(registry: &mut LintRegistryBuilder) { types::register_lints(registry); registry.register_lint(&UNUSED_IGNORE_COMMENT); - registry.register_lint(&UNKNOWN_RULE); + registry.register_lint(&IGNORE_COMMENT_UNKNOWN_RULE); registry.register_lint(&INVALID_IGNORE_COMMENT); } diff --git a/crates/ty_python_semantic/src/lint.rs b/crates/ty_python_semantic/src/lint.rs index 6432d56956..0eccfac326 100644 --- a/crates/ty_python_semantic/src/lint.rs +++ b/crates/ty_python_semantic/src/lint.rs @@ -1,10 +1,11 @@ +use crate::diagnostic::did_you_mean; use core::fmt; use itertools::Itertools; use ruff_db::diagnostic::{DiagnosticId, LintName, Severity}; use rustc_hash::FxHashMap; +use std::error::Error; use std::fmt::Formatter; use std::hash::Hasher; -use thiserror::Error; #[derive(Debug, Clone)] pub struct LintMetadata { @@ -380,7 +381,12 @@ impl LintRegistry { } } - Err(GetLintError::Unknown(code.to_string())) + let suggestion = did_you_mean(self.by_name.keys(), code); + + Err(GetLintError::Unknown { + code: code.to_string(), + suggestion, + }) } } } @@ -415,25 +421,45 @@ impl LintRegistry { } } -#[derive(Error, Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub enum GetLintError { /// The name maps to this removed lint. - #[error("lint `{0}` has been removed")] Removed(LintName), /// No lint with the given name is known. - #[error("unknown lint `{0}`")] - Unknown(String), + Unknown { + code: String, + suggestion: Option, + }, /// The name uses the full qualified diagnostic id `lint:` instead of just `rule`. /// The String is the name without the `lint:` category prefix. - #[error("unknown lint `{prefixed}`. Did you mean `{suggestion}`?")] PrefixedWithCategory { prefixed: String, suggestion: String, }, } +impl Error for GetLintError {} + +impl std::fmt::Display for GetLintError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + GetLintError::Removed(code) => write!(f, "Removed rule `{code}`"), + GetLintError::Unknown { code, suggestion } => match suggestion { + None => write!(f, "Unknown rule `{code}`"), + Some(suggestion) => { + write!(f, "Unknown rule `{code}`. Did you mean `{suggestion}`?") + } + }, + GetLintError::PrefixedWithCategory { + prefixed, + suggestion, + } => write!(f, "Unknown rule `{prefixed}`. Did you mean `{suggestion}`?"), + } + } +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum LintEntry { /// An existing lint rule. Can be in preview, stable or deprecated. diff --git a/crates/ty_python_semantic/src/util/subscript.rs b/crates/ty_python_semantic/src/subscript.rs similarity index 99% rename from crates/ty_python_semantic/src/util/subscript.rs rename to crates/ty_python_semantic/src/subscript.rs index f519acffdf..b7ea13db10 100644 --- a/crates/ty_python_semantic/src/util/subscript.rs +++ b/crates/ty_python_semantic/src/subscript.rs @@ -208,7 +208,7 @@ where mod tests { use crate::Db; use crate::db::tests::setup_db; - use crate::util::subscript::{OutOfBoundsError, StepSizeZeroError}; + use crate::subscript::{OutOfBoundsError, StepSizeZeroError}; use super::{PyIndex, PySlice}; use itertools::{Itertools, assert_equal}; diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index cf33190c82..6c9edbec68 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -1,7 +1,7 @@ use crate::lint::{GetLintError, Level, LintMetadata, LintRegistry, LintStatus}; use crate::types::TypeCheckDiagnostics; use crate::{Db, declare_lint, lint::LintId}; -use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Span}; +use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, Span}; use ruff_db::{files::File, parsed::parsed_module, source::source_text}; use ruff_python_parser::TokenKind; use ruff_python_trivia::Cursor; @@ -55,7 +55,7 @@ declare_lint! { /// ```py /// a = 20 / 0 # ty: ignore[division-by-zero] /// ``` - pub(crate) static UNKNOWN_RULE = { + pub(crate) static IGNORE_COMMENT_UNKNOWN_RULE = { summary: "detects `ty: ignore` comments that reference unknown rules", status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Warn, @@ -143,38 +143,12 @@ pub(crate) fn check_suppressions(db: &dyn Db, file: File, diagnostics: &mut Type /// Checks for `ty: ignore` comments that reference unknown rules. fn check_unknown_rule(context: &mut CheckSuppressionsContext) { - if context.is_lint_disabled(&UNKNOWN_RULE) { + if context.is_lint_disabled(&IGNORE_COMMENT_UNKNOWN_RULE) { return; } for unknown in &context.suppressions.unknown { - match &unknown.reason { - GetLintError::Removed(removed) => { - context.report_lint( - &UNKNOWN_RULE, - unknown.range, - format_args!("Removed rule `{removed}`"), - ); - } - GetLintError::Unknown(rule) => { - context.report_lint( - &UNKNOWN_RULE, - unknown.range, - format_args!("Unknown rule `{rule}`"), - ); - } - - GetLintError::PrefixedWithCategory { - prefixed, - suggestion, - } => { - context.report_lint( - &UNKNOWN_RULE, - unknown.range, - format_args!("Unknown rule `{prefixed}`. Did you mean `{suggestion}`?"), - ); - } - } + context.report_lint(&IGNORE_COMMENT_UNKNOWN_RULE, unknown.range, &unknown.reason); } } @@ -300,7 +274,7 @@ impl<'a> CheckSuppressionsContext<'a> { &mut self, lint: &'static LintMetadata, range: TextRange, - message: fmt::Arguments, + message: impl IntoDiagnosticMessage, ) { if let Some(suppression) = self.suppressions.find_suppression(range, LintId::of(lint)) { self.diagnostics.mark_used(suppression.id()); @@ -316,7 +290,7 @@ impl<'a> CheckSuppressionsContext<'a> { &mut self, lint: &'static LintMetadata, range: TextRange, - message: fmt::Arguments, + message: impl IntoDiagnosticMessage, ) { let Some(severity) = self.db.rule_selection(self.file).severity(LintId::of(lint)) else { return; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 477bf9aa11..6976d6dc4a 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -30,6 +30,7 @@ pub(crate) use self::infer::{ }; pub(crate) use self::signatures::{CallableSignature, Parameter, Parameters, Signature}; pub(crate) use self::subclass_of::{SubclassOfInner, SubclassOfType}; +pub use crate::diagnostic::add_inferred_python_version_hint_to_diagnostic; use crate::module_name::ModuleName; use crate::module_resolver::{KnownModule, resolve_module}; use crate::place::{ @@ -69,7 +70,6 @@ pub(crate) use crate::types::typed_dict::{TypedDictParams, TypedDictType, walk_t use crate::types::variance::{TypeVarVariance, VarianceInferable}; use crate::types::visitor::any_over_type; use crate::unpack::EvaluationMode; -pub use crate::util::diagnostics::add_inferred_python_version_hint_to_diagnostic; use crate::{Db, FxOrderSet, Module, Program}; pub(crate) use class::{ClassLiteral, ClassType, GenericAlias, KnownClass}; use instance::Protocol; diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 8c836c0038..8046297079 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -5,6 +5,8 @@ use super::{ CallArguments, CallDunderError, ClassBase, ClassLiteral, KnownClass, add_inferred_python_version_hint_to_diagnostic, }; +use crate::diagnostic::did_you_mean; +use crate::diagnostic::format_enumeration; use crate::lint::{Level, LintRegistryBuilder, LintStatus}; use crate::semantic_index::definition::{Definition, DefinitionKind}; use crate::semantic_index::place::{PlaceTable, ScopedPlaceId}; @@ -23,7 +25,6 @@ use crate::types::{ ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type, infer_isolated_expression, protocol_class::ProtocolClass, }; -use crate::util::diagnostics::format_enumeration; use crate::{ Db, DisplaySettings, FxIndexMap, FxOrderMap, Module, ModuleName, Program, declare_lint, }; @@ -3190,29 +3191,3 @@ pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions( &format!("accessing `{}`", attr.id), ); } - -/// Suggest a name from `existing_names` that is similar to `wrong_name`. -fn did_you_mean, T: AsRef>( - existing_names: impl Iterator, - wrong_name: T, -) -> Option { - if wrong_name.as_ref().len() < 3 { - return None; - } - - existing_names - .filter(|ref id| id.as_ref().len() >= 2) - .map(|ref id| { - ( - id.as_ref().to_string(), - strsim::damerau_levenshtein( - &id.as_ref().to_lowercase(), - &wrong_name.as_ref().to_lowercase(), - ), - ) - }) - .min_by_key(|(_, dist)| *dist) - // Heuristic to filter out bad matches - .filter(|(_, dist)| *dist <= 3) - .map(|(id, _)| id) -} diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 2dee39100e..f0fd9ec502 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -17,6 +17,7 @@ use super::{ infer_deferred_types, infer_definition_types, infer_expression_types, infer_same_file_expression_type, infer_scope_types, infer_unpack_types, }; +use crate::diagnostic::format_enumeration; use crate::module_name::{ModuleName, ModuleNameResolutionError}; use crate::module_resolver::{ KnownModule, ModuleResolveMode, file_to_module, resolve_module, search_paths, @@ -45,6 +46,7 @@ use crate::semantic_index::symbol::{ScopedSymbolId, Symbol}; use crate::semantic_index::{ ApplicableConstraints, EnclosingSnapshotResult, SemanticIndex, place_table, }; +use crate::subscript::{PyIndex, PySlice}; use crate::types::call::bind::MatchingOverloadIndex; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::class::{CodeGeneratorKind, FieldKind, MetaclassErrorKind, MethodDecorator}; @@ -104,8 +106,6 @@ use crate::types::{ }; use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic}; use crate::unpack::{EvaluationMode, UnpackPosition}; -use crate::util::diagnostics::format_enumeration; -use crate::util::subscript::{PyIndex, PySlice}; use crate::{Db, FxOrderSet, Program}; mod annotation_expression; diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index dcb3df675d..f091c99ea5 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -22,6 +22,7 @@ use std::hash::Hash; use itertools::{Either, EitherOrBoth, Itertools}; use crate::semantic_index::definition::Definition; +use crate::subscript::{Nth, OutOfBoundsError, PyIndex, PySlice, StepSizeZeroError}; use crate::types::class::{ClassType, KnownClass}; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::generics::InferableTypeVars; @@ -31,7 +32,6 @@ use crate::types::{ UnionBuilder, UnionType, }; use crate::types::{Truthiness, TypeContext}; -use crate::util::subscript::{Nth, OutOfBoundsError, PyIndex, PySlice, StepSizeZeroError}; use crate::{Db, FxOrderSet, Program}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/ty_python_semantic/src/util/mod.rs b/crates/ty_python_semantic/src/util/mod.rs deleted file mode 100644 index 54555cd617..0000000000 --- a/crates/ty_python_semantic/src/util/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod diagnostics; -pub(crate) mod subscript; diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index 0a9007459d..ba3b75028c 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -40,6 +40,7 @@ Settings: Settings { "duplicate-kw-only": Error (Default), "escape-character-in-forward-annotation": Error (Default), "fstring-type-annotation": Error (Default), + "ignore-comment-unknown-rule": Warning (Default), "implicit-concatenated-string-type-annotation": Error (Default), "inconsistent-mro": Error (Default), "index-out-of-bounds": Error (Default), @@ -90,7 +91,6 @@ Settings: Settings { "unavailable-implicit-super-arguments": Error (Default), "undefined-reveal": Warning (Default), "unknown-argument": Error (Default), - "unknown-rule": Warning (Default), "unresolved-attribute": Error (Default), "unresolved-global": Warning (Default), "unresolved-import": Error (Default), diff --git a/ty.schema.json b/ty.schema.json index 5e3323b517..55cb190bb8 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -415,6 +415,16 @@ } ] }, + "ignore-comment-unknown-rule": { + "title": "detects `ty: ignore` comments that reference unknown rules", + "description": "## What it does\nChecks for `ty: ignore[code]` where `code` isn't a known lint rule.\n\n## Why is this bad?\nA `ty: ignore[code]` directive with a `code` that doesn't match\nany known rule will not suppress any type errors, and is probably a mistake.\n\n## Examples\n```py\na = 20 / 0 # ty: ignore[division-by-zer]\n```\n\nUse instead:\n\n```py\na = 20 / 0 # ty: ignore[division-by-zero]\n```", + "default": "warn", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "implicit-concatenated-string-type-annotation": { "title": "detects implicit concatenated strings in type annotations", "description": "## What it does\nChecks for implicit concatenated strings in type annotation positions.\n\n## Why is this bad?\nStatic analysis tools like ty can't analyze type annotations that use implicit concatenated strings.\n\n## Examples\n```python\ndef test(): -> \"Literal[\" \"5\" \"]\":\n ...\n```\n\nUse instead:\n```python\ndef test(): -> \"Literal[5]\":\n ...\n```", @@ -925,16 +935,6 @@ } ] }, - "unknown-rule": { - "title": "detects `ty: ignore` comments that reference unknown rules", - "description": "## What it does\nChecks for `ty: ignore[code]` where `code` isn't a known lint rule.\n\n## Why is this bad?\nA `ty: ignore[code]` directive with a `code` that doesn't match\nany known rule will not suppress any type errors, and is probably a mistake.\n\n## Examples\n```py\na = 20 / 0 # ty: ignore[division-by-zer]\n```\n\nUse instead:\n\n```py\na = 20 / 0 # ty: ignore[division-by-zero]\n```", - "default": "warn", - "oneOf": [ - { - "$ref": "#/definitions/Level" - } - ] - }, "unresolved-attribute": { "title": "detects references to unresolved attributes", "description": "## What it does\nChecks for unresolved attributes.\n\n## Why is this bad?\nAccessing an unbound attribute will raise an `AttributeError` at runtime.\nAn unresolved attribute is not guaranteed to exist from the type alone,\nso this could also indicate that the object is not of the type that the user expects.\n\n## Examples\n```python\nclass A: ...\n\nA().foo # AttributeError: 'A' object has no attribute 'foo'\n```", From 16efe53a72c22f631a312a51f8b152069ffe23cf Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 18 Oct 2025 14:02:55 +0100 Subject: [PATCH 095/113] [ty] Fix panic on recursive class definitions in a stub that use constrained type variables (#20955) --- .../mdtest/generics/legacy/classes.md | 24 +++++++++++++++++++ .../mdtest/generics/pep695/classes.md | 19 +++++++++++++++ crates/ty_python_semantic/src/types.rs | 16 +++++++++---- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md index 184a7b49a5..c520b7e883 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md @@ -753,5 +753,29 @@ class C(C, Generic[T]): ... class D(D[int], Generic[T]): ... ``` +### Cyclic inheritance in a stub file combined with constrained type variables + +This is a regression test for ; we used to panic on +this: + +`stub.pyi`: + +```pyi +from typing import Generic, TypeVar + +class A(B): ... +class G: ... + +T = TypeVar("T", G, A) + +class C(Generic[T]): ... +class B(C[A]): ... +class D(C[G]): ... + +def func(x: D): ... + +func(G()) # error: [invalid-argument-type] +``` + [crtp]: https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern [f-bound]: https://en.wikipedia.org/wiki/Bounded_quantification#F-bounded_quantification diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md index 13ed24c7f4..1f52d16d9a 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md @@ -638,6 +638,25 @@ class C[T](C): ... class D[T](D[int]): ... ``` +### Cyclic inheritance in a stub file combined with constrained type variables + +This is a regression test for ; we used to panic on +this: + +`stub.pyi`: + +```pyi +class A(B): ... +class G: ... +class C[T: (G, A)]: ... +class B(C[A]): ... +class D(C[G]): ... + +def func(x: D): ... + +func(G()) # error: [invalid-argument-type] +``` + ## Tuple as a PEP-695 generic class Our special handling for `tuple` does not break if `tuple` is defined as a PEP-695 generic class in diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 6976d6dc4a..be545c4e59 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -8303,7 +8303,11 @@ impl<'db> TypeVarInstance<'db> { )) } - #[salsa::tracked(cycle_fn=lazy_bound_cycle_recover, cycle_initial=lazy_bound_cycle_initial, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked( + cycle_fn=lazy_bound_or_constraints_cycle_recover, + cycle_initial=lazy_bound_or_constraints_cycle_initial, + heap_size=ruff_memory_usage::heap_size + )] fn lazy_bound(self, db: &'db dyn Db) -> Option> { let definition = self.definition(db)?; let module = parsed_module(db, definition.file(db)).load(db); @@ -8324,7 +8328,11 @@ impl<'db> TypeVarInstance<'db> { Some(TypeVarBoundOrConstraints::UpperBound(ty)) } - #[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked( + cycle_fn=lazy_bound_or_constraints_cycle_recover, + cycle_initial=lazy_bound_or_constraints_cycle_initial, + heap_size=ruff_memory_usage::heap_size + )] fn lazy_constraints(self, db: &'db dyn Db) -> Option> { let definition = self.definition(db)?; let module = parsed_module(db, definition.file(db)).load(db); @@ -8385,7 +8393,7 @@ impl<'db> TypeVarInstance<'db> { } #[allow(clippy::ref_option)] -fn lazy_bound_cycle_recover<'db>( +fn lazy_bound_or_constraints_cycle_recover<'db>( _db: &'db dyn Db, _value: &Option>, _count: u32, @@ -8394,7 +8402,7 @@ fn lazy_bound_cycle_recover<'db>( salsa::CycleRecoveryAction::Iterate } -fn lazy_bound_cycle_initial<'db>( +fn lazy_bound_or_constraints_cycle_initial<'db>( _db: &'db dyn Db, _self: TypeVarInstance<'db>, ) -> Option> { From 68c1fa86c8d18801f3cfc943d6f16a78e745dda2 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 18 Oct 2025 15:01:46 +0100 Subject: [PATCH 096/113] [ty] Fix panic when attempting to validate the members of a protocol that inherits from a protocol in another module (#20956) --- .../resources/mdtest/protocols.md | 27 +++++++++++++ ...gnostics_for_prot…_(585a3e9545d41b64).snap | 40 ++++++++++++++----- .../src/types/infer/builder.rs | 2 +- .../src/types/protocol_class.rs | 11 ++--- 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 5a939e4880..2818de99e4 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -1114,6 +1114,8 @@ it's a large section). +`a.py`: + ```py from typing import Protocol @@ -1140,6 +1142,31 @@ class A(Protocol): pass ``` +Validation of protocols that had cross-module inheritance used to break, so we test that explicitly +here too: + +`b.py`: + +```py +from typing import Protocol + +# Ensure the number of scopes in `b.py` is greater than the number of scopes in `c.py`: +class SomethingUnrelated: ... + +class A(Protocol): + x: int +``` + +`c.py`: + +```py +from b import A +from typing import Protocol + +class C(A, Protocol): + x = 42 # fine, due to declaration in the base class +``` + ## Equivalence of protocols ```toml diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Diagnostics_for_prot…_(585a3e9545d41b64).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Diagnostics_for_prot…_(585a3e9545d41b64).snap index 425665b486..6609a48adb 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Diagnostics_for_prot…_(585a3e9545d41b64).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Diagnostics_for_prot…_(585a3e9545d41b64).snap @@ -9,7 +9,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md # Python source files -## mdtest_snippet.py +## a.py ``` 1 | from typing import Protocol @@ -37,11 +37,33 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md 23 | pass ``` +## b.py + +``` +1 | from typing import Protocol +2 | +3 | # Ensure the number of scopes in `b.py` is greater than the number of scopes in `c.py`: +4 | class SomethingUnrelated: ... +5 | +6 | class A(Protocol): +7 | x: int +``` + +## c.py + +``` +1 | from b import A +2 | from typing import Protocol +3 | +4 | class C(A, Protocol): +5 | x = 42 # fine, due to declaration in the base class +``` + # Diagnostics ``` warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the body of a protocol class - --> src/mdtest_snippet.py:12:5 + --> src/a.py:12:5 | 11 | # error: [ambiguous-protocol-member] 12 | a = None # type: int @@ -50,7 +72,7 @@ warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the 14 | b = ... # type: str | info: Assigning to an undeclared variable in a protocol class leads to an ambiguous interface - --> src/mdtest_snippet.py:6:7 + --> src/a.py:6:7 | 4 | return True 5 | @@ -66,7 +88,7 @@ info: rule `ambiguous-protocol-member` is enabled by default ``` warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the body of a protocol class - --> src/mdtest_snippet.py:14:5 + --> src/a.py:14:5 | 12 | a = None # type: int 13 | # error: [ambiguous-protocol-member] @@ -76,7 +98,7 @@ warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the 16 | if coinflip(): | info: Assigning to an undeclared variable in a protocol class leads to an ambiguous interface - --> src/mdtest_snippet.py:6:7 + --> src/a.py:6:7 | 4 | return True 5 | @@ -92,7 +114,7 @@ info: rule `ambiguous-protocol-member` is enabled by default ``` warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the body of a protocol class - --> src/mdtest_snippet.py:17:9 + --> src/a.py:17:9 | 16 | if coinflip(): 17 | c = 1 # error: [ambiguous-protocol-member] @@ -101,7 +123,7 @@ warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the 19 | c = 2 | info: Assigning to an undeclared variable in a protocol class leads to an ambiguous interface - --> src/mdtest_snippet.py:6:7 + --> src/a.py:6:7 | 4 | return True 5 | @@ -117,7 +139,7 @@ info: rule `ambiguous-protocol-member` is enabled by default ``` warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the body of a protocol class - --> src/mdtest_snippet.py:22:9 + --> src/a.py:22:9 | 21 | # error: [ambiguous-protocol-member] 22 | for d in range(42): @@ -125,7 +147,7 @@ warning[ambiguous-protocol-member]: Cannot assign to undeclared variable in the 23 | pass | info: Assigning to an undeclared variable in a protocol class leads to an ambiguous interface - --> src/mdtest_snippet.py:6:7 + --> src/a.py:6:7 | 4 | return True 5 | diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index f0fd9ec502..e0ad80ac06 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -932,7 +932,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } if let Some(protocol) = class.into_protocol_class(self.db()) { - protocol.validate_members(&self.context, self.index); + protocol.validate_members(&self.context); } } } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index eaa88bc793..904d9d97d0 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -10,9 +10,7 @@ use crate::types::TypeContext; use crate::{ Db, FxOrderSet, place::{Definedness, Place, PlaceAndQualifiers, place_from_bindings, place_from_declarations}, - semantic_index::{ - SemanticIndex, definition::Definition, place::ScopedPlaceId, place_table, use_def_map, - }, + semantic_index::{definition::Definition, place::ScopedPlaceId, place_table, use_def_map}, types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, CallableType, ClassBase, ClassLiteral, ClassType, FindLegacyTypeVarsVisitor, HasRelationToVisitor, @@ -77,11 +75,11 @@ impl<'db> ProtocolClass<'db> { /// Iterate through the body of the protocol class. Check that all definitions /// in the protocol class body are either explicitly declared directly in the /// class body, or are declared in a superclass of the protocol class. - pub(super) fn validate_members(self, context: &InferContext, index: &SemanticIndex<'db>) { + pub(super) fn validate_members(self, context: &InferContext) { let db = context.db(); let interface = self.interface(db); let body_scope = self.class_literal(db).0.body_scope(db); - let class_place_table = index.place_table(body_scope.file_scope_id(db)); + let class_place_table = place_table(db, body_scope); for (symbol_id, mut bindings_iterator) in use_def_map(db, body_scope).all_end_of_scope_symbol_bindings() @@ -104,8 +102,7 @@ impl<'db> ProtocolClass<'db> { }; !place_from_declarations( db, - index - .use_def_map(superclass_scope.file_scope_id(db)) + use_def_map(db, superclass_scope) .end_of_scope_declarations(ScopedPlaceId::Symbol(scoped_symbol_id)), ) .into_place_and_conflicting_declarations() From 09306a1d69994abab076d27e73ac0cb5f305906d Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 18 Oct 2025 15:42:39 +0100 Subject: [PATCH 097/113] Run py-fuzzer using Python 3.14 in CI (#20957) --- .github/workflows/ci.yaml | 7 ++++--- .github/workflows/daily_fuzz.yaml | 2 +- python/py-fuzzer/fuzz.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4f0b9b607..664c9deb7e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,7 +22,7 @@ env: CARGO_TERM_COLOR: always RUSTUP_MAX_RETRIES: 10 PACKAGE_NAME: ruff - PYTHON_VERSION: "3.13" + PYTHON_VERSION: "3.14" NEXTEST_PROFILE: ci jobs: @@ -557,7 +557,8 @@ jobs: persist-credentials: false - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: - python-version: ${{ env.PYTHON_VERSION }} + # TODO: figure out why `ruff-ecosystem` crashes on Python 3.14 + python-version: "3.13" - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 name: Download comparison Ruff binary @@ -711,7 +712,7 @@ jobs: --baseline-executable="${PWD}/ty" \ --only-new-bugs \ --bin=ty \ - 0-500 + 0-1000 ) cargo-shear: diff --git a/.github/workflows/daily_fuzz.yaml b/.github/workflows/daily_fuzz.yaml index aeeae1d88c..c299e00fda 100644 --- a/.github/workflows/daily_fuzz.yaml +++ b/.github/workflows/daily_fuzz.yaml @@ -49,7 +49,7 @@ jobs: # shellcheck disable=SC2046 ( uv run \ - --python=3.13 \ + --python=3.14 \ --project=./python/py-fuzzer \ --locked \ fuzz \ diff --git a/python/py-fuzzer/fuzz.py b/python/py-fuzzer/fuzz.py index eacd7587b2..76cc52d2a1 100644 --- a/python/py-fuzzer/fuzz.py +++ b/python/py-fuzzer/fuzz.py @@ -66,7 +66,7 @@ def ruff_contains_bug(code: str, *, ruff_executable: Path) -> bool: "lint.select=[]", "--no-cache", "--target-version", - "py313", + "py314", "--preview", "-", ], From 44d9063058b0dd00281128751fe3e21aee67a8d9 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 18 Oct 2025 18:00:55 +0100 Subject: [PATCH 098/113] Reduce duplicated logic between the macOS and Windows CI jobs (#20958) --- .github/workflows/ci.yaml | 44 ++++++++------------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 664c9deb7e..70d4d5fb1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -334,9 +334,14 @@ jobs: - name: "Run tests" run: cargo insta test --release --all-features --unreferenced reject --test-runner nextest - cargo-test-windows: - name: "cargo test (windows)" - runs-on: ${{ github.repository == 'astral-sh/ruff' && 'depot-windows-2022-16' || 'windows-latest' }} + cargo-test-other: + strategy: + matrix: + platform: + - ${{ github.repository == 'astral-sh/ruff' && 'depot-windows-2022-16' || 'windows-latest' }} + - macos-latest + name: "cargo test (${{ matrix.platform }})" + runs-on: ${{ matrix.platform }} needs: determine_changes if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 @@ -355,37 +360,6 @@ jobs: uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 with: enable-cache: "true" - - name: "Run tests" - env: - # Workaround for . - RUSTUP_WINDOWS_PATH_ADD_BIN: 1 - run: | - cargo nextest run --all-features --profile ci - cargo test --all-features --doc - - cargo-test-macos: - name: "cargo test (macos)" - runs-on: macos-latest - needs: determine_changes - if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} - timeout-minutes: 20 - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 - - name: "Install Rust toolchain" - run: rustup show - - name: "Install mold" - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - - name: "Install cargo nextest" - uses: taiki-e/install-action@522492a8c115f1b6d4d318581f09638e9442547b # v2.62.21 - with: - tool: cargo-nextest - - name: "Install uv" - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 - with: - enable-cache: "true" - name: "Run tests" run: | cargo nextest run --all-features --profile ci @@ -675,7 +649,7 @@ jobs: - determine_changes # Only runs on pull requests, since that is the only we way we can find the base version for comparison. if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && github.event_name == 'pull_request' && (needs.determine_changes.outputs.ty == 'true' || needs.determine_changes.outputs.py-fuzzer == 'true') }} - timeout-minutes: 5 + timeout-minutes: ${{ github.repository == 'astral-sh/ruff' && 5 || 20 }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: From 3c229ae58abbe87523c9a2d3223ebcc67962137b Mon Sep 17 00:00:00 2001 From: David Peter Date: Sat, 18 Oct 2025 20:20:39 +0200 Subject: [PATCH 099/113] [ty] `dataclass_transform`: Support for fields with an `alias` (#20961) ## Summary closes https://github.com/astral-sh/ty/issues/1385 ## Conformance tests Two false positives removed, as expected. ## Test Plan New Markdown tests --- .../mdtest/dataclasses/dataclass_transform.md | 70 ++++++++++++++++--- crates/ty_python_semantic/src/types.rs | 4 ++ .../ty_python_semantic/src/types/call/bind.rs | 9 ++- crates/ty_python_semantic/src/types/class.rs | 19 +++-- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md index f8246c883b..ebdf5073cd 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md @@ -451,7 +451,7 @@ checkers do not seem to support this either. ```py from typing_extensions import dataclass_transform, Any -def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ... @dataclass_transform(field_specifiers=(fancy_field,)) def fancy_model[T](cls: type[T]) -> type[T]: ... @@ -460,7 +460,7 @@ def fancy_model[T](cls: type[T]) -> type[T]: @fancy_model class Person: id: int = fancy_field(init=False) - name: str = fancy_field() + internal_name: str = fancy_field(alias="name") age: int | None = fancy_field(kw_only=True) reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None @@ -468,7 +468,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int -reveal_type(alice.name) # revealed: str +reveal_type(alice.internal_name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` @@ -477,7 +477,7 @@ reveal_type(alice.age) # revealed: int | None ```py from typing_extensions import dataclass_transform, Any -def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ... @dataclass_transform(field_specifiers=(fancy_field,)) class FancyMeta(type): def __new__(cls, name, bases, namespace): @@ -488,7 +488,7 @@ class FancyBase(metaclass=FancyMeta): ... class Person(FancyBase): id: int = fancy_field(init=False) - name: str = fancy_field() + internal_name: str = fancy_field(alias="name") age: int | None = fancy_field(kw_only=True) reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None @@ -496,7 +496,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int -reveal_type(alice.name) # revealed: str +reveal_type(alice.internal_name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` @@ -505,7 +505,7 @@ reveal_type(alice.age) # revealed: int | None ```py from typing_extensions import dataclass_transform, Any -def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ... @dataclass_transform(field_specifiers=(fancy_field,)) class FancyBase: def __init_subclass__(cls): @@ -514,7 +514,7 @@ class FancyBase: class Person(FancyBase): id: int = fancy_field(init=False) - name: str = fancy_field() + internal_name: str = fancy_field(alias="name") age: int | None = fancy_field(kw_only=True) reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None @@ -522,7 +522,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int -reveal_type(alice.name) # revealed: str +reveal_type(alice.internal_name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` @@ -549,6 +549,58 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None Person(name="Alice") ``` +### Support for `alias` + +The `alias` parameter in field specifiers allows providing an alternative name for the parameter in +the synthesized `__init__` method. + +```py +from typing_extensions import dataclass_transform, Any + +def field_with_alias(*, alias: str | None = None, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(field_with_alias,)) +def model[T](cls: type[T]) -> type[T]: + return cls + +@model +class Person: + internal_name: str = field_with_alias(alias="name") + internal_age: int = field_with_alias(alias="age", kw_only=True) +``` + +The synthesized `__init__` method uses the alias names instead of the actual attribute names: + +```py +reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int) -> None +``` + +We can construct instances using the alias names: + +```py +p = Person(name="Alice", age=30) +``` + +Passing the `name` parameter positionally also works: + +```py +p = Person("Alice", age=30) +``` + +But the attributes are still accessed by their actual names: + +```py +reveal_type(p.internal_name) # revealed: str +reveal_type(p.internal_age) # revealed: int +``` + +Trying to use the actual attribute names in the constructor results in an error: + +```py +# error: [unknown-argument] "Argument `internal_age` does not match any known parameter" +# error: [missing-argument] "No argument provided for required parameter `age`" +p = Person(name="Alice", internal_age=30) +``` + ### With overloaded field specifiers ```py diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index be545c4e59..9327a8f0ec 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -8000,6 +8000,9 @@ pub struct FieldInstance<'db> { /// Whether or not this field can only be passed as a keyword argument to `__init__`. pub kw_only: Option, + + /// This name is used to provide an alternative parameter name in the synthesized `__init__` method. + pub alias: Option>, } // The Salsa heap is tracked separately. @@ -8013,6 +8016,7 @@ impl<'db> FieldInstance<'db> { .map(|ty| ty.normalized_impl(db, visitor)), self.init(db), self.kw_only(db), + self.alias(db), ) } } diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b4c00af378..9c4df3c22c 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -618,6 +618,9 @@ impl<'db> Bindings<'db> { let kw_only = overload .parameter_type_by_name("kw_only", true) .unwrap_or(None); + let alias = overload + .parameter_type_by_name("alias", true) + .unwrap_or(None); // `dataclasses.field` and field-specifier functions of commonly used // libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return @@ -650,6 +653,10 @@ impl<'db> Bindings<'db> { None }; + let alias = alias + .and_then(Type::as_string_literal) + .map(|literal| Box::from(literal.value(db))); + // `typeshed` pretends that `dataclasses.field()` returns the type of the // default value directly. At runtime, however, this function returns an // instance of `dataclasses.Field`. We also model it this way and return @@ -658,7 +665,7 @@ impl<'db> Bindings<'db> { // are assignable to `T` if the default type of the field is assignable // to `T`. Otherwise, we would error on `name: str = field(default="")`. overload.set_return_type(Type::KnownInstance(KnownInstanceType::Field( - FieldInstance::new(db, default_ty, init, kw_only), + FieldInstance::new(db, default_ty, init, kw_only, alias), ))); } diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index c2a2ccd823..56df9329ed 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1341,6 +1341,8 @@ pub(crate) enum FieldKind<'db> { init: bool, /// Whether or not this field can only be passed as a keyword argument to `__init__`. kw_only: Option, + /// The name for this field in the `__init__` signature, if specified. + alias: Option>, }, /// `TypedDict` field metadata TypedDict { @@ -2314,14 +2316,15 @@ impl<'db> ClassLiteral<'db> { let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option>| { for (field_name, field) in self.fields(db, specialization, field_policy) { - let (init, mut default_ty, kw_only) = match field.kind { - FieldKind::NamedTuple { default_ty } => (true, default_ty, None), + let (init, mut default_ty, kw_only, alias) = match &field.kind { + FieldKind::NamedTuple { default_ty } => (true, *default_ty, None, None), FieldKind::Dataclass { init, default_ty, kw_only, + alias, .. - } => (init, default_ty, kw_only), + } => (*init, *default_ty, *kw_only, alias.as_ref()), FieldKind::TypedDict { .. } => continue, }; let mut field_ty = field.declared_ty; @@ -2387,10 +2390,13 @@ impl<'db> ClassLiteral<'db> { let is_kw_only = name == "__replace__" || kw_only.unwrap_or(has_dataclass_param(DataclassFlags::KW_ONLY)); + // Use the alias name if provided, otherwise use the field name + let parameter_name = alias.map(Name::new).unwrap_or(field_name); + let mut parameter = if is_kw_only { - Parameter::keyword_only(field_name) + Parameter::keyword_only(parameter_name) } else { - Parameter::positional_or_keyword(field_name) + Parameter::positional_or_keyword(parameter_name) } .with_annotated_type(field_ty); @@ -2925,6 +2931,7 @@ impl<'db> ClassLiteral<'db> { let mut init = true; let mut kw_only = None; + let mut alias = None; if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty { default_ty = field.default_type(db); if self @@ -2938,6 +2945,7 @@ impl<'db> ClassLiteral<'db> { } else { init = field.init(db); kw_only = field.kw_only(db); + alias = field.alias(db); } } @@ -2948,6 +2956,7 @@ impl<'db> ClassLiteral<'db> { init_only: attr.is_init_var(), init, kw_only, + alias, }, CodeGeneratorKind::TypedDict => { let is_required = if attr.is_required() { From b6b96d75eb024a0ab23eeb02b6d6b983f62cb7e4 Mon Sep 17 00:00:00 2001 From: Takayuki Maeda Date: Sun, 19 Oct 2025 18:07:16 +0900 Subject: [PATCH 100/113] [`ruff`] Use DiagnosticTag for more pyflakes and panda rules (#20801) --- crates/ruff_linter/src/checkers/noqa.rs | 2 ++ .../src/rules/pandas_vet/rules/subscript.rs | 11 ++++++----- .../rules/pyflakes/rules/redefined_while_unused.rs | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/ruff_linter/src/checkers/noqa.rs b/crates/ruff_linter/src/checkers/noqa.rs index 926c7dcfb8..7cf58a5def 100644 --- a/crates/ruff_linter/src/checkers/noqa.rs +++ b/crates/ruff_linter/src/checkers/noqa.rs @@ -130,6 +130,7 @@ pub(crate) fn check_noqa( let edit = delete_comment(directive.range(), locator); let mut diagnostic = context .report_diagnostic(UnusedNOQA { codes: None }, directive.range()); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Unnecessary); diagnostic.set_fix(Fix::safe_edit(edit)); } } @@ -226,6 +227,7 @@ pub(crate) fn check_noqa( }, directive.range(), ); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Unnecessary); diagnostic.set_fix(Fix::safe_edit(edit)); } } diff --git a/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs b/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs index 30fc920952..cda65b6554 100644 --- a/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs +++ b/crates/ruff_linter/src/rules/pandas_vet/rules/subscript.rs @@ -165,16 +165,17 @@ pub(crate) fn subscript(checker: &Checker, value: &Expr, expr: &Expr) { match attr.as_str() { // PD007 "ix" if checker.is_rule_enabled(Rule::PandasUseOfDotIx) => { - checker.report_diagnostic(PandasUseOfDotIx, range) + let mut diagnostic = checker.report_diagnostic(PandasUseOfDotIx, range); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated); } // PD008 "at" if checker.is_rule_enabled(Rule::PandasUseOfDotAt) => { - checker.report_diagnostic(PandasUseOfDotAt, range) + checker.report_diagnostic(PandasUseOfDotAt, range); } // PD009 "iat" if checker.is_rule_enabled(Rule::PandasUseOfDotIat) => { - checker.report_diagnostic(PandasUseOfDotIat, range) + checker.report_diagnostic(PandasUseOfDotIat, range); } - _ => return, - }; + _ => (), + } } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs b/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs index 184a88f408..3c13941f6a 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/redefined_while_unused.rs @@ -191,6 +191,7 @@ pub(crate) fn redefined_while_unused(checker: &Checker, scope_id: ScopeId, scope }, binding.range(), ); + diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Unnecessary); diagnostic.secondary_annotation( format_args!("previous definition of `{name}` here"), From 1f8297cfe6f303e47cf82aefe742fb13080b34aa Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Oct 2025 10:58:25 +0100 Subject: [PATCH 101/113] [ty] Improve error messages for unresolved attribute diagnostics (#20963) ## Summary - Type checkers (and type-checker authors) think in terms of types, but I think most Python users think in terms of values. Rather than saying that a _type_ `X` "has no attribute `foo`" (which I think sounds strange to many users), say that "an object of type `X` has no attribute `foo`" - Special-case certain types so that the diagnostic messages read more like normal English: rather than saying "Type `` has no attribute `bar`" or "Object of type `` has no attribute `bar`", just say "Class `Foo` has no attribute `bar`" ## Test Plan Mdtests and snapshots updated --- crates/ty/tests/cli/python_environment.rs | 4 +- crates/ty/tests/file_watching.rs | 4 +- .../resources/mdtest/annotations/callable.md | 2 +- .../resources/mdtest/attributes.md | 12 ++-- .../resources/mdtest/call/methods.md | 2 +- .../resources/mdtest/class/super.md | 8 +-- .../resources/mdtest/descriptor_protocol.md | 8 +-- .../resources/mdtest/expression/attribute.md | 4 +- .../resources/mdtest/import/relative.md | 2 +- .../resources/mdtest/narrow/hasattr.md | 2 +- .../resources/mdtest/scopes/unbound.md | 2 +- ...ossibly-missing_att…_(e603e3da35f55c73).snap | 4 +- ...ributes_of_standa…_(49ba2c9016d64653).snap | 4 +- ...licit_Super_Objec…_(b753048091f275c0).snap | 12 ++-- .../type_properties/is_equivalent_to.md | 2 +- .../resources/mdtest/typed_dict.md | 8 +-- .../src/types/diagnostic.rs | 23 ++++++-- .../ty_python_semantic/src/types/display.rs | 4 ++ .../src/types/infer/builder.rs | 58 +++++++++++++------ 19 files changed, 102 insertions(+), 63 deletions(-) diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 4f3e442473..04fa8be88f 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -30,7 +30,7 @@ fn config_override_python_version() -> anyhow::Result<()> { success: false exit_code: 1 ----- stdout ----- - error[unresolved-attribute]: Type `` has no attribute `last_exc` + error[unresolved-attribute]: Module `sys` has no member `last_exc` --> test.py:5:7 | 4 | # Access `sys.last_exc` that was only added in Python 3.12 @@ -962,7 +962,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { success: false exit_code: 1 ----- stdout ----- - error[unresolved-attribute]: Type `` has no attribute `grantpt` + error[unresolved-attribute]: Module `os` has no member `grantpt` --> main.py:4:1 | 2 | import os diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs index 950cf82a84..5a738b5fd5 100644 --- a/crates/ty/tests/file_watching.rs +++ b/crates/ty/tests/file_watching.rs @@ -1112,11 +1112,11 @@ print(sys.last_exc, os.getegid()) assert_eq!(diagnostics.len(), 2); assert_eq!( diagnostics[0].primary_message(), - "Type `` has no attribute `last_exc`" + "Module `sys` has no member `last_exc`" ); assert_eq!( diagnostics[1].primary_message(), - "Type `` has no attribute `getegid`" + "Module `os` has no member `getegid`" ); // Change the python version diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index 7ffb49bb72..780b2a87db 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -366,7 +366,7 @@ on function-like callables: ```py def f_wrong(c: Callable[[], None]): - # error: [unresolved-attribute] "Type `() -> None` has no attribute `__qualname__`" + # error: [unresolved-attribute] "Object of type `() -> None` has no attribute `__qualname__`" c.__qualname__ # error: [unresolved-attribute] "Unresolved attribute `__qualname__` on type `() -> None`." diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 3c910ee77e..a71a40578b 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -1260,13 +1260,13 @@ def _(flag1: bool, flag2: bool): C = C1 if flag1 else C2 if flag2 else C3 - # error: [possibly-missing-attribute] "Attribute `x` on type ` | | ` may be missing" + # error: [possibly-missing-attribute] "Attribute `x` may be missing on object of type ` | | `" reveal_type(C.x) # revealed: Unknown | Literal[1, 3] # error: [invalid-assignment] "Object of type `Literal[100]` is not assignable to attribute `x` on type ` | | `" C.x = 100 - # error: [possibly-missing-attribute] "Attribute `x` on type `C1 | C2 | C3` may be missing" + # error: [possibly-missing-attribute] "Attribute `x` may be missing on object of type `C1 | C2 | C3`" reveal_type(C().x) # revealed: Unknown | Literal[1, 3] # error: [invalid-assignment] "Object of type `Literal[100]` is not assignable to attribute `x` on type `C1 | C2 | C3`" @@ -1292,7 +1292,7 @@ def _(flag: bool, flag1: bool, flag2: bool): C = C1 if flag1 else C2 if flag2 else C3 - # error: [possibly-missing-attribute] "Attribute `x` on type ` | | ` may be missing" + # error: [possibly-missing-attribute] "Attribute `x` may be missing on object of type ` | | `" reveal_type(C.x) # revealed: Unknown | Literal[1, 2, 3] # error: [possibly-missing-attribute] @@ -1300,7 +1300,7 @@ def _(flag: bool, flag1: bool, flag2: bool): # Note: we might want to consider ignoring possibly-missing diagnostics for instance attributes eventually, # see the "Possibly unbound/undeclared instance attribute" section below. - # error: [possibly-missing-attribute] "Attribute `x` on type `C1 | C2 | C3` may be missing" + # error: [possibly-missing-attribute] "Attribute `x` may be missing on object of type `C1 | C2 | C3`" reveal_type(C().x) # revealed: Unknown | Literal[1, 2, 3] # error: [possibly-missing-attribute] @@ -1433,7 +1433,7 @@ def _(flag: bool): class C2: ... C = C1 if flag else C2 - # error: [unresolved-attribute] "Type ` | ` has no attribute `x`" + # error: [unresolved-attribute] "Object of type ` | ` has no attribute `x`" reveal_type(C.x) # revealed: Unknown # TODO: This should ideally be a `unresolved-attribute` error. We need better union @@ -1771,7 +1771,7 @@ reveal_type(date.day) # revealed: int reveal_type(date.month) # revealed: int reveal_type(date.year) # revealed: int -# error: [unresolved-attribute] "Type `Date` has no attribute `century`" +# error: [unresolved-attribute] "Object of type `Date` has no attribute `century`" reveal_type(date.century) # revealed: Unknown ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/methods.md b/crates/ty_python_semantic/resources/mdtest/call/methods.md index b6547e338d..8179e3272d 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/methods.md +++ b/crates/ty_python_semantic/resources/mdtest/call/methods.md @@ -311,7 +311,7 @@ reveal_type(C.f(1)) # revealed: str The method `f` can not be accessed from an instance of the class: ```py -# error: [unresolved-attribute] "Type `C` has no attribute `f`" +# error: [unresolved-attribute] "Object of type `C` has no attribute `f`" C().f ``` diff --git a/crates/ty_python_semantic/resources/mdtest/class/super.md b/crates/ty_python_semantic/resources/mdtest/class/super.md index 933e43bbd1..1862866764 100644 --- a/crates/ty_python_semantic/resources/mdtest/class/super.md +++ b/crates/ty_python_semantic/resources/mdtest/class/super.md @@ -308,7 +308,7 @@ class B(A): ... reveal_type(super(B)) # revealed: super -# error: [unresolved-attribute] "Type `super` has no attribute `a`" +# error: [unresolved-attribute] "Object of type `super` has no attribute `a`" super(B).a ``` @@ -436,7 +436,7 @@ def f(x: C | D): s = super(A, x) reveal_type(s) # revealed: , C> | , D> - # error: [possibly-missing-attribute] "Attribute `b` on type `, C> | , D>` may be missing" + # error: [possibly-missing-attribute] "Attribute `b` may be missing on object of type `, C> | , D>`" s.b def f(flag: bool): @@ -476,7 +476,7 @@ def f(flag: bool): reveal_type(s.x) # revealed: Unknown | Literal[1, 2] reveal_type(s.y) # revealed: int | str - # error: [possibly-missing-attribute] "Attribute `a` on type `, B> | , D>` may be missing" + # error: [possibly-missing-attribute] "Attribute `a` may be missing on object of type `, B> | , D>`" reveal_type(s.a) # revealed: str ``` @@ -619,7 +619,7 @@ class B(A): # TODO: Once `Self` is supported, this should raise `unresolved-attribute` error super().a -# error: [unresolved-attribute] "Type `, B>` has no attribute `a`" +# error: [unresolved-attribute] "Object of type `, B>` has no attribute `a`" super(B, B(42)).a ``` diff --git a/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md b/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md index 9210c84483..f5e0f254d0 100644 --- a/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md +++ b/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md @@ -758,16 +758,16 @@ def _(flag: bool): non_data: NonDataDescriptor = NonDataDescriptor() data: DataDescriptor = DataDescriptor() - # error: [possibly-missing-attribute] "Attribute `non_data` on type `` may be missing" + # error: [possibly-missing-attribute] "Attribute `non_data` may be missing on class `PossiblyUnbound`" reveal_type(PossiblyUnbound.non_data) # revealed: int - # error: [possibly-missing-attribute] "Attribute `non_data` on type `PossiblyUnbound` may be missing" + # error: [possibly-missing-attribute] "Attribute `non_data` may be missing on object of type `PossiblyUnbound`" reveal_type(PossiblyUnbound().non_data) # revealed: int - # error: [possibly-missing-attribute] "Attribute `data` on type `` may be missing" + # error: [possibly-missing-attribute] "Attribute `data` may be missing on class `PossiblyUnbound`" reveal_type(PossiblyUnbound.data) # revealed: int - # error: [possibly-missing-attribute] "Attribute `data` on type `PossiblyUnbound` may be missing" + # error: [possibly-missing-attribute] "Attribute `data` may be missing on object of type `PossiblyUnbound`" reveal_type(PossiblyUnbound().data) # revealed: int ``` diff --git a/crates/ty_python_semantic/resources/mdtest/expression/attribute.md b/crates/ty_python_semantic/resources/mdtest/expression/attribute.md index f59da0bc48..60c1bc518a 100644 --- a/crates/ty_python_semantic/resources/mdtest/expression/attribute.md +++ b/crates/ty_python_semantic/resources/mdtest/expression/attribute.md @@ -26,9 +26,9 @@ def _(flag: bool): reveal_type(A.union_declared) # revealed: int | str - # error: [possibly-missing-attribute] "Attribute `possibly_unbound` on type `` may be missing" + # error: [possibly-missing-attribute] "Attribute `possibly_unbound` may be missing on class `A`" reveal_type(A.possibly_unbound) # revealed: str - # error: [unresolved-attribute] "Type `` has no attribute `non_existent`" + # error: [unresolved-attribute] "Class `A` has no attribute `non_existent`" reveal_type(A.non_existent) # revealed: Unknown ``` diff --git a/crates/ty_python_semantic/resources/mdtest/import/relative.md b/crates/ty_python_semantic/resources/mdtest/import/relative.md index 6621172404..a80735e460 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/relative.md +++ b/crates/ty_python_semantic/resources/mdtest/import/relative.md @@ -247,7 +247,7 @@ X: int = 42 from . import foo import package -# error: [unresolved-attribute] "Type `` has no attribute `foo`" +# error: [unresolved-attribute] "Module `package` has no member `foo`" reveal_type(package.foo.X) # revealed: Unknown ``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md b/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md index ab38563ee7..633dd83bd2 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md @@ -95,6 +95,6 @@ def f(x: object): reveal_type(x.__str__) # revealed: bound method object.__str__() -> str reveal_type(x.__dict__) # revealed: dict[str, Any] - # error: [unresolved-attribute] "Type `` has no attribute `foo`" + # error: [unresolved-attribute] "Object of type `` has no attribute `foo`" reveal_type(x.foo) # revealed: Unknown ``` diff --git a/crates/ty_python_semantic/resources/mdtest/scopes/unbound.md b/crates/ty_python_semantic/resources/mdtest/scopes/unbound.md index 43c8f5f564..bf1f81d5c2 100644 --- a/crates/ty_python_semantic/resources/mdtest/scopes/unbound.md +++ b/crates/ty_python_semantic/resources/mdtest/scopes/unbound.md @@ -16,7 +16,7 @@ class C: if flag: x = 2 -# error: [possibly-missing-attribute] "Attribute `x` on type `` may be missing" +# error: [possibly-missing-attribute] "Attribute `x` may be missing on class `C`" reveal_type(C.x) # revealed: Unknown | Literal[2] reveal_type(C.y) # revealed: Unknown | Literal[1] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/attribute_assignment…_-_Attribute_assignment_-_Possibly-missing_att…_(e603e3da35f55c73).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/attribute_assignment…_-_Attribute_assignment_-_Possibly-missing_att…_(e603e3da35f55c73).snap index 9335fec6da..420b92e5a7 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/attribute_assignment…_-_Attribute_assignment_-_Possibly-missing_att…_(e603e3da35f55c73).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/attribute_assignment…_-_Attribute_assignment_-_Possibly-missing_att…_(e603e3da35f55c73).snap @@ -26,7 +26,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/attribute_as # Diagnostics ``` -warning[possibly-missing-attribute]: Attribute `attr` on type `` may be missing +warning[possibly-missing-attribute]: Attribute `attr` may be missing on class `C` --> src/mdtest_snippet.py:6:5 | 4 | attr: int = 0 @@ -41,7 +41,7 @@ info: rule `possibly-missing-attribute` is enabled by default ``` ``` -warning[possibly-missing-attribute]: Attribute `attr` on type `C` may be missing +warning[possibly-missing-attribute]: Attribute `attr` may be missing on object of type `C` --> src/mdtest_snippet.py:9:5 | 8 | instance = C() diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap index b5171a701c..a1cd9d301c 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap @@ -23,7 +23,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md # Diagnostics ``` -error[unresolved-attribute]: Type `` has no attribute `UTC` +error[unresolved-attribute]: Module `datetime` has no member `UTC` --> src/main.py:4:13 | 3 | # error: [unresolved-attribute] @@ -38,7 +38,7 @@ info: rule `unresolved-attribute` is enabled by default ``` ``` -error[unresolved-attribute]: Type `` has no attribute `fakenotreal` +error[unresolved-attribute]: Module `datetime` has no member `fakenotreal` --> src/main.py:6:13 | 4 | reveal_type(datetime.UTC) # revealed: Unknown diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap index 014d05eff4..873e98e2dc 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/super.md_-_Super_-_Basic_Usage_-_Explicit_Super_Objec…_(b753048091f275c0).snap @@ -118,7 +118,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/class/super.md # Diagnostics ``` -error[unresolved-attribute]: Type `, C>` has no attribute `c` +error[unresolved-attribute]: Object of type `, C>` has no attribute `c` --> src/mdtest_snippet.py:19:1 | 17 | super(C, C()).a @@ -133,7 +133,7 @@ info: rule `unresolved-attribute` is enabled by default ``` ``` -error[unresolved-attribute]: Type `, C>` has no attribute `b` +error[unresolved-attribute]: Object of type `, C>` has no attribute `b` --> src/mdtest_snippet.py:22:1 | 21 | super(B, C()).a @@ -146,7 +146,7 @@ info: rule `unresolved-attribute` is enabled by default ``` ``` -error[unresolved-attribute]: Type `, C>` has no attribute `c` +error[unresolved-attribute]: Object of type `, C>` has no attribute `c` --> src/mdtest_snippet.py:23:1 | 21 | super(B, C()).a @@ -161,7 +161,7 @@ info: rule `unresolved-attribute` is enabled by default ``` ``` -error[unresolved-attribute]: Type `, C>` has no attribute `a` +error[unresolved-attribute]: Object of type `, C>` has no attribute `a` --> src/mdtest_snippet.py:25:1 | 23 | super(B, C()).c # error: [unresolved-attribute] @@ -176,7 +176,7 @@ info: rule `unresolved-attribute` is enabled by default ``` ``` -error[unresolved-attribute]: Type `, C>` has no attribute `b` +error[unresolved-attribute]: Object of type `, C>` has no attribute `b` --> src/mdtest_snippet.py:26:1 | 25 | super(A, C()).a # error: [unresolved-attribute] @@ -189,7 +189,7 @@ info: rule `unresolved-attribute` is enabled by default ``` ``` -error[unresolved-attribute]: Type `, C>` has no attribute `c` +error[unresolved-attribute]: Object of type `, C>` has no attribute `c` --> src/mdtest_snippet.py:27:1 | 25 | super(A, C()).a # error: [unresolved-attribute] diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md index 624c5884c8..41c7f562bb 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md @@ -618,7 +618,7 @@ import importlib from module2 import importlib as other_importlib from ty_extensions import TypeOf, static_assert, is_equivalent_to -# error: [unresolved-attribute] "Type `` has no attribute `abc`" +# error: [unresolved-attribute] "Module `importlib` has no member `abc`" reveal_type(importlib.abc) # revealed: Unknown reveal_type(other_importlib.abc) # revealed: diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 5cefed9b28..d810a79efe 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -672,18 +672,18 @@ Also, the "attributes" on the class definition can not be accessed. Neither on t on inhabitants of the type defined by the class: ```py -# error: [unresolved-attribute] "Type `` has no attribute `name`" +# error: [unresolved-attribute] "Class `Person` has no attribute `name`" Person.name def _(P: type[Person]): - # error: [unresolved-attribute] "Type `type[Person]` has no attribute `name`" + # error: [unresolved-attribute] "Object of type `type[Person]` has no attribute `name`" P.name def _(p: Person) -> None: - # error: [unresolved-attribute] "Type `Person` has no attribute `name`" + # error: [unresolved-attribute] "Object of type `Person` has no attribute `name`" p.name - type(p).name # error: [unresolved-attribute] "Type `` has no attribute `name`" + type(p).name # error: [unresolved-attribute] "Class `dict[str, object]` has no attribute `name`" ``` ## Special properties diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 8046297079..7db83b9b88 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2269,10 +2269,25 @@ pub(super) fn report_possibly_missing_attribute( let Some(builder) = context.report_lint(&POSSIBLY_MISSING_ATTRIBUTE, target) else { return; }; - builder.into_diagnostic(format_args!( - "Attribute `{attribute}` on type `{}` may be missing", - object_ty.display(context.db()), - )); + let db = context.db(); + match object_ty { + Type::ModuleLiteral(module) => builder.into_diagnostic(format_args!( + "Member `{attribute}` may be missing on module `{}`", + module.module(db).name(db), + )), + Type::ClassLiteral(class) => builder.into_diagnostic(format_args!( + "Attribute `{attribute}` may be missing on class `{}`", + class.name(db), + )), + Type::GenericAlias(alias) => builder.into_diagnostic(format_args!( + "Attribute `{attribute}` may be missing on class `{}`", + alias.display(db), + )), + _ => builder.into_diagnostic(format_args!( + "Attribute `{attribute}` may be missing on object of type `{}`", + object_ty.display(db), + )), + }; } pub(super) fn report_invalid_exception_caught(context: &InferContext, node: &ast::Expr, ty: Type) { diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 5b2f69e811..560943aba1 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -814,6 +814,10 @@ impl Display for DisplayFunctionType<'_> { } impl<'db> GenericAlias<'db> { + pub(crate) fn display(&'db self, db: &'db dyn Db) -> DisplayGenericAlias<'db> { + self.display_with(db, DisplaySettings::default()) + } + pub(crate) fn display_with( &'db self, db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e0ad80ac06..44ed5841f8 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -7618,25 +7618,45 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .context .report_lint(&UNRESOLVED_ATTRIBUTE, attribute) { - if bound_on_instance { - builder.into_diagnostic( - format_args!( - "Attribute `{}` can only be accessed on instances, \ - not on the class object `{}` itself.", - attr.id, - value_type.display(db) - ), - ); - } else { - let diagnostic = builder.into_diagnostic( - format_args!( - "Type `{}` has no attribute `{}`", - value_type.display(db), - attr.id - ), - ); - hint_if_stdlib_attribute_exists_on_other_versions(db, diagnostic, &value_type, attr); - } + if bound_on_instance { + builder.into_diagnostic( + format_args!( + "Attribute `{}` can only be accessed on instances, \ + not on the class object `{}` itself.", + attr.id, + value_type.display(db) + ), + ); + } else { + let diagnostic = match value_type { + Type::ModuleLiteral(module) => builder.into_diagnostic(format_args!( + "Module `{}` has no member `{}`", + module.module(db).name(db), + &attr.id + )), + Type::ClassLiteral(class) => builder.into_diagnostic(format_args!( + "Class `{}` has no attribute `{}`", + class.name(db), + &attr.id + )), + Type::GenericAlias(alias) => builder.into_diagnostic(format_args!( + "Class `{}` has no attribute `{}`", + alias.display(db), + &attr.id + )), + Type::FunctionLiteral(function) => builder.into_diagnostic(format_args!( + "Function `{}` has no attribute `{}`", + function.name(db), + &attr.id + )), + _ => builder.into_diagnostic(format_args!( + "Object of type `{}` has no attribute `{}`", + value_type.display(db), + &attr.id + )), + }; + hint_if_stdlib_attribute_exists_on_other_versions(db, diagnostic, &value_type, attr); + } } } From 36d4b02fa97a7b19572f524980b7f0fbd6d5d8aa Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama <45118249+mtshiba@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:13:10 +0900 Subject: [PATCH 102/113] [ty] fix non-deterministic overload function inference (#20966) --- crates/ty_python_semantic/src/types/infer/builder.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 44ed5841f8..9c6826e71e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -106,7 +106,7 @@ use crate::types::{ }; use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic}; use crate::unpack::{EvaluationMode, UnpackPosition}; -use crate::{Db, FxOrderSet, Program}; +use crate::{Db, FxIndexSet, FxOrderSet, Program}; mod annotation_expression; mod type_expression; @@ -257,8 +257,10 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// return x /// ``` /// + /// To keep the calculation deterministic, we use an `FxIndexSet` whose order is determined by the sequence of insertion calls. + /// /// [`check_overloaded_functions`]: TypeInferenceBuilder::check_overloaded_functions - called_functions: FxHashSet>, + called_functions: FxIndexSet>, /// Whether we are in a context that binds unbound typevars. typevar_binding_context: Option>, @@ -312,7 +314,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { region, scope, return_types_and_ranges: vec![], - called_functions: FxHashSet::default(), + called_functions: FxIndexSet::default(), deferred_state: DeferredExpressionState::None, multi_inference_state: MultiInferenceState::Panic, expressions: FxHashMap::default(), @@ -949,7 +951,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Collect all the unique overloaded function places in this scope. This requires a set // because an overloaded function uses the same place for each of the overloads and the // implementation. - let overloaded_function_places: FxHashSet<_> = self + let overloaded_function_places: FxIndexSet<_> = self .declarations .iter() .filter_map(|(definition, ty)| { @@ -971,7 +973,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .index .use_def_map(self.scope().file_scope_id(self.db())); - let mut public_functions = FxHashSet::default(); + let mut public_functions = FxIndexSet::default(); for place in overloaded_function_places { if let Place::Defined(Type::FunctionLiteral(function), _, Definedness::AlwaysDefined) = From 0b8de723c6cfca90f1a1a1e4512b78c549652eb5 Mon Sep 17 00:00:00 2001 From: Renkai Ge Date: Sun, 19 Oct 2025 18:25:21 +0800 Subject: [PATCH 103/113] Refactor format tests to use CliTest helper (#20953) Co-authored-by: Micha Reiser --- crates/ruff/tests/{ => cli}/format.rs | 1244 ++++++++--------- crates/ruff/tests/cli/main.rs | 17 + .../cli__format__output_format_azure.snap} | 2 +- .../cli__format__output_format_concise.snap} | 2 +- .../cli__format__output_format_full.snap} | 2 +- .../cli__format__output_format_github.snap} | 2 +- .../cli__format__output_format_gitlab.snap} | 2 +- .../cli__format__output_format_grouped.snap} | 3 +- ...li__format__output_format_json-lines.snap} | 2 +- .../cli__format__output_format_json.snap} | 2 +- .../cli__format__output_format_junit.snap} | 2 +- .../cli__format__output_format_pylint.snap} | 3 +- .../cli__format__output_format_rdjson.snap} | 2 +- .../cli__format__output_format_sarif.snap} | 2 +- 14 files changed, 606 insertions(+), 681 deletions(-) rename crates/ruff/tests/{ => cli}/format.rs (65%) rename crates/ruff/tests/{snapshots/format__output_format_azure.snap => cli/snapshots/cli__format__output_format_azure.snap} (89%) rename crates/ruff/tests/{snapshots/format__output_format_concise.snap => cli/snapshots/cli__format__output_format_concise.snap} (88%) rename crates/ruff/tests/{snapshots/format__output_format_full.snap => cli/snapshots/cli__format__output_format_full.snap} (90%) rename crates/ruff/tests/{snapshots/format__output_format_github.snap => cli/snapshots/cli__format__output_format_github.snap} (90%) rename crates/ruff/tests/{snapshots/format__output_format_gitlab.snap => cli/snapshots/cli__format__output_format_gitlab.snap} (93%) rename crates/ruff/tests/{snapshots/format__output_format_grouped.snap => cli/snapshots/cli__format__output_format_grouped.snap} (83%) rename crates/ruff/tests/{snapshots/format__output_format_json-lines.snap => cli/snapshots/cli__format__output_format_json-lines.snap} (93%) rename crates/ruff/tests/{snapshots/format__output_format_json.snap => cli/snapshots/cli__format__output_format_json.snap} (95%) rename crates/ruff/tests/{snapshots/format__output_format_junit.snap => cli/snapshots/cli__format__output_format_junit.snap} (94%) rename crates/ruff/tests/{snapshots/format__output_format_pylint.snap => cli/snapshots/cli__format__output_format_pylint.snap} (81%) rename crates/ruff/tests/{snapshots/format__output_format_rdjson.snap => cli/snapshots/cli__format__output_format_rdjson.snap} (96%) rename crates/ruff/tests/{snapshots/format__output_format_sarif.snap => cli/snapshots/cli__format__output_format_sarif.snap} (97%) diff --git a/crates/ruff/tests/format.rs b/crates/ruff/tests/cli/format.rs similarity index 65% rename from crates/ruff/tests/format.rs rename to crates/ruff/tests/cli/format.rs index 6205fa98e8..7ab9e59f26 100644 --- a/crates/ruff/tests/format.rs +++ b/crates/ruff/tests/cli/format.rs @@ -2,24 +2,17 @@ use std::fs; use std::path::Path; -use std::process::Command; -use std::str; use anyhow::Result; -use insta_cmd::{assert_cmd_snapshot, get_cargo_bin}; -use regex::escape; -use tempfile::TempDir; +use insta_cmd::assert_cmd_snapshot; -const BIN_NAME: &str = "ruff"; - -fn tempdir_filter(path: impl AsRef) -> String { - format!(r"{}\\?/?", escape(path.as_ref().to_str().unwrap())) -} +use super::{CliTest, tempdir_filter}; #[test] -fn default_options() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py"]) +fn default_options() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -46,26 +39,19 @@ if condition: ----- stderr ----- "#); + Ok(()) } #[test] fn default_files() -> Result<()> { - let tempdir = TempDir::new()?; - fs::write( - tempdir.path().join("foo.py"), - r#" -foo = "needs formatting" -"#, - )?; - fs::write( - tempdir.path().join("bar.py"), - r#" -bar = "needs formatting" -"#, - )?; + let test = CliTest::with_files([ + ("foo.py", r#"foo = "needs formatting""#), + ("bar.py", r#"bar = "needs formatting""#), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--no-cache", "--check"]).current_dir(tempdir.path()), @r" + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .arg("--check"), @r" success: false exit_code: 1 ----- stdout ----- @@ -80,9 +66,10 @@ bar = "needs formatting" } #[test] -fn format_warn_stdin_filename_with_files() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "foo.py"]) +fn format_warn_stdin_filename_with_files() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "foo.py"]) .arg("foo.py") .pass_stdin("foo = 1"), @r" success: true @@ -93,12 +80,14 @@ fn format_warn_stdin_filename_with_files() { ----- stderr ----- warning: Ignoring file foo.py in favor of standard input. "); + Ok(()) } #[test] -fn nonexistent_config_file() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config", "foo.toml", "."]), @r" +fn nonexistent_config_file() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--config", "foo.toml", "."]), @r" success: false exit_code: 2 ----- stdout ----- @@ -115,12 +104,14 @@ fn nonexistent_config_file() { For more information, try '--help'. "); + Ok(()) } #[test] -fn config_override_rejected_if_invalid_toml() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config", "foo = bar", "."]), @r" +fn config_override_rejected_if_invalid_toml() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--config", "foo = bar", "."]), @r" success: false exit_code: 2 ----- stdout ----- @@ -142,84 +133,69 @@ fn config_override_rejected_if_invalid_toml() { For more information, try '--help'. "); + Ok(()) } #[test] fn too_many_config_files() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_dot_toml = tempdir.path().join("ruff.toml"); - let ruff2_dot_toml = tempdir.path().join("ruff2.toml"); - fs::File::create(&ruff_dot_toml)?; - fs::File::create(&ruff2_dot_toml)?; - insta::with_settings!({ - filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] - }, { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .arg("format") + let test = CliTest::with_files([("ruff.toml", ""), ("ruff2.toml", "")])?; + + assert_cmd_snapshot!(test.format_command() .arg("--config") - .arg(&ruff_dot_toml) + .arg("ruff.toml") .arg("--config") - .arg(&ruff2_dot_toml) + .arg("ruff2.toml") .arg("."), @r" - success: false - exit_code: 2 - ----- stdout ----- + success: false + exit_code: 2 + ----- stdout ----- - ----- stderr ----- - ruff failed - Cause: You cannot specify more than one configuration file on the command line. + ----- stderr ----- + ruff failed + Cause: You cannot specify more than one configuration file on the command line. - tip: remove either `--config=[TMP]/ruff.toml` or `--config=[TMP]/ruff2.toml`. - For more information, try `--help`. - "); - }); + tip: remove either `--config=ruff.toml` or `--config=ruff2.toml`. + For more information, try `--help`. + "); Ok(()) } #[test] fn config_file_and_isolated() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_dot_toml = tempdir.path().join("ruff.toml"); - fs::File::create(&ruff_dot_toml)?; - insta::with_settings!({ - filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] - }, { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .arg("format") - .arg("--config") - .arg(&ruff_dot_toml) + let test = CliTest::with_file("ruff.toml", "")?; + + assert_cmd_snapshot!(test.format_command() .arg("--isolated") + .arg("--config") + .arg("ruff.toml") .arg("."), @r" - success: false - exit_code: 2 - ----- stdout ----- + success: false + exit_code: 2 + ----- stdout ----- - ----- stderr ----- - ruff failed - Cause: The argument `--config=[TMP]/ruff.toml` cannot be used with `--isolated` + ----- stderr ----- + ruff failed + Cause: The argument `--config=ruff.toml` cannot be used with `--isolated` - tip: You cannot specify a configuration file and also specify `--isolated`, - as `--isolated` causes ruff to ignore all configuration files. - For more information, try `--help`. - "); - }); + tip: You cannot specify a configuration file and also specify `--isolated`, + as `--isolated` causes ruff to ignore all configuration files. + For more information, try `--help`. + "); Ok(()) } #[test] fn config_override_via_cli() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write(&ruff_toml, "line-length = 100")?; + let test = CliTest::with_file("ruff.toml", "line-length = 70")?; + let fixture = r#" def foo(): print("looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong string") "#; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .arg("format") + assert_cmd_snapshot!(test.format_command() .arg("--config") - .arg(&ruff_toml) + .arg("ruff.toml") // This overrides the long line length set in the config file .args(["--config", "line-length=80"]) .arg("-") @@ -239,18 +215,16 @@ def foo(): #[test] fn config_doubly_overridden_via_cli() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write(&ruff_toml, "line-length = 70")?; + let test = CliTest::with_file("ruff.toml", "line-length = 70")?; + let fixture = r#" def foo(): print("looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong string") "#; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .arg("format") + assert_cmd_snapshot!(test.format_command() .arg("--config") - .arg(&ruff_toml) + .arg("ruff.toml") // This overrides the long line length set in the config file... .args(["--config", "line-length=80"]) // ...but this overrides them both: @@ -270,10 +244,8 @@ def foo(): #[test] fn format_options() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r#" indent-width = 8 line-length = 84 @@ -286,9 +258,9 @@ line-ending = "cr-lf" "#, )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config"]) - .arg(&ruff_toml) + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -319,10 +291,8 @@ if condition: #[test] fn docstring_options() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r" [format] docstring-code-format = true @@ -330,9 +300,9 @@ docstring-code-line-length = 20 ", )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config"]) - .arg(&ruff_toml) + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") .arg("-") .pass_stdin(r" def f(x): @@ -406,21 +376,20 @@ def f(x): #[test] fn mixed_line_endings() -> Result<()> { - let tempdir = TempDir::new()?; + let test = CliTest::with_files([ + ( + "main.py", + "from test import say_hy\n\nif __name__ == \"__main__\":\n say_hy(\"dear Ruff contributor\")\n", + ), + ( + "test.py", + "def say_hy(name: str):\r\n print(f\"Hy {name}\")\r\n", + ), + ])?; - fs::write( - tempdir.path().join("main.py"), - "from test import say_hy\n\nif __name__ == \"__main__\":\n say_hy(\"dear Ruff contributor\")\n", - )?; - - fs::write( - tempdir.path().join("test.py"), - "def say_hy(name: str):\r\n print(f\"Hy {name}\")\r\n", - )?; - - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--diff", "--isolated"]) + assert_cmd_snapshot!(test.format_command() + .arg("--diff") + .arg("--isolated") .arg("."), @r" success: true exit_code: 0 @@ -434,58 +403,48 @@ fn mixed_line_endings() -> Result<()> { #[test] fn exclude() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" extend-exclude = ["out"] [format] exclude = ["test.py", "generated.py"] "#, - )?; - - fs::write( - tempdir.path().join("main.py"), - r#" + ), + ( + "main.py", + r#" from test import say_hy if __name__ == "__main__": say_hy("dear Ruff contributor") "#, - )?; - - // Excluded file but passed to the CLI directly, should be formatted - let test_path = tempdir.path().join("test.py"); - fs::write( - &test_path, - r#" + ), + // Excluded file but passed to the CLI directly, should be formatted + ( + "test.py", + r#" def say_hy(name: str): print(f"Hy {name}")"#, - )?; - - fs::write( - tempdir.path().join("generated.py"), - r#"NUMBERS = [ + ), + ( + "generated.py", + r#"NUMBERS = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ] OTHER = "OTHER" "#, - )?; + ), + ("out/a.py", "a = a"), + ])?; - let out_dir = tempdir.path().join("out"); - fs::create_dir(&out_dir)?; - - fs::write(out_dir.join("a.py"), "a = a")?; - - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--check", "--config"]) - .arg(ruff_toml.file_name().unwrap()) + assert_cmd_snapshot!(test.format_command() + .args(["--check", "--config", "ruff.toml"]) // Explicitly pass test.py, should be formatted regardless of it being excluded by format.exclude - .arg(test_path.file_name().unwrap()) + .arg("test.py") // Format all other files in the directory, should respect the `exclude` and `format.exclude` options .arg("."), @r" success: false @@ -503,16 +462,11 @@ OTHER = "OTHER" /// Regression test for #[test] fn deduplicate_directory_and_explicit_file() -> Result<()> { - let tempdir = TempDir::new()?; - let root = tempdir.path(); - - let main = root.join("main.py"); - fs::write(&main, "x = 1\n")?; + let test = CliTest::with_file("main.py", "x = 1\n")?; assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(root) - .args(["format", "--no-cache", "--check"]) + test.format_command() + .arg("--check") .arg(".") .arg("main.py"), @r" @@ -531,18 +485,16 @@ fn deduplicate_directory_and_explicit_file() -> Result<()> { #[test] fn syntax_error() -> Result<()> { - let tempdir = TempDir::new()?; - - fs::write( - tempdir.path().join("main.py"), + let test = CliTest::with_file( + "main.py", r" from module import = ", )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--isolated", "--check"]) + assert_cmd_snapshot!(test.format_command() + .arg("--check") + .arg("--isolated") .arg("main.py"), @r" success: false exit_code: 2 @@ -557,10 +509,8 @@ from module import = #[test] fn messages() -> Result<()> { - let tempdir = TempDir::new()?; - - fs::write( - tempdir.path().join("main.py"), + let test = CliTest::with_file( + "main.py", r#" from test import say_hy @@ -569,9 +519,9 @@ if __name__ == "__main__": "#, )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--isolated", "--check"]) + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .arg("--check") .arg("main.py"), @r" success: false exit_code: 1 @@ -582,9 +532,8 @@ if __name__ == "__main__": ----- stderr ----- "); - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--isolated"]) + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") .arg("main.py"), @r" success: true exit_code: 0 @@ -594,9 +543,8 @@ if __name__ == "__main__": ----- stderr ----- "); - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--isolated"]) + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") .arg("main.py"), @r" success: true exit_code: 0 @@ -629,95 +577,87 @@ if __name__ == "__main__": say_hy("dear Ruff contributor") "#; - let tempdir = TempDir::new()?; - let input = tempdir.path().join("input.py"); - fs::write(&input, CONTENT)?; + let test = CliTest::with_settings(|_project_dir, mut settings| { + // JSON double escapes backslashes + settings.add_filter(r#""[^"]+\\?/?input.py"#, r#""[TMP]/input.py"#); + + settings + })?; + test.write_file("input.py", CONTENT)?; let snapshot = format!("output_format_{output_format}"); - let project_dir = dunce::canonicalize(tempdir.path())?; - - insta::with_settings!({ - filters => vec![ - (tempdir_filter(&project_dir).as_str(), "[TMP]/"), - (tempdir_filter(&tempdir).as_str(), "[TMP]/"), - (r#""[^"]+\\?/?input.py"#, r#""[TMP]/input.py"#), - (ruff_linter::VERSION, "[VERSION]"), - ] - }, { - assert_cmd_snapshot!( - snapshot, - Command::new(get_cargo_bin(BIN_NAME)) - .args([ - "format", - "--no-cache", - "--output-format", - output_format, - "--preview", - "--check", - "input.py", - ]) - .current_dir(&tempdir), - ); - }); + assert_cmd_snapshot!( + snapshot, + test.format_command().args([ + "--output-format", + output_format, + "--preview", + "--check", + "input.py", + ]), + ); Ok(()) } #[test] -fn output_format_notebook() { - let args = ["format", "--no-cache", "--isolated", "--preview", "--check"]; - let fixtures = Path::new("resources").join("test").join("fixtures"); +fn output_format_notebook() -> Result<()> { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let fixtures = crate_root.join("resources").join("test").join("fixtures"); let path = fixtures.join("unformatted.ipynb"); - insta::with_settings!({filters => vec![ - // Replace windows paths - (r"\\", "/"), - ]}, { - assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)).args(args).arg(path), - @r" - success: false - exit_code: 1 - ----- stdout ----- - unformatted: File would be reformatted - --> resources/test/fixtures/unformatted.ipynb:cell 1:1:1 - ::: cell 1 - 1 | import numpy - - maths = (numpy.arange(100)**2).sum() - - stats= numpy.asarray([1,2,3,4]).median() - 2 + - 3 + maths = (numpy.arange(100) ** 2).sum() - 4 + stats = numpy.asarray([1, 2, 3, 4]).median() - ::: cell 3 - 1 | # A cell with IPython escape command - 2 | def some_function(foo, bar): - 3 | pass - 4 + - 5 + - 6 | %matplotlib inline - ::: cell 4 - 1 | foo = %pwd - - def some_function(foo,bar,): - 2 + - 3 + - 4 + def some_function( - 5 + foo, - 6 + bar, - 7 + ): - 8 | # Another cell with IPython escape command - 9 | foo = %pwd - 10 | print(foo) - 1 file would be reformatted + let test = CliTest::with_settings(|_, mut settings| { + settings.add_filter(&tempdir_filter(crate_root.to_str().unwrap()), "CRATE_ROOT/"); + settings + })?; - ----- stderr ----- - "); - }); + assert_cmd_snapshot!( + test.format_command().args(["--isolated", "--preview", "--check"]).arg(path), + @r" + success: false + exit_code: 1 + ----- stdout ----- + unformatted: File would be reformatted + --> CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 1:1:1 + ::: cell 1 + 1 | import numpy + - maths = (numpy.arange(100)**2).sum() + - stats= numpy.asarray([1,2,3,4]).median() + 2 + + 3 + maths = (numpy.arange(100) ** 2).sum() + 4 + stats = numpy.asarray([1, 2, 3, 4]).median() + ::: cell 3 + 1 | # A cell with IPython escape command + 2 | def some_function(foo, bar): + 3 | pass + 4 + + 5 + + 6 | %matplotlib inline + ::: cell 4 + 1 | foo = %pwd + - def some_function(foo,bar,): + 2 + + 3 + + 4 + def some_function( + 5 + foo, + 6 + bar, + 7 + ): + 8 | # Another cell with IPython escape command + 9 | foo = %pwd + 10 | print(foo) + + 1 file would be reformatted + + ----- stderr ----- + " + ); + Ok(()) } #[test] fn exit_non_zero_on_format() -> Result<()> { - let tempdir = TempDir::new()?; + let test = CliTest::new()?; let contents = r#" from test import say_hy @@ -726,20 +666,13 @@ if __name__ == "__main__": say_hy("dear Ruff contributor") "#; - fs::write(tempdir.path().join("main.py"), contents)?; - - let mut cmd = Command::new(get_cargo_bin(BIN_NAME)); - cmd.current_dir(tempdir.path()) - .args([ - "format", - "--no-cache", - "--isolated", - "--exit-non-zero-on-format", - ]) - .arg("main.py"); + test.write_file("main.py", contents)?; // First format should exit with code 1 since the file needed formatting - assert_cmd_snapshot!(cmd, @r" + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .arg("--exit-non-zero-on-format") + .arg("main.py"), @r" success: false exit_code: 1 ----- stdout ----- @@ -749,7 +682,10 @@ if __name__ == "__main__": "); // Second format should exit with code 0 since no files needed formatting - assert_cmd_snapshot!(cmd, @r" + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .arg("--exit-non-zero-on-format") + .arg("main.py"), @r" success: true exit_code: 0 ----- stdout ----- @@ -759,20 +695,13 @@ if __name__ == "__main__": "); // Repeat the tests above with the --exit-non-zero-on-fix alias - fs::write(tempdir.path().join("main.py"), contents)?; - - let mut cmd = Command::new(get_cargo_bin(BIN_NAME)); - cmd.current_dir(tempdir.path()) - .args([ - "format", - "--no-cache", - "--isolated", - "--exit-non-zero-on-fix", - ]) - .arg("main.py"); + test.write_file("main.py", contents)?; // First format should exit with code 1 since the file needed formatting - assert_cmd_snapshot!(cmd, @r" + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .arg("--exit-non-zero-on-fix") + .arg("main.py"), @r" success: false exit_code: 1 ----- stdout ----- @@ -782,7 +711,10 @@ if __name__ == "__main__": "); // Second format should exit with code 0 since no files needed formatting - assert_cmd_snapshot!(cmd, @r" + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .arg("--exit-non-zero-on-fix") + .arg("main.py"), @r" success: true exit_code: 0 ----- stdout ----- @@ -796,58 +728,48 @@ if __name__ == "__main__": #[test] fn force_exclude() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" extend-exclude = ["out"] [format] exclude = ["test.py", "generated.py"] "#, - )?; - - fs::write( - tempdir.path().join("main.py"), - r#" + ), + ( + "main.py", + r#" from test import say_hy if __name__ == "__main__": say_hy("dear Ruff contributor") "#, - )?; - - // Excluded file but passed to the CLI directly, should be formatted - let test_path = tempdir.path().join("test.py"); - fs::write( - &test_path, - r#" + ), + // Excluded file but passed to the CLI directly, should be formatted + ( + "test.py", + r#" def say_hy(name: str): print(f"Hy {name}")"#, - )?; - - fs::write( - tempdir.path().join("generated.py"), - r#"NUMBERS = [ + ), + ( + "generated.py", + r#"NUMBERS = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ] OTHER = "OTHER" "#, - )?; + ), + ("out/a.py", "a = a"), + ])?; - let out_dir = tempdir.path().join("out"); - fs::create_dir(&out_dir)?; - - fs::write(out_dir.join("a.py"), "a = a")?; - - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--no-cache", "--force-exclude", "--check", "--config"]) - .arg(ruff_toml.file_name().unwrap()) - // Explicitly pass test.py, should be respect the `format.exclude` when `--force-exclude` is present - .arg(test_path.file_name().unwrap()) + assert_cmd_snapshot!(test.format_command() + .args(["--force-exclude", "--check", "--config", "ruff.toml"]) + // Explicitly pass test.py, should not be formatted because of --force-exclude + .arg("test.py") // Format all other files in the directory, should respect the `exclude` and `format.exclude` options .arg("."), @r" success: false @@ -863,10 +785,8 @@ OTHER = "OTHER" #[test] fn exclude_stdin() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r#" extend-select = ["B", "Q"] ignore = ["Q000", "Q001", "Q002", "Q003"] @@ -876,9 +796,8 @@ exclude = ["generated.py"] "#, )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--config", &ruff_toml.file_name().unwrap().to_string_lossy(), "--stdin-filename", "generated.py", "-"]) + assert_cmd_snapshot!(test.format_command() + .args(["--config", "ruff.toml", "--stdin-filename", "generated.py", "-"]) .pass_stdin(r#" from test import say_hy @@ -903,10 +822,8 @@ if __name__ == '__main__': #[test] fn force_exclude_stdin() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r#" extend-select = ["B", "Q"] ignore = ["Q000", "Q001", "Q002", "Q003"] @@ -916,9 +833,8 @@ exclude = ["generated.py"] "#, )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .args(["format", "--config", &ruff_toml.file_name().unwrap().to_string_lossy(), "--stdin-filename", "generated.py", "--force-exclude", "-"]) + assert_cmd_snapshot!(test.format_command() + .args(["--config", "ruff.toml", "--stdin-filename", "generated.py", "--force-exclude", "-"]) .pass_stdin(r#" from test import say_hy @@ -944,12 +860,10 @@ if __name__ == '__main__': #[test] fn format_option_inheritance() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - let base_toml = tempdir.path().join("base.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" extend = "base.toml" [lint] @@ -958,19 +872,19 @@ extend-select = ["COM812"] [format] quote-style = "single" "#, - )?; - - fs::write( - base_toml, - r#" + ), + ( + "base.toml", + r#" [format] indent-style = "tab" "#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config"]) - .arg(&ruff_toml) + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -1003,91 +917,79 @@ if condition: #[test] fn deprecated_options() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r" tab-size = 2 ", )?; - insta::with_settings!({filters => vec![ - (&*regex::escape(ruff_toml.to_str().unwrap()), "[RUFF-TOML-PATH]"), - ]}, { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config"]) - .arg(&ruff_toml) + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") .arg("-") .pass_stdin(r" if True: pass "), @r" - success: false - exit_code: 2 - ----- stdout ----- + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + ruff failed + Cause: Failed to load configuration `[TMP]/ruff.toml` + Cause: Failed to parse [TMP]/ruff.toml + Cause: TOML parse error at line 1, column 1 + | + 1 | + | ^ + unknown field `tab-size` + "); - ----- stderr ----- - ruff failed - Cause: Failed to load configuration `[RUFF-TOML-PATH]` - Cause: Failed to parse [RUFF-TOML-PATH] - Cause: TOML parse error at line 1, column 1 - | - 1 | - | ^ - unknown field `tab-size` - "); - }); Ok(()) } /// Since 0.1.0 the legacy format option is no longer supported #[test] fn legacy_format_option() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r#" format = "json" "#, )?; - insta::with_settings!({filters => vec![ - (&*regex::escape(ruff_toml.to_str().unwrap()), "[RUFF-TOML-PATH]"), - ]}, { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + assert_cmd_snapshot!(test.command() .args(["check", "--select", "F401", "--no-cache", "--config"]) - .arg(&ruff_toml) + .arg("ruff.toml") .arg("-") .pass_stdin(r" import os "), @r#" - success: false - exit_code: 2 - ----- stdout ----- + success: false + exit_code: 2 + ----- stdout ----- - ----- stderr ----- - ruff failed - Cause: Failed to load configuration `[RUFF-TOML-PATH]` - Cause: Failed to parse [RUFF-TOML-PATH] - Cause: TOML parse error at line 2, column 10 - | - 2 | format = "json" - | ^^^^^^ - invalid type: string "json", expected struct FormatOptions - "#); - }); + ----- stderr ----- + ruff failed + Cause: Failed to load configuration `[TMP]/ruff.toml` + Cause: Failed to parse [TMP]/ruff.toml + Cause: TOML parse error at line 2, column 10 + | + 2 | format = "json" + | ^^^^^^ + invalid type: string "json", expected struct FormatOptions + "#); Ok(()) } #[test] fn conflicting_options() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" indent-width = 2 [lint] @@ -1113,20 +1015,19 @@ allow-multiline = false skip-magic-trailing-comma = true indent-style = "tab" "#, - )?; - - let test_path = tempdir.path().join("test.py"); - fs::write( - &test_path, - r#" + ), + ( + "test.py", + r#" def say_hy(name: str): print(f"Hy {name}")"#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--no-cache", "--config"]) - .arg(&ruff_toml) - .arg(test_path), @r#" + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") + .arg("test.py"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -1150,10 +1051,8 @@ def say_hy(name: str): #[test] fn conflicting_options_stdin() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, + let test = CliTest::with_file( + "ruff.toml", r#" indent-width = 2 @@ -1179,9 +1078,9 @@ indent-style = "tab" "#, )?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--config"]) - .arg(&ruff_toml) + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") .arg("-") .pass_stdin(r#" def say_hy(name: str): @@ -1209,11 +1108,10 @@ def say_hy(name: str): #[test] fn valid_linter_options() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" [lint] select = ["ALL"] ignore = ["D203", "D212", "COM812", "ISC001"] @@ -1234,20 +1132,19 @@ multiline-quotes = "double" skip-magic-trailing-comma = false quote-style = "single" "#, - )?; - - let test_path = tempdir.path().join("test.py"); - fs::write( - &test_path, - r#" + ), + ( + "test.py", + r#" def say_hy(name: str): print(f"Hy {name}")"#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--no-cache", "--config"]) - .arg(&ruff_toml) - .arg(test_path), @r" + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") + .arg("test.py"), @r" success: true exit_code: 0 ----- stdout ----- @@ -1260,11 +1157,10 @@ def say_hy(name: str): #[test] fn valid_linter_options_preserve() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" [lint] select = ["Q"] @@ -1276,20 +1172,19 @@ multiline-quotes = "single" [format] quote-style = "preserve" "#, - )?; - - let test_path = tempdir.path().join("test.py"); - fs::write( - &test_path, - r#" + ), + ( + "test.py", + r#" def say_hy(name: str): print(f"Hy {name}")"#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--no-cache", "--config"]) - .arg(&ruff_toml) - .arg(test_path), @r" + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") + .arg("test.py"), @r" success: true exit_code: 0 ----- stdout ----- @@ -1302,29 +1197,26 @@ def say_hy(name: str): #[test] fn all_rules_default_options() -> Result<()> { - let tempdir = TempDir::new()?; - let ruff_toml = tempdir.path().join("ruff.toml"); - - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" [lint] select = ["ALL"] "#, - )?; - - let test_path = tempdir.path().join("test.py"); - fs::write( - &test_path, - r#" + ), + ( + "test.py", + r#" def say_hy(name: str): print(f"Hy {name}")"#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--no-cache", "--config"]) - .arg(&ruff_toml) - .arg(test_path), @r" + assert_cmd_snapshot!(test.format_command() + .arg("--config") + .arg("ruff.toml") + .arg("test.py"), @r" success: true exit_code: 0 ----- stdout ----- @@ -1339,117 +1231,116 @@ def say_hy(name: str): } #[test] -fn test_diff() { - let args = ["format", "--no-cache", "--isolated", "--diff"]; - let fixtures = Path::new("resources").join("test").join("fixtures"); +fn test_diff() -> Result<()> { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let test = CliTest::with_settings(|_, mut settings| { + settings.add_filter(&tempdir_filter(crate_root.to_str().unwrap()), "CRATE_ROOT/"); + settings + })?; + let fixtures = crate_root.join("resources").join("test").join("fixtures"); let paths = [ fixtures.join("unformatted.py"), fixtures.join("formatted.py"), fixtures.join("unformatted.ipynb"), ]; - insta::with_settings!({filters => vec![ - // Replace windows paths - (r"\\", "/"), - ]}, { - assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)).args(args).args(paths), - @r" - success: false - exit_code: 1 - ----- stdout ----- - --- resources/test/fixtures/unformatted.ipynb:cell 1 - +++ resources/test/fixtures/unformatted.ipynb:cell 1 - @@ -1,3 +1,4 @@ - import numpy - -maths = (numpy.arange(100)**2).sum() - -stats= numpy.asarray([1,2,3,4]).median() - + - +maths = (numpy.arange(100) ** 2).sum() - +stats = numpy.asarray([1, 2, 3, 4]).median() - --- resources/test/fixtures/unformatted.ipynb:cell 3 - +++ resources/test/fixtures/unformatted.ipynb:cell 3 - @@ -1,4 +1,6 @@ - # A cell with IPython escape command - def some_function(foo, bar): - pass - + - + - %matplotlib inline - --- resources/test/fixtures/unformatted.ipynb:cell 4 - +++ resources/test/fixtures/unformatted.ipynb:cell 4 - @@ -1,5 +1,10 @@ + + assert_cmd_snapshot!( + test.format_command().args(["--isolated", "--diff"]).args(paths), + @r" + success: false + exit_code: 1 + ----- stdout ----- + --- CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 1 + +++ CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 1 + @@ -1,3 +1,4 @@ + import numpy + -maths = (numpy.arange(100)**2).sum() + -stats= numpy.asarray([1,2,3,4]).median() + + + +maths = (numpy.arange(100) ** 2).sum() + +stats = numpy.asarray([1, 2, 3, 4]).median() + --- CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 3 + +++ CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 3 + @@ -1,4 +1,6 @@ + # A cell with IPython escape command + def some_function(foo, bar): + pass + + + + + %matplotlib inline + --- CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 4 + +++ CRATE_ROOT/resources/test/fixtures/unformatted.ipynb:cell 4 + @@ -1,5 +1,10 @@ + foo = %pwd + -def some_function(foo,bar,): + + + + + +def some_function( + + foo, + + bar, + +): + # Another cell with IPython escape command foo = %pwd - -def some_function(foo,bar,): - + - + - +def some_function( - + foo, - + bar, - +): - # Another cell with IPython escape command - foo = %pwd - print(foo) + print(foo) - --- resources/test/fixtures/unformatted.py - +++ resources/test/fixtures/unformatted.py - @@ -1,3 +1,3 @@ - x = 1 - -y=2 - +y = 2 - z = 3 + --- CRATE_ROOT/resources/test/fixtures/unformatted.py + +++ CRATE_ROOT/resources/test/fixtures/unformatted.py + @@ -1,3 +1,3 @@ + x = 1 + -y=2 + +y = 2 + z = 3 - ----- stderr ----- - 2 files would be reformatted, 1 file already formatted - "); - }); + ----- stderr ----- + 2 files would be reformatted, 1 file already formatted + "); + + Ok(()) } #[test] -fn test_diff_no_change() { - let args = ["format", "--no-cache", "--isolated", "--diff"]; - let fixtures = Path::new("resources").join("test").join("fixtures"); +fn test_diff_no_change() -> Result<()> { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let test = CliTest::with_settings(|_, mut settings| { + settings.add_filter(&tempdir_filter(crate_root.to_str().unwrap()), "CRATE_ROOT/"); + settings + })?; + + let fixtures = crate_root.join("resources").join("test").join("fixtures"); let paths = [fixtures.join("unformatted.py")]; - insta::with_settings!({filters => vec![ - // Replace windows paths - (r"\\", "/"), - ]}, { - assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)).args(args).args(paths), - @r" - success: false - exit_code: 1 - ----- stdout ----- - --- resources/test/fixtures/unformatted.py - +++ resources/test/fixtures/unformatted.py - @@ -1,3 +1,3 @@ - x = 1 - -y=2 - +y = 2 - z = 3 + assert_cmd_snapshot!( + test.format_command().args(["--isolated", "--diff"]).args(paths), + @r" + success: false + exit_code: 1 + ----- stdout ----- + --- CRATE_ROOT/resources/test/fixtures/unformatted.py + +++ CRATE_ROOT/resources/test/fixtures/unformatted.py + @@ -1,3 +1,3 @@ + x = 1 + -y=2 + +y = 2 + z = 3 - ----- stderr ----- - 1 file would be reformatted - " - ); - }); + ----- stderr ----- + 1 file would be reformatted + " + ); + + Ok(()) } #[test] -fn test_diff_stdin_unformatted() { - let args = [ - "format", - "--isolated", - "--diff", - "-", - "--stdin-filename", - "unformatted.py", - ]; +fn test_diff_stdin_unformatted() -> Result<()> { + let test = CliTest::new()?; let fixtures = Path::new("resources").join("test").join("fixtures"); let unformatted = fs::read(fixtures.join("unformatted.py")).unwrap(); assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)).args(args).pass_stdin(unformatted), + test.format_command() + .args(["--isolated", "--diff", "-", "--stdin-filename", "unformatted.py"]) + .pass_stdin(unformatted), @r" success: false exit_code: 1 @@ -1465,15 +1356,16 @@ fn test_diff_stdin_unformatted() { ----- stderr ----- "); + Ok(()) } #[test] -fn test_diff_stdin_formatted() { - let args = ["format", "--isolated", "--diff", "-"]; +fn test_diff_stdin_formatted() -> Result<()> { + let test = CliTest::new()?; let fixtures = Path::new("resources").join("test").join("fixtures"); let unformatted = fs::read(fixtures.join("formatted.py")).unwrap(); assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)).args(args).pass_stdin(unformatted), + test.format_command().args(["--isolated", "--diff", "-"]).pass_stdin(unformatted), @r" success: true exit_code: 0 @@ -1481,14 +1373,17 @@ fn test_diff_stdin_formatted() { ----- stderr ----- "); + Ok(()) } #[test] -fn test_notebook_trailing_semicolon() { +fn test_notebook_trailing_semicolon() -> Result<()> { + let test = CliTest::new()?; + let fixtures = Path::new("resources").join("test").join("fixtures"); let unformatted = fs::read(fixtures.join("trailing_semicolon.ipynb")).unwrap(); - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.ipynb"]) + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.ipynb"]) .arg("-") .pass_stdin(unformatted), @r##" success: true @@ -1910,23 +1805,21 @@ fn test_notebook_trailing_semicolon() { ----- stderr ----- "##); + Ok(()) } #[test] fn syntax_error_in_notebooks() -> Result<()> { - let tempdir = TempDir::new()?; - - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" include = ["*.ipy"] "#, - )?; - - fs::write( - tempdir.path().join("main.ipy"), - r#" + ), + ( + "main.ipy", + r#" { "cells": [ { @@ -1974,13 +1867,11 @@ include = ["*.ipy"] "nbformat_minor": 0 } "#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .arg("format") - .arg("--no-cache") - .args(["--config", &ruff_toml.file_name().unwrap().to_string_lossy()]) + assert_cmd_snapshot!(test.format_command() + .args(["--config", "ruff.toml"]) .args(["--extension", "ipy:ipynb"]) .arg("."), @r" success: false @@ -1995,19 +1886,16 @@ include = ["*.ipy"] #[test] fn extension() -> Result<()> { - let tempdir = TempDir::new()?; - - let ruff_toml = tempdir.path().join("ruff.toml"); - fs::write( - &ruff_toml, - r#" + let test = CliTest::with_files([ + ( + "ruff.toml", + r#" include = ["*.ipy"] "#, - )?; - - fs::write( - tempdir.path().join("main.ipy"), - r#" + ), + ( + "main.ipy", + r#" { "cells": [ { @@ -2044,13 +1932,11 @@ include = ["*.ipy"] "nbformat_minor": 5 } "#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .current_dir(tempdir.path()) - .arg("format") - .arg("--no-cache") - .args(["--config", &ruff_toml.file_name().unwrap().to_string_lossy()]) + assert_cmd_snapshot!(test.format_command() + .args(["--config", "ruff.toml"]) .args(["--extension", "ipy:ipynb"]) .arg("."), @r" success: true @@ -2064,9 +1950,10 @@ include = ["*.ipy"] } #[test] -fn range_formatting() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=2:8-2:14"]) +fn range_formatting() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=2:8-2:14"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2086,12 +1973,14 @@ def foo(arg1, arg2,): ----- stderr ----- "#); + Ok(()) } #[test] -fn range_formatting_unicode() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=2:21-3"]) +fn range_formatting_unicode() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=2:21-3"]) .arg("-") .pass_stdin(r#" def foo(arg1="👋🏽" ): print("Format this" ) @@ -2105,37 +1994,34 @@ def foo(arg1="👋🏽" ): print("Format this" ) ----- stderr ----- "#); + Ok(()) } #[test] -fn range_formatting_multiple_files() -> std::io::Result<()> { - let tempdir = TempDir::new()?; - let file1 = tempdir.path().join("file1.py"); - - fs::write( - &file1, - r#" +fn range_formatting_multiple_files() -> Result<()> { + let test = CliTest::with_files([ + ( + "file1.py", + r#" def file1(arg1, arg2,): print("Shouldn't format this" ) "#, - )?; - - let file2 = tempdir.path().join("file2.py"); - - fs::write( - &file2, - r#" + ), + ( + "file2.py", + r#" def file2(arg1, arg2,): print("Shouldn't format this" ) "#, - )?; + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--range=1:8-1:15"]) - .arg(file1) - .arg(file2), @r" + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--range=1:8-1:15"]) + .arg("file1.py") + .arg("file2.py"), @r" success: false exit_code: 2 ----- stdout ----- @@ -2149,9 +2035,10 @@ def file2(arg1, arg2,): } #[test] -fn range_formatting_out_of_bounds() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=100:40-200:1"]) +fn range_formatting_out_of_bounds() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=100:40-200:1"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2168,12 +2055,14 @@ def foo(arg1, arg2,): ----- stderr ----- "#); + Ok(()) } #[test] -fn range_start_larger_than_end() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=90-50"]) +fn range_start_larger_than_end() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=90-50"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2190,12 +2079,14 @@ def foo(arg1, arg2,): For more information, try '--help'. "); + Ok(()) } #[test] -fn range_line_numbers_only() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=2-3"]) +fn range_line_numbers_only() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=2-3"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2215,12 +2106,14 @@ def foo(arg1, arg2,): ----- stderr ----- "#); + Ok(()) } #[test] -fn range_start_only() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=3"]) +fn range_start_only() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=3"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2236,12 +2129,14 @@ def foo(arg1, arg2,): ----- stderr ----- "#); + Ok(()) } #[test] -fn range_end_only() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=-3"]) +fn range_end_only() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=-3"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2260,12 +2155,14 @@ def foo(arg1, arg2,): ----- stderr ----- "#); + Ok(()) } #[test] -fn range_missing_line() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=1-:20"]) +fn range_missing_line() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=1-:20"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2282,12 +2179,14 @@ def foo(arg1, arg2,): For more information, try '--help'. "); + Ok(()) } #[test] -fn zero_line_number() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=0:2"]) +fn zero_line_number() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=0:2"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2305,12 +2204,14 @@ def foo(arg1, arg2,): For more information, try '--help'. "); + Ok(()) } #[test] -fn column_and_line_zero() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=0:0"]) +fn column_and_line_zero() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--range=0:0"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -2328,12 +2229,14 @@ def foo(arg1, arg2,): For more information, try '--help'. "); + Ok(()) } #[test] -fn range_formatting_notebook() { - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--no-cache", "--stdin-filename", "main.ipynb", "--range=1-2"]) +fn range_formatting_notebook() -> Result<()> { + let test = CliTest::new()?; + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "main.ipynb", "--range=1-2"]) .arg("-") .pass_stdin(r#" { @@ -2379,6 +2282,7 @@ fn range_formatting_notebook() { ----- stderr ----- error: Failed to format main.ipynb: Range formatting isn't supported for notebooks. "); + Ok(()) } /// Test that the formatter respects `per-file-target-version`. Context managers can't be @@ -2386,10 +2290,11 @@ fn range_formatting_notebook() { /// /// Adapted from #[test] -fn per_file_target_version_formatter() { +fn per_file_target_version_formatter() -> Result<()> { + let test = CliTest::new()?; // without `per-file-target-version` this should not be reformatted in the same way - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--target-version=py38"]) + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--stdin-filename", "test.py", "--target-version=py38"]) .arg("-") .pass_stdin(r#" with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a_really_long_baz") as baz: @@ -2406,8 +2311,9 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a ----- stderr ----- "#); - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated", "--stdin-filename", "test.py", "--target-version=py38"]) + assert_cmd_snapshot!(test.format_command() + .arg("--isolated") + .args(["--stdin-filename", "test.py", "--target-version=py38"]) .args(["--config", r#"per-file-target-version = {"test.py" = "py311"}"#]) .arg("-") .pass_stdin(r#" @@ -2426,6 +2332,7 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a ----- stderr ----- "#); + Ok(()) } /// Regression test for with very helpful @@ -2436,21 +2343,19 @@ fn cookiecutter_globbing() -> Result<()> { // problem is this `{{cookiecutter.repo_name}}` directory containing a config file with a glob. // The absolute path of the glob contains the glob metacharacters `{{` and `}}` even though the // user's glob does not. - let tempdir = TempDir::new()?; - let cookiecutter = tempdir.path().join("{{cookiecutter.repo_name}}"); - let cookiecutter_toml = cookiecutter.join("pyproject.toml"); - let tests = cookiecutter.join("tests"); - fs::create_dir_all(&tests)?; - fs::write( - cookiecutter_toml, - r#"tool.ruff.lint.per-file-ignores = { "tests/*" = ["F811"] }"#, - )?; - let maintest = tests.join("maintest.py"); - fs::write(maintest, "import foo\nimport bar\nimport foo\n")?; + let test = CliTest::with_files([ + ( + "{{cookiecutter.repo_name}}/pyproject.toml", + r#"tool.ruff.lint.per-file-ignores = { "tests/*" = ["F811"] }"#, + ), + ( + "{{cookiecutter.repo_name}}/tests/maintest.py", + "import foo\nimport bar\nimport foo\n", + ), + ])?; - assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--no-cache", "--diff"]) - .current_dir(tempdir.path()), @r" + assert_cmd_snapshot!(test.format_command() + .args(["--isolated", "--diff", "."]), @r" success: true exit_code: 0 ----- stdout ----- @@ -2463,19 +2368,20 @@ fn cookiecutter_globbing() -> Result<()> { } #[test] -fn stable_output_format_warning() { +fn stable_output_format_warning() -> Result<()> { + let test = CliTest::new()?; assert_cmd_snapshot!( - Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--output-format=full", "-"]) - .pass_stdin("1"), + test.format_command() + .args(["--output-format=full", "-"]) + .pass_stdin(""), @r" success: true exit_code: 0 ----- stdout ----- - 1 ----- stderr ----- warning: The --output-format flag for the formatter is unstable and requires preview mode to use. ", ); + Ok(()) } diff --git a/crates/ruff/tests/cli/main.rs b/crates/ruff/tests/cli/main.rs index 91f3704e4c..ef7313253e 100644 --- a/crates/ruff/tests/cli/main.rs +++ b/crates/ruff/tests/cli/main.rs @@ -15,6 +15,7 @@ use std::{ }; use tempfile::TempDir; +mod format; mod lint; const BIN_NAME: &str = "ruff"; @@ -57,6 +58,16 @@ impl CliTest { Self::with_settings(|_, settings| settings) } + pub(crate) fn with_files<'a>( + files: impl IntoIterator, + ) -> anyhow::Result { + let case = Self::new()?; + for file in files { + case.write_file(file.0, file.1)?; + } + Ok(case) + } + pub(crate) fn with_settings( setup_settings: impl FnOnce(&Path, insta::Settings) -> insta::Settings, ) -> Result { @@ -174,4 +185,10 @@ impl CliTest { command } + + pub(crate) fn format_command(&self) -> Command { + let mut command = self.command(); + command.args(["format", "--no-cache"]); + command + } } diff --git a/crates/ruff/tests/snapshots/format__output_format_azure.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_azure.snap similarity index 89% rename from crates/ruff/tests/snapshots/format__output_format_azure.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_azure.snap index f27a327727..c6401871ae 100644 --- a/crates/ruff/tests/snapshots/format__output_format_azure.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_azure.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_concise.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_concise.snap similarity index 88% rename from crates/ruff/tests/snapshots/format__output_format_concise.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_concise.snap index 8a24633768..6e220438c0 100644 --- a/crates/ruff/tests/snapshots/format__output_format_concise.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_concise.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_full.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_full.snap similarity index 90% rename from crates/ruff/tests/snapshots/format__output_format_full.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_full.snap index 30eb3a0a51..e48f0b5563 100644 --- a/crates/ruff/tests/snapshots/format__output_format_full.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_full.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_github.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_github.snap similarity index 90% rename from crates/ruff/tests/snapshots/format__output_format_github.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_github.snap index e279ad2b87..92086762b5 100644 --- a/crates/ruff/tests/snapshots/format__output_format_github.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_github.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_gitlab.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_gitlab.snap similarity index 93% rename from crates/ruff/tests/snapshots/format__output_format_gitlab.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_gitlab.snap index fbf68871d3..7b0802de73 100644 --- a/crates/ruff/tests/snapshots/format__output_format_gitlab.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_gitlab.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_grouped.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_grouped.snap similarity index 83% rename from crates/ruff/tests/snapshots/format__output_format_grouped.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_grouped.snap index bd9ec0e7e4..60482d5075 100644 --- a/crates/ruff/tests/snapshots/format__output_format_grouped.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_grouped.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: @@ -7,6 +7,7 @@ info: - "--no-cache" - "--output-format" - grouped + - "--preview" - "--check" - input.py --- diff --git a/crates/ruff/tests/snapshots/format__output_format_json-lines.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_json-lines.snap similarity index 93% rename from crates/ruff/tests/snapshots/format__output_format_json-lines.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_json-lines.snap index dadde72435..10c4622041 100644 --- a/crates/ruff/tests/snapshots/format__output_format_json-lines.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_json-lines.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_json.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_json.snap similarity index 95% rename from crates/ruff/tests/snapshots/format__output_format_json.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_json.snap index 1bbff05aec..e1058c41b1 100644 --- a/crates/ruff/tests/snapshots/format__output_format_json.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_json.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_junit.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_junit.snap similarity index 94% rename from crates/ruff/tests/snapshots/format__output_format_junit.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_junit.snap index 518c836729..221b908122 100644 --- a/crates/ruff/tests/snapshots/format__output_format_junit.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_junit.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_pylint.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_pylint.snap similarity index 81% rename from crates/ruff/tests/snapshots/format__output_format_pylint.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_pylint.snap index 7d5f80fed4..6400bdf265 100644 --- a/crates/ruff/tests/snapshots/format__output_format_pylint.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_pylint.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: @@ -7,6 +7,7 @@ info: - "--no-cache" - "--output-format" - pylint + - "--preview" - "--check" - input.py --- diff --git a/crates/ruff/tests/snapshots/format__output_format_rdjson.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_rdjson.snap similarity index 96% rename from crates/ruff/tests/snapshots/format__output_format_rdjson.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_rdjson.snap index dcdb5bda8c..b9d6dcdce3 100644 --- a/crates/ruff/tests/snapshots/format__output_format_rdjson.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_rdjson.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: diff --git a/crates/ruff/tests/snapshots/format__output_format_sarif.snap b/crates/ruff/tests/cli/snapshots/cli__format__output_format_sarif.snap similarity index 97% rename from crates/ruff/tests/snapshots/format__output_format_sarif.snap rename to crates/ruff/tests/cli/snapshots/cli__format__output_format_sarif.snap index 1f31ab0374..e6373198ac 100644 --- a/crates/ruff/tests/snapshots/format__output_format_sarif.snap +++ b/crates/ruff/tests/cli/snapshots/cli__format__output_format_sarif.snap @@ -1,5 +1,5 @@ --- -source: crates/ruff/tests/format.rs +source: crates/ruff/tests/cli/format.rs info: program: ruff args: From 590ce9d9ed70db0578b14a4c93aaaba31485724a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Oct 2025 14:59:10 +0100 Subject: [PATCH 104/113] Use uv more in CI workflows (#20968) ## Summary More dogfooding of our own tools. I didn't touch the build-binaries workflow (it's scary) or the publish-docs workflow (which doesn't run on PRs) or the ruff-lsp job in the ci.yaml workflow (ruff-lsp is deprecated; it doesn't seem worth making changes there). ## Test Plan CI on this PR --- .github/workflows/ci.yaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 70d4d5fb1c..c57773078f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -529,10 +529,11 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 with: # TODO: figure out why `ruff-ecosystem` crashes on Python 3.14 python-version: "3.13" + activate-environment: true - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 name: Download comparison Ruff binary @@ -551,7 +552,7 @@ jobs: - name: Install ruff-ecosystem run: | - pip install ./python/ruff-ecosystem + uv pip install ./python/ruff-ecosystem - name: Run `ruff check` stable ecosystem check if: ${{ needs.determine_changes.outputs.linter == 'true' }} @@ -787,9 +788,6 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - with: - python-version: "3.13" - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 - name: "Add SSH key" if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }} @@ -800,12 +798,15 @@ jobs: run: rustup show - name: Install uv uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + python-version: 3.13 + activate-environment: true - name: "Install Insiders dependencies" if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }} - run: uv pip install -r docs/requirements-insiders.txt --system + run: uv pip install -r docs/requirements-insiders.txt - name: "Install dependencies" if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS != 'true' }} - run: uv pip install -r docs/requirements.txt --system + run: uv pip install -r docs/requirements.txt - name: "Update README File" run: python scripts/transform_readme.py --target mkdocs - name: "Generate docs" From 029a7aa2000c3b2dba06f1c7ea2961bf345173d0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:32:48 +0200 Subject: [PATCH 105/113] Update Rust crate snapbox to v0.6.22 (#20983) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1442583e70..a16956d37d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -642,7 +642,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1690,7 +1690,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1754,7 +1754,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3795,9 +3795,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "snapbox" -version = "0.6.21" +version = "0.6.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96dcfc4581e3355d70ac2ee14cfdf81dce3d85c85f1ed9e2c1d3013f53b3436b" +checksum = "805d09a74586d9b17061e5be6ee5f8cc37e5982c349948114ffc5f68093fe5ec" dependencies = [ "anstream", "anstyle", @@ -3810,7 +3810,7 @@ dependencies = [ "similar", "snapbox-macros", "wait-timeout", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] From bb3a88cdd5b333fb1d606929a2f39572b732a520 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:33:09 +0200 Subject: [PATCH 106/113] Update Rust crate clap to v4.5.49 (#20976) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a16956d37d..3babde2765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,9 +433,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" dependencies = [ "clap_builder", "clap_derive", @@ -443,9 +443,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" dependencies = [ "anstream", "anstyle", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -1007,7 +1007,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.0", + "windows-sys 0.59.0", ] [[package]] @@ -1093,7 +1093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -3523,7 +3523,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -3918,7 +3918,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -5004,7 +5004,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] From 4622b8f8493f08fd6cfc735b6ad08236fad28463 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:33:26 +0200 Subject: [PATCH 107/113] Update Rust crate quote to v1.0.41 (#20980) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3babde2765..e34b4a4550 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,9 +2627,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] From b82404f7067529289f663e570d81282d044c7d2f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:35:32 +0200 Subject: [PATCH 108/113] Update Rust crate globset to v0.4.17 (#20978) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e34b4a4550..e5fe849913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1287,9 +1287,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" dependencies = [ "aho-corasick", "bstr", From 0fc5520404cb4daa6a3ed3a83e51e117c3a5fb4f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:45:17 +0200 Subject: [PATCH 109/113] Update Rust crate regex-automata to v0.4.13 (#20981) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5fe849913..01d93279a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2781,9 +2781,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", From 991e8ed178bbfbf8485af8d77cdab2b5cffdb1cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:53:07 +0200 Subject: [PATCH 110/113] Update Rust crate serde to v1.0.228 (#20982) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01d93279a2..5a992976ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3625,9 +3625,9 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -3646,18 +3646,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", From 48b50128eb207f96ad4d9c215b0fe1d46f3d21a1 Mon Sep 17 00:00:00 2001 From: Takayuki Maeda Date: Mon, 20 Oct 2025 15:59:52 +0900 Subject: [PATCH 111/113] [`ruff`] Update schemars to v1 (#20942) --- Cargo.lock | 33 +- Cargo.toml | 2 +- crates/ruff_dev/src/generate_json_schema.rs | 6 +- crates/ruff_dev/src/generate_ty_schema.rs | 6 +- crates/ruff_linter/src/rule_selector.rs | 114 ++-- crates/ruff_linter/src/settings/types.rs | 8 +- crates/ruff_python_ast/Cargo.toml | 3 +- crates/ruff_python_ast/src/name.rs | 34 +- crates/ruff_python_ast/src/python_version.rs | 57 +- crates/ruff_python_formatter/Cargo.toml | 3 +- crates/ruff_python_formatter/src/options.rs | 30 +- crates/ruff_python_semantic/src/imports.rs | 12 +- crates/ruff_text_size/src/schemars_impls.rs | 11 +- crates/ruff_workspace/Cargo.toml | 2 + crates/ruff_workspace/src/options.rs | 36 +- crates/ty_project/Cargo.toml | 2 + crates/ty_project/src/metadata/options.rs | 85 ++- crates/ty_project/src/metadata/value.rs | 29 +- crates/ty_python_semantic/Cargo.toml | 2 + .../ty_python_semantic/src/python_platform.rs | 94 +-- ruff.schema.json | 618 +++++++++--------- ty.schema.json | 203 +++--- 22 files changed, 686 insertions(+), 704 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a992976ca..25fa2b9632 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2767,6 +2767,26 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.11.3" @@ -3197,6 +3217,7 @@ dependencies = [ "salsa", "schemars", "serde", + "serde_json", "thiserror 2.0.16", ] @@ -3484,6 +3505,7 @@ dependencies = [ "rustc-hash", "schemars", "serde", + "serde_json", "shellexpand", "strum", "tempfile", @@ -3589,11 +3611,12 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.22" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", + "ref-cast", "schemars_derive", "serde", "serde_json", @@ -3601,9 +3624,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.22" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" dependencies = [ "proc-macro2", "quote", @@ -4384,6 +4407,7 @@ dependencies = [ "salsa", "schemars", "serde", + "serde_json", "thiserror 2.0.16", "toml", "tracing", @@ -4431,6 +4455,7 @@ dependencies = [ "salsa", "schemars", "serde", + "serde_json", "smallvec", "static_assertions", "strsim", diff --git a/Cargo.toml b/Cargo.toml index ad61751a7d..5f1112913e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,7 @@ salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "ef9f9329be6923ac "salsa_unstable", "inventory", ] } -schemars = { version = "0.8.16" } +schemars = { version = "1.0.4" } seahash = { version = "4.1.0" } serde = { version = "1.0.197", features = ["derive"] } serde-wasm-bindgen = { version = "0.6.4" } diff --git a/crates/ruff_dev/src/generate_json_schema.rs b/crates/ruff_dev/src/generate_json_schema.rs index 61d616cfb4..239f675828 100644 --- a/crates/ruff_dev/src/generate_json_schema.rs +++ b/crates/ruff_dev/src/generate_json_schema.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::{Result, bail}; use pretty_assertions::StrComparison; -use schemars::schema_for; +use schemars::generate::SchemaSettings; use crate::ROOT_DIR; use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND}; @@ -17,7 +17,9 @@ pub(crate) struct Args { } pub(crate) fn main(args: &Args) -> Result<()> { - let schema = schema_for!(Options); + let settings = SchemaSettings::draft07(); + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::(); let schema_string = serde_json::to_string_pretty(&schema).unwrap(); let filename = "ruff.schema.json"; let schema_path = PathBuf::from(ROOT_DIR).join(filename); diff --git a/crates/ruff_dev/src/generate_ty_schema.rs b/crates/ruff_dev/src/generate_ty_schema.rs index eac09963b3..e819e91d10 100644 --- a/crates/ruff_dev/src/generate_ty_schema.rs +++ b/crates/ruff_dev/src/generate_ty_schema.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::{Result, bail}; use pretty_assertions::StrComparison; -use schemars::schema_for; +use schemars::generate::SchemaSettings; use crate::ROOT_DIR; use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND}; @@ -17,7 +17,9 @@ pub(crate) struct Args { } pub(crate) fn main(args: &Args) -> Result<()> { - let schema = schema_for!(Options); + let settings = SchemaSettings::draft07(); + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::(); let schema_string = serde_json::to_string_pretty(&schema).unwrap(); let filename = "ty.schema.json"; let schema_path = PathBuf::from(ROOT_DIR).join(filename); diff --git a/crates/ruff_linter/src/rule_selector.rs b/crates/ruff_linter/src/rule_selector.rs index 399c881a34..b0417f4c1d 100644 --- a/crates/ruff_linter/src/rule_selector.rs +++ b/crates/ruff_linter/src/rule_selector.rs @@ -257,9 +257,8 @@ pub struct PreviewOptions { #[cfg(feature = "schemars")] mod schema { use itertools::Itertools; - use schemars::_serde_json::Value; - use schemars::JsonSchema; - use schemars::schema::{InstanceType, Schema, SchemaObject}; + use schemars::{JsonSchema, Schema, SchemaGenerator}; + use serde_json::Value; use strum::IntoEnumIterator; use crate::RuleSelector; @@ -267,64 +266,65 @@ mod schema { use crate::rule_selector::{Linter, RuleCodePrefix}; impl JsonSchema for RuleSelector { - fn schema_name() -> String { - "RuleSelector".to_string() + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("RuleSelector") } - fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> Schema { - Schema::Object(SchemaObject { - instance_type: Some(InstanceType::String.into()), - enum_values: Some( - [ - // Include the non-standard "ALL" selectors. - "ALL".to_string(), - // Include the legacy "C" and "T" selectors. - "C".to_string(), - "T".to_string(), - // Include some common redirect targets for those legacy selectors. - "C9".to_string(), - "T1".to_string(), - "T2".to_string(), - ] - .into_iter() - .chain( - RuleCodePrefix::iter() - .map(|p| { - let prefix = p.linter().common_prefix(); - let code = p.short_code(); - format!("{prefix}{code}") - }) - .chain(Linter::iter().filter_map(|l| { - let prefix = l.common_prefix(); - (!prefix.is_empty()).then(|| prefix.to_string()) - })), - ) - .filter(|p| { - // Exclude any prefixes where all of the rules are removed - if let Ok(Self::Rule { prefix, .. } | Self::Prefix { prefix, .. }) = - RuleSelector::parse_no_redirect(p) - { - !prefix.rules().all(|rule| rule.is_removed()) - } else { - true - } + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + let enum_values: Vec = [ + // Include the non-standard "ALL" selectors. + "ALL".to_string(), + // Include the legacy "C" and "T" selectors. + "C".to_string(), + "T".to_string(), + // Include some common redirect targets for those legacy selectors. + "C9".to_string(), + "T1".to_string(), + "T2".to_string(), + ] + .into_iter() + .chain( + RuleCodePrefix::iter() + .map(|p| { + let prefix = p.linter().common_prefix(); + let code = p.short_code(); + format!("{prefix}{code}") }) - .filter(|_rule| { - // Filter out all test-only rules - #[cfg(any(feature = "test-rules", test))] - #[expect(clippy::used_underscore_binding)] - if _rule.starts_with("RUF9") || _rule == "PLW0101" { - return false; - } - - true - }) - .sorted() - .map(Value::String) - .collect(), - ), - ..SchemaObject::default() + .chain(Linter::iter().filter_map(|l| { + let prefix = l.common_prefix(); + (!prefix.is_empty()).then(|| prefix.to_string()) + })), + ) + .filter(|p| { + // Exclude any prefixes where all of the rules are removed + if let Ok(Self::Rule { prefix, .. } | Self::Prefix { prefix, .. }) = + RuleSelector::parse_no_redirect(p) + { + !prefix.rules().all(|rule| rule.is_removed()) + } else { + true + } }) + .filter(|_rule| { + // Filter out all test-only rules + #[cfg(any(feature = "test-rules", test))] + #[expect(clippy::used_underscore_binding)] + if _rule.starts_with("RUF9") || _rule == "PLW0101" { + return false; + } + + true + }) + .sorted() + .collect(); + + let mut schema = schemars::json_schema!({ "type": "string" }); + schema.ensure_object().insert( + "enum".to_string(), + Value::Array(enum_values.into_iter().map(Value::String).collect()), + ); + + schema } } } diff --git a/crates/ruff_linter/src/settings/types.rs b/crates/ruff_linter/src/settings/types.rs index 1331035345..05cd13909b 100644 --- a/crates/ruff_linter/src/settings/types.rs +++ b/crates/ruff_linter/src/settings/types.rs @@ -617,12 +617,12 @@ impl TryFrom for RequiredVersion { #[cfg(feature = "schemars")] impl schemars::JsonSchema for RequiredVersion { - fn schema_name() -> String { - "RequiredVersion".to_string() + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("RequiredVersion") } - fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { - generator.subschema_for::() + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + ::json_schema(generator) } } diff --git a/crates/ruff_python_ast/Cargo.toml b/crates/ruff_python_ast/Cargo.toml index 5a50f4ee74..062870d3e4 100644 --- a/crates/ruff_python_ast/Cargo.toml +++ b/crates/ruff_python_ast/Cargo.toml @@ -30,10 +30,11 @@ rustc-hash = { workspace = true } salsa = { workspace = true, optional = true } schemars = { workspace = true, optional = true } serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } thiserror = { workspace = true } [features] -schemars = ["dep:schemars"] +schemars = ["dep:schemars", "dep:serde_json"] cache = ["dep:ruff_cache", "dep:ruff_macros"] serde = [ "dep:serde", diff --git a/crates/ruff_python_ast/src/name.rs b/crates/ruff_python_ast/src/name.rs index a1e1376282..a4d8fe46c9 100644 --- a/crates/ruff_python_ast/src/name.rs +++ b/crates/ruff_python_ast/src/name.rs @@ -11,6 +11,11 @@ use crate::generated::ExprName; #[cfg_attr(feature = "cache", derive(ruff_macros::CacheKey))] #[cfg_attr(feature = "salsa", derive(salsa::Update))] #[cfg_attr(feature = "get-size", derive(get_size2::GetSize))] +#[cfg_attr( + feature = "schemars", + derive(schemars::JsonSchema), + schemars(with = "String") +)] pub struct Name(compact_str::CompactString); impl Name { @@ -201,35 +206,6 @@ impl PartialEq for &String { } } -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for Name { - fn is_referenceable() -> bool { - String::is_referenceable() - } - - fn schema_name() -> String { - String::schema_name() - } - - fn schema_id() -> std::borrow::Cow<'static, str> { - String::schema_id() - } - - fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { - String::json_schema(generator) - } - - fn _schemars_private_non_optional_json_schema( - generator: &mut schemars::r#gen::SchemaGenerator, - ) -> schemars::schema::Schema { - String::_schemars_private_non_optional_json_schema(generator) - } - - fn _schemars_private_is_option() -> bool { - String::_schemars_private_is_option() - } -} - /// A representation of a qualified name, like `typing.List`. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct QualifiedName<'a>(SegmentsVec<'a>); diff --git a/crates/ruff_python_ast/src/python_version.rs b/crates/ruff_python_ast/src/python_version.rs index 82f34f2a48..6d472d9ab4 100644 --- a/crates/ruff_python_ast/src/python_version.rs +++ b/crates/ruff_python_ast/src/python_version.rs @@ -188,42 +188,39 @@ mod serde { #[cfg(feature = "schemars")] mod schemars { use super::PythonVersion; - use schemars::_serde_json::Value; - use schemars::JsonSchema; - use schemars::schema::{Metadata, Schema, SchemaObject, SubschemaValidation}; + use schemars::{JsonSchema, Schema, SchemaGenerator}; + use serde_json::Value; impl JsonSchema for PythonVersion { - fn schema_name() -> String { - "PythonVersion".to_string() + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("PythonVersion") } - fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> Schema { - let sub_schemas = std::iter::once(Schema::Object(SchemaObject { - instance_type: Some(schemars::schema::InstanceType::String.into()), - string: Some(Box::new(schemars::schema::StringValidation { - pattern: Some(r"^\d+\.\d+$".to_string()), - ..Default::default() - })), - ..Default::default() - })) - .chain(Self::iter().map(|v| { - Schema::Object(SchemaObject { - const_value: Some(Value::String(v.to_string())), - metadata: Some(Box::new(Metadata { - description: Some(format!("Python {v}")), - ..Metadata::default() - })), - ..SchemaObject::default() + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + let mut any_of: Vec = vec![ + schemars::json_schema!({ + "type": "string", + "pattern": r"^\d+\.\d+$", }) - })); + .into(), + ]; - Schema::Object(SchemaObject { - subschemas: Some(Box::new(SubschemaValidation { - any_of: Some(sub_schemas.collect()), - ..Default::default() - })), - ..SchemaObject::default() - }) + for version in Self::iter() { + let mut schema = schemars::json_schema!({ + "const": version.to_string(), + }); + schema.ensure_object().insert( + "description".to_string(), + Value::String(format!("Python {version}")), + ); + any_of.push(schema.into()); + } + + let mut schema = Schema::default(); + schema + .ensure_object() + .insert("anyOf".to_string(), Value::Array(any_of)); + schema } } } diff --git a/crates/ruff_python_formatter/Cargo.toml b/crates/ruff_python_formatter/Cargo.toml index 1f351ca622..95ca60ee10 100644 --- a/crates/ruff_python_formatter/Cargo.toml +++ b/crates/ruff_python_formatter/Cargo.toml @@ -34,6 +34,7 @@ rustc-hash = { workspace = true } salsa = { workspace = true } serde = { workspace = true, optional = true } schemars = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } smallvec = { workspace = true } static_assertions = { workspace = true } thiserror = { workspace = true } @@ -66,7 +67,7 @@ serde = [ "ruff_source_file/serde", "ruff_python_ast/serde", ] -schemars = ["dep:schemars", "ruff_formatter/schemars"] +schemars = ["dep:schemars", "dep:serde_json", "ruff_formatter/schemars"] [lints] workspace = true diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index ec84fa65fb..5d19dec9cb 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -403,15 +403,12 @@ pub enum DocstringCodeLineWidth { #[cfg(feature = "schemars")] mod schema { use ruff_formatter::LineWidth; - use schemars::r#gen::SchemaGenerator; - use schemars::schema::{Metadata, Schema, SubschemaValidation}; + use schemars::{Schema, SchemaGenerator}; + use serde_json::Value; /// A dummy type that is used to generate a schema for `DocstringCodeLineWidth::Dynamic`. pub(super) fn dynamic(_: &mut SchemaGenerator) -> Schema { - Schema::Object(schemars::schema::SchemaObject { - const_value: Some("dynamic".to_string().into()), - ..Default::default() - }) + schemars::json_schema!({ "const": "dynamic" }) } // We use a manual schema for `fixed` even thought it isn't strictly necessary according to the @@ -422,19 +419,14 @@ mod schema { // `allOf`. There's no semantic difference between `allOf` and `oneOf` for single element lists. pub(super) fn fixed(generator: &mut SchemaGenerator) -> Schema { let schema = generator.subschema_for::(); - Schema::Object(schemars::schema::SchemaObject { - metadata: Some(Box::new(Metadata { - description: Some( - "Wrap docstring code examples at a fixed line width.".to_string(), - ), - ..Metadata::default() - })), - subschemas: Some(Box::new(SubschemaValidation { - one_of: Some(vec![schema]), - ..SubschemaValidation::default() - })), - ..Default::default() - }) + let mut schema_object = Schema::default(); + let map = schema_object.ensure_object(); + map.insert( + "description".to_string(), + Value::String("Wrap docstring code examples at a fixed line width.".to_string()), + ); + map.insert("oneOf".to_string(), Value::Array(vec![schema.into()])); + schema_object } } diff --git a/crates/ruff_python_semantic/src/imports.rs b/crates/ruff_python_semantic/src/imports.rs index 9f1188a9db..95c54ced42 100644 --- a/crates/ruff_python_semantic/src/imports.rs +++ b/crates/ruff_python_semantic/src/imports.rs @@ -274,15 +274,11 @@ impl<'de> serde::de::Deserialize<'de> for NameImports { #[cfg(feature = "schemars")] impl schemars::JsonSchema for NameImports { - fn schema_name() -> String { - "NameImports".to_string() + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("NameImports") } - fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { - schemars::schema::SchemaObject { - instance_type: Some(schemars::schema::InstanceType::String.into()), - ..Default::default() - } - .into() + fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "type": "string" }) } } diff --git a/crates/ruff_text_size/src/schemars_impls.rs b/crates/ruff_text_size/src/schemars_impls.rs index 4bc9ba2e01..9653334354 100644 --- a/crates/ruff_text_size/src/schemars_impls.rs +++ b/crates/ruff_text_size/src/schemars_impls.rs @@ -6,11 +6,12 @@ //! bindings to the Workspace API use crate::{TextRange, TextSize}; -use schemars::{JsonSchema, r#gen::SchemaGenerator, schema::Schema}; +use schemars::{JsonSchema, Schema, SchemaGenerator}; +use std::borrow::Cow; impl JsonSchema for TextSize { - fn schema_name() -> String { - String::from("TextSize") + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("TextSize") } fn json_schema(r#gen: &mut SchemaGenerator) -> Schema { @@ -21,8 +22,8 @@ impl JsonSchema for TextSize { } impl JsonSchema for TextRange { - fn schema_name() -> String { - String::from("TextRange") + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("TextRange") } fn json_schema(r#gen: &mut SchemaGenerator) -> Schema { diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index f3def3ee9b..9ef114df94 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -42,6 +42,7 @@ regex = { workspace = true } rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true, optional = true } shellexpand = { workspace = true } strum = { workspace = true } toml = { workspace = true } @@ -63,6 +64,7 @@ ignored = ["colored"] default = [] schemars = [ "dep:schemars", + "dep:serde_json", "ruff_formatter/schemars", "ruff_linter/schemars", "ruff_python_formatter/schemars", diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 0260b5f9e5..47ee0fe738 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -469,6 +469,7 @@ pub struct Options { deny_unknown_fields, rename_all = "kebab-case" )] +#[cfg_attr(feature = "schemars", schemars(!from))] pub struct LintOptions { #[serde(flatten)] pub common: LintCommonOptions, @@ -563,8 +564,8 @@ impl OptionsMetadata for DeprecatedTopLevelLintOptions { #[cfg(feature = "schemars")] impl schemars::JsonSchema for DeprecatedTopLevelLintOptions { - fn schema_name() -> String { - "DeprecatedTopLevelLintOptions".to_owned() + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("DeprecatedTopLevelLintOptions") } fn schema_id() -> std::borrow::Cow<'static, str> { std::borrow::Cow::Borrowed(concat!( @@ -573,28 +574,25 @@ impl schemars::JsonSchema for DeprecatedTopLevelLintOptions { "DeprecatedTopLevelLintOptions" )) } - fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { - use schemars::schema::Schema; + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + use serde_json::Value; - let common_schema = LintCommonOptions::json_schema(generator); - let mut schema_obj = common_schema.into_object(); - - if let Some(object) = schema_obj.object.as_mut() { - for property in object.properties.values_mut() { - if let Schema::Object(property_object) = property { - if let Some(metadata) = &mut property_object.metadata { - metadata.deprecated = true; - } else { - property_object.metadata = Some(Box::new(schemars::schema::Metadata { - deprecated: true, - ..schemars::schema::Metadata::default() - })); - } + let mut schema = LintCommonOptions::json_schema(generator); + if let Some(properties) = schema + .ensure_object() + .get_mut("properties") + .and_then(|value| value.as_object_mut()) + { + for property in properties.values_mut() { + if let Ok(property_schema) = <&mut schemars::Schema>::try_from(property) { + property_schema + .ensure_object() + .insert("deprecated".to_string(), Value::Bool(true)); } } } - Schema::Object(schema_obj) + schema } } diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index 7fe125e534..74356dac24 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -41,6 +41,7 @@ rustc-hash = { workspace = true } salsa = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true, optional = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } @@ -54,6 +55,7 @@ default = ["zstd"] deflate = ["ty_vendored/deflate"] schemars = [ "dep:schemars", + "dep:serde_json", "ruff_db/schemars", "ruff_python_ast/schemars", "ty_python_semantic/schemars", diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 08a1582f93..bcd821c53d 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -784,9 +784,7 @@ impl SrcOptions { Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize, Hash, get_size2::GetSize, )] #[serde(rename_all = "kebab-case", transparent)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct Rules { - #[cfg_attr(feature = "schemars", schemars(with = "schema::Rules"))] #[get_size(ignore)] // TODO: Add `GetSize` support for `OrderMap`. inner: OrderMap, RangedValue, BuildHasherDefault>, } @@ -1535,62 +1533,55 @@ impl std::error::Error for ToSettingsError {} #[cfg(feature = "schemars")] mod schema { - use schemars::JsonSchema; - use schemars::r#gen::SchemaGenerator; - use schemars::schema::{ - InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, SubschemaValidation, - }; - use ty_python_semantic::lint::Level; - - pub(super) struct Rules; - - impl JsonSchema for Rules { - fn schema_name() -> String { - "Rules".to_string() + impl schemars::JsonSchema for super::Rules { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Rules") } - fn json_schema(generator: &mut SchemaGenerator) -> Schema { + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + use serde_json::{Map, Value}; + let registry = ty_python_semantic::default_lint_registry(); + let level_schema = generator.subschema_for::(); - let level_schema = generator.subschema_for::(); - - let properties: schemars::Map = registry + let properties: Map = registry .lints() .iter() .map(|lint| { - ( - lint.name().to_string(), - Schema::Object(SchemaObject { - metadata: Some(Box::new(Metadata { - title: Some(lint.summary().to_string()), - description: Some(lint.documentation()), - deprecated: lint.status.is_deprecated(), - default: Some(lint.default_level.to_string().into()), - ..Metadata::default() - })), - subschemas: Some(Box::new(SubschemaValidation { - one_of: Some(vec![level_schema.clone()]), - ..Default::default() - })), - ..Default::default() - }), - ) + let mut schema = schemars::Schema::default(); + let object = schema.ensure_object(); + object.insert( + "title".to_string(), + Value::String(lint.summary().to_string()), + ); + object.insert( + "description".to_string(), + Value::String(lint.documentation()), + ); + if lint.status.is_deprecated() { + object.insert("deprecated".to_string(), Value::Bool(true)); + } + object.insert( + "default".to_string(), + Value::String(lint.default_level.to_string()), + ); + object.insert( + "oneOf".to_string(), + Value::Array(vec![level_schema.clone().into()]), + ); + + (lint.name().to_string(), schema.into()) }) .collect(); - Schema::Object(SchemaObject { - instance_type: Some(InstanceType::Object.into()), - object: Some(Box::new(ObjectValidation { - properties, - // Allow unknown rules: ty will warn about them. - // It gives a better experience when using an older ty version because - // the schema will not deny rules that have been removed in newer versions. - additional_properties: Some(Box::new(level_schema)), - ..ObjectValidation::default() - })), + let mut schema = schemars::json_schema!({ "type": "object" }); + let object = schema.ensure_object(); + object.insert("properties".to_string(), Value::Object(properties)); + // Allow unknown rules: ty will warn about them. It gives a better experience when using an older + // ty version because the schema will not deny rules that have been removed in newer versions. + object.insert("additionalProperties".to_string(), level_schema.into()); - ..Default::default() - }) + schema } } } diff --git a/crates/ty_project/src/metadata/value.rs b/crates/ty_project/src/metadata/value.rs index 95a157a451..f1f08d718a 100644 --- a/crates/ty_project/src/metadata/value.rs +++ b/crates/ty_project/src/metadata/value.rs @@ -86,7 +86,6 @@ impl Drop for ValueSourceGuard { /// or if the values were loaded from different sources. #[derive(Clone, serde::Serialize, get_size2::GetSize)] #[serde(transparent)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct RangedValue { value: T, #[serde(skip)] @@ -100,6 +99,34 @@ pub struct RangedValue { range: Option, } +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for RangedValue +where + T: schemars::JsonSchema, +{ + fn schema_name() -> std::borrow::Cow<'static, str> { + T::schema_name() + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + T::schema_id() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + T::json_schema(generator) + } + + fn _schemars_private_non_optional_json_schema( + generator: &mut schemars::SchemaGenerator, + ) -> schemars::Schema { + T::_schemars_private_non_optional_json_schema(generator) + } + + fn _schemars_private_is_option() -> bool { + T::_schemars_private_is_option() + } +} + impl RangedValue { pub fn new(value: T, source: ValueSource) -> Self { Self::with_range(value, source, TextRange::default()) diff --git a/crates/ty_python_semantic/Cargo.toml b/crates/ty_python_semantic/Cargo.toml index ef81237b5e..edeafa821f 100644 --- a/crates/ty_python_semantic/Cargo.toml +++ b/crates/ty_python_semantic/Cargo.toml @@ -43,6 +43,7 @@ rustc-hash = { workspace = true } hashbrown = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } smallvec = { workspace = true } static_assertions = { workspace = true } test-case = { workspace = true } @@ -68,6 +69,7 @@ quickcheck = { version = "1.0.3", default-features = false } quickcheck_macros = { version = "1.0.0" } [features] +schemars = ["dep:schemars", "dep:serde_json"] serde = ["ruff_db/serde", "dep:serde", "ruff_python_ast/serde"] testing = [] diff --git a/crates/ty_python_semantic/src/python_platform.rs b/crates/ty_python_semantic/src/python_platform.rs index 6f0c0fbab4..b21424ee33 100644 --- a/crates/ty_python_semantic/src/python_platform.rs +++ b/crates/ty_python_semantic/src/python_platform.rs @@ -58,77 +58,45 @@ impl Default for PythonPlatform { mod schema { use crate::PythonPlatform; use ruff_db::RustDoc; - use schemars::_serde_json::Value; - use schemars::JsonSchema; - use schemars::r#gen::SchemaGenerator; - use schemars::schema::{Metadata, Schema, SchemaObject, SubschemaValidation}; + use schemars::{JsonSchema, Schema, SchemaGenerator}; + use serde_json::Value; impl JsonSchema for PythonPlatform { - fn schema_name() -> String { - "PythonPlatform".to_string() + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("PythonPlatform") } fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - Schema::Object(SchemaObject { - // Hard code some well known values, but allow any other string as well. - subschemas: Some(Box::new(SubschemaValidation { - any_of: Some(vec![ - Schema::Object(SchemaObject { - instance_type: Some(schemars::schema::InstanceType::String.into()), - ..SchemaObject::default() - }), - // Promote well-known values for better auto-completion. - // Using `const` over `enumValues` as recommended [here](https://github.com/SchemaStore/schemastore/blob/master/CONTRIBUTING.md#documenting-enums). - Schema::Object(SchemaObject { - const_value: Some(Value::String("all".to_string())), - metadata: Some(Box::new(Metadata { - description: Some( - "Do not make any assumptions about the target platform." - .to_string(), - ), - ..Metadata::default() - })), + fn constant(value: &str, description: &str) -> Value { + let mut schema = schemars::json_schema!({ "const": value }); + schema.ensure_object().insert( + "description".to_string(), + Value::String(description.to_string()), + ); + schema.into() + } - ..SchemaObject::default() - }), - Schema::Object(SchemaObject { - const_value: Some(Value::String("darwin".to_string())), - metadata: Some(Box::new(Metadata { - description: Some("Darwin".to_string()), - ..Metadata::default() - })), + // Hard code some well known values, but allow any other string as well. + let mut any_of = vec![schemars::json_schema!({ "type": "string" }).into()]; + // Promote well-known values for better auto-completion. + // Using `const` over `enumValues` as recommended [here](https://github.com/SchemaStore/schemastore/blob/master/CONTRIBUTING.md#documenting-enums). + any_of.push(constant( + "all", + "Do not make any assumptions about the target platform.", + )); + any_of.push(constant("darwin", "Darwin")); + any_of.push(constant("linux", "Linux")); + any_of.push(constant("win32", "Windows")); - ..SchemaObject::default() - }), - Schema::Object(SchemaObject { - const_value: Some(Value::String("linux".to_string())), - metadata: Some(Box::new(Metadata { - description: Some("Linux".to_string()), - ..Metadata::default() - })), + let mut schema = Schema::default(); + let object = schema.ensure_object(); + object.insert("anyOf".to_string(), Value::Array(any_of)); + object.insert( + "description".to_string(), + Value::String(::rust_doc().to_string()), + ); - ..SchemaObject::default() - }), - Schema::Object(SchemaObject { - const_value: Some(Value::String("win32".to_string())), - metadata: Some(Box::new(Metadata { - description: Some("Windows".to_string()), - ..Metadata::default() - })), - - ..SchemaObject::default() - }), - ]), - - ..SubschemaValidation::default() - })), - metadata: Some(Box::new(Metadata { - description: Some(::rust_doc().to_string()), - ..Metadata::default() - })), - - ..SchemaObject::default() - }) + schema } } } diff --git a/ruff.schema.json b/ruff.schema.json index b44f308d65..1917af7f6d 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4,12 +4,12 @@ "type": "object", "properties": { "allowed-confusables": { - "description": "A list of allowed \"confusable\" Unicode characters to ignore when enforcing `RUF001`, `RUF002`, and `RUF003`.", - "deprecated": true, + "description": "A list of allowed \"confusable\" Unicode characters to ignore when\nenforcing `RUF001`, `RUF002`, and `RUF003`.", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string", "maxLength": 1, @@ -28,7 +28,7 @@ ] }, "builtins": { - "description": "A list of builtins to treat as defined references, in addition to the system builtins.", + "description": "A list of builtins to treat as defined references, in addition to the\nsystem builtins.", "type": [ "array", "null" @@ -38,22 +38,22 @@ } }, "cache-dir": { - "description": "A path to the cache directory.\n\nBy default, Ruff stores cache results in a `.ruff_cache` directory in the current project root.\n\nHowever, Ruff will also respect the `RUFF_CACHE_DIR` environment variable, which takes precedence over that default.\n\nThis setting will override even the `RUFF_CACHE_DIR` environment variable, if set.", + "description": "A path to the cache directory.\n\nBy default, Ruff stores cache results in a `.ruff_cache` directory in\nthe current project root.\n\nHowever, Ruff will also respect the `RUFF_CACHE_DIR` environment\nvariable, which takes precedence over that default.\n\nThis setting will override even the `RUFF_CACHE_DIR` environment\nvariable, if set.", "type": [ "string", "null" ] }, "dummy-variable-rgx": { - "description": "A regular expression used to identify \"dummy\" variables, or those which should be ignored when enforcing (e.g.) unused-variable rules. The default expression matches `_`, `__`, and `_var`, but not `_var_`.", - "deprecated": true, + "description": "A regular expression used to identify \"dummy\" variables, or those which\nshould be ignored when enforcing (e.g.) unused-variable rules. The\ndefault expression matches `_`, `__`, and `_var`, but not `_var_`.", "type": [ "string", "null" - ] + ], + "deprecated": true }, "exclude": { - "description": "A list of file patterns to exclude from formatting and linting.\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).\n\nNote that you'll typically want to use [`extend-exclude`](#extend-exclude) to modify the excluded paths.", + "description": "A list of file patterns to exclude from formatting and linting.\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory\n named `.mypy_cache` in the tree), `foo.py` (to exclude any file named\n `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ).\n- Relative patterns, like `directory/foo.py` (to exclude that specific\n file) or `directory/*.py` (to exclude any Python files in\n `directory`). Note that these paths are relative to the project root\n (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).\n\nNote that you'll typically want to use\n[`extend-exclude`](#extend-exclude) to modify the excluded paths.", "type": [ "array", "null" @@ -63,22 +63,22 @@ } }, "explicit-preview-rules": { - "description": "Whether to require exact codes to select preview rules. When enabled, preview rules will not be selected by prefixes — the full code of each preview rule will be required to enable the rule.", - "deprecated": true, + "description": "Whether to require exact codes to select preview rules. When enabled,\npreview rules will not be selected by prefixes — the full code of each\npreview rule will be required to enable the rule.", "type": [ "boolean", "null" - ] + ], + "deprecated": true }, "extend": { - "description": "A path to a local `pyproject.toml` file to merge into this configuration. User home directory and environment variables will be expanded.\n\nTo resolve the current `pyproject.toml` file, Ruff will first resolve this base configuration file, then merge in any properties defined in the current configuration file.", + "description": "A path to a local `pyproject.toml` file to merge into this\nconfiguration. User home directory and environment variables will be\nexpanded.\n\nTo resolve the current `pyproject.toml` file, Ruff will first resolve\nthis base configuration file, then merge in any properties defined\nin the current configuration file.", "type": [ "string", "null" ] }, "extend-exclude": { - "description": "A list of file patterns to omit from formatting and linting, in addition to those specified by [`exclude`](#exclude).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to omit from formatting and linting, in addition to those\nspecified by [`exclude`](#exclude).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory\n named `.mypy_cache` in the tree), `foo.py` (to exclude any file named\n `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ).\n- Relative patterns, like `directory/foo.py` (to exclude that specific\n file) or `directory/*.py` (to exclude any Python files in\n `directory`). Note that these paths are relative to the project root\n (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -88,29 +88,29 @@ } }, "extend-fixable": { - "description": "A list of rule codes or prefixes to consider fixable, in addition to those specified by [`fixable`](#lint_fixable).", - "deprecated": true, + "description": "A list of rule codes or prefixes to consider fixable, in addition to those\nspecified by [`fixable`](#lint_fixable).", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-ignore": { - "description": "A list of rule codes or prefixes to ignore, in addition to those specified by `ignore`.", - "deprecated": true, + "description": "A list of rule codes or prefixes to ignore, in addition to those\nspecified by `ignore`.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-include": { - "description": "A list of file patterns to include when linting, in addition to those specified by [`include`](#include).\n\nInclusion are based on globs, and should be single-path patterns, like `*.pyw`, to include any file with the `.pyw` extension.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to include when linting, in addition to those\nspecified by [`include`](#include).\n\nInclusion are based on globs, and should be single-path patterns, like\n`*.pyw`, to include any file with the `.pyw` extension.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -120,8 +120,7 @@ } }, "extend-per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, in addition to any rules excluded by [`per-file-ignores`](#lint_per-file-ignores).", - "deprecated": true, + "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, in addition to any rules excluded by [`per-file-ignores`](#lint_per-file-ignores).", "type": [ "object", "null" @@ -131,65 +130,66 @@ "items": { "$ref": "#/definitions/RuleSelector" } - } + }, + "deprecated": true }, "extend-safe-fixes": { - "description": "A list of rule codes or prefixes for which unsafe fixes should be considered safe.", - "deprecated": true, + "description": "A list of rule codes or prefixes for which unsafe fixes should be considered\nsafe.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-select": { - "description": "A list of rule codes or prefixes to enable, in addition to those specified by [`select`](#lint_select).", - "deprecated": true, + "description": "A list of rule codes or prefixes to enable, in addition to those\nspecified by [`select`](#lint_select).", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-unfixable": { - "description": "A list of rule codes or prefixes to consider non-auto-fixable, in addition to those specified by [`unfixable`](#lint_unfixable).", - "deprecated": true, + "description": "A list of rule codes or prefixes to consider non-auto-fixable, in addition to those\nspecified by [`unfixable`](#lint_unfixable).", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-unsafe-fixes": { - "description": "A list of rule codes or prefixes for which safe fixes should be considered unsafe.", - "deprecated": true, + "description": "A list of rule codes or prefixes for which safe fixes should be considered\nunsafe.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "external": { - "description": "A list of rule codes or prefixes that are unsupported by Ruff, but should be preserved when (e.g.) validating `# noqa` directives. Useful for retaining `# noqa` directives that cover plugins not yet implemented by Ruff.", - "deprecated": true, + "description": "A list of rule codes or prefixes that are unsupported by Ruff, but should be\npreserved when (e.g.) validating `# noqa` directives. Useful for\nretaining `# noqa` directives that cover plugins not yet implemented\nby Ruff.", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "fix": { - "description": "Enable fix behavior by-default when running `ruff` (overridden by the `--fix` and `--no-fix` command-line flags). Only includes automatic fixes unless `--unsafe-fixes` is provided.", + "description": "Enable fix behavior by-default when running `ruff` (overridden\nby the `--fix` and `--no-fix` command-line flags).\nOnly includes automatic fixes unless `--unsafe-fixes` is provided.", "type": [ "boolean", "null" @@ -203,19 +203,18 @@ ] }, "fixable": { - "description": "A list of rule codes or prefixes to consider fixable. By default, all rules are considered fixable.", - "deprecated": true, + "description": "A list of rule codes or prefixes to consider fixable. By default,\nall rules are considered fixable.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "flake8-annotations": { "description": "Options for the `flake8-annotations` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8AnnotationsOptions" @@ -223,11 +222,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-bandit": { "description": "Options for the `flake8-bandit` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8BanditOptions" @@ -235,11 +234,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-boolean-trap": { "description": "Options for the `flake8-boolean-trap` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8BooleanTrapOptions" @@ -247,11 +246,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-bugbear": { "description": "Options for the `flake8-bugbear` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8BugbearOptions" @@ -259,11 +258,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-builtins": { "description": "Options for the `flake8-builtins` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8BuiltinsOptions" @@ -271,11 +270,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-comprehensions": { "description": "Options for the `flake8-comprehensions` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8ComprehensionsOptions" @@ -283,11 +282,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-copyright": { "description": "Options for the `flake8-copyright` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8CopyrightOptions" @@ -295,11 +294,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-errmsg": { "description": "Options for the `flake8-errmsg` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8ErrMsgOptions" @@ -307,11 +306,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-gettext": { "description": "Options for the `flake8-gettext` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8GetTextOptions" @@ -319,11 +318,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-implicit-str-concat": { "description": "Options for the `flake8-implicit-str-concat` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8ImplicitStrConcatOptions" @@ -331,11 +330,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-import-conventions": { "description": "Options for the `flake8-import-conventions` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8ImportConventionsOptions" @@ -343,11 +342,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-pytest-style": { "description": "Options for the `flake8-pytest-style` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8PytestStyleOptions" @@ -355,11 +354,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-quotes": { "description": "Options for the `flake8-quotes` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8QuotesOptions" @@ -367,11 +366,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-self": { "description": "Options for the `flake8_self` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8SelfOptions" @@ -379,11 +378,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-tidy-imports": { "description": "Options for the `flake8-tidy-imports` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8TidyImportsOptions" @@ -391,11 +390,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-type-checking": { "description": "Options for the `flake8-type-checking` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8TypeCheckingOptions" @@ -403,11 +402,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "flake8-unused-arguments": { "description": "Options for the `flake8-unused-arguments` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Flake8UnusedArgumentsOptions" @@ -415,10 +414,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "force-exclude": { - "description": "Whether to enforce [`exclude`](#exclude) and [`extend-exclude`](#extend-exclude) patterns, even for paths that are passed to Ruff explicitly. Typically, Ruff will lint any paths passed in directly, even if they would typically be excluded. Setting `force-exclude = true` will cause Ruff to respect these exclusions unequivocally.\n\nThis is useful for [`pre-commit`](https://pre-commit.com/), which explicitly passes all changed files to the [`ruff-pre-commit`](https://github.com/astral-sh/ruff-pre-commit) plugin, regardless of whether they're marked as excluded by Ruff's own settings.", + "description": "Whether to enforce [`exclude`](#exclude) and [`extend-exclude`](#extend-exclude) patterns,\neven for paths that are passed to Ruff explicitly. Typically, Ruff will lint\nany paths passed in directly, even if they would typically be\nexcluded. Setting `force-exclude = true` will cause Ruff to\nrespect these exclusions unequivocally.\n\nThis is useful for [`pre-commit`](https://pre-commit.com/), which explicitly passes all\nchanged files to the [`ruff-pre-commit`](https://github.com/astral-sh/ruff-pre-commit)\nplugin, regardless of whether they're marked as excluded by Ruff's own\nsettings.", "type": [ "boolean", "null" @@ -436,26 +436,26 @@ ] }, "ignore": { - "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact rules (like `F841`), entire categories (like `F`), or anything in between.\n\nWhen breaking ties between enabled and disabled rules (via `select` and `ignore`, respectively), more specific prefixes override less specific prefixes. `ignore` takes precedence over `select` if the same prefix appears in both.", - "deprecated": true, + "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the same\nprefix appears in both.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "ignore-init-module-imports": { - "description": "Avoid automatically removing unused imports in `__init__.py` files. Such imports will still be flagged, but with a dedicated message suggesting that the import is either added to the module's `__all__` symbol, or re-exported with a redundant alias (e.g., `import os as os`).\n\nThis option is enabled by default, but you can opt-in to removal of imports via an unsafe fix.", - "deprecated": true, + "description": "Avoid automatically removing unused imports in `__init__.py` files. Such\nimports will still be flagged, but with a dedicated message suggesting\nthat the import is either added to the module's `__all__` symbol, or\nre-exported with a redundant alias (e.g., `import os as os`).\n\nThis option is enabled by default, but you can opt-in to removal of imports\nvia an unsafe fix.", "type": [ "boolean", "null" - ] + ], + "deprecated": true }, "include": { - "description": "A list of file patterns to include when linting.\n\nInclusion are based on globs, and should be single-path patterns, like `*.pyw`, to include any file with the `.pyw` extension. `pyproject.toml` is included here not for configuration but because we lint whether e.g. the `[project]` matches the schema.\n\nNotebook files (`.ipynb` extension) are included by default on Ruff 0.6.0+.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to include when linting.\n\nInclusion are based on globs, and should be single-path patterns, like\n`*.pyw`, to include any file with the `.pyw` extension. `pyproject.toml` is\nincluded here not for configuration but because we lint whether e.g. the\n`[project]` matches the schema.\n\nNotebook files (`.ipynb` extension) are included by default on Ruff 0.6.0+.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -465,7 +465,7 @@ } }, "indent-width": { - "description": "The number of spaces per indentation level (tab).\n\nUsed by the formatter and when enforcing long-line violations (like `E501`) to determine the visual width of a tab.\n\nThis option changes the number of spaces the formatter inserts when using soft-tabs (`indent-style = space`).\n\nPEP 8 recommends using 4 spaces per [indentation level](https://peps.python.org/pep-0008/#indentation).", + "description": "The number of spaces per indentation level (tab).\n\nUsed by the formatter and when enforcing long-line violations (like `E501`) to determine the visual\nwidth of a tab.\n\nThis option changes the number of spaces the formatter inserts when\nusing soft-tabs (`indent-style = space`).\n\nPEP 8 recommends using 4 spaces per [indentation level](https://peps.python.org/pep-0008/#indentation).", "anyOf": [ { "$ref": "#/definitions/IndentWidth" @@ -477,7 +477,6 @@ }, "isort": { "description": "Options for the `isort` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/IsortOptions" @@ -485,10 +484,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "line-length": { - "description": "The line length to use when enforcing long-lines violations (like `E501`) and at which `isort` and the formatter prefers to wrap lines.\n\nThe length is determined by the number of characters per line, except for lines containing East Asian characters or emojis. For these lines, the [unicode width](https://unicode.org/reports/tr11/) of each character is added up to determine the length.\n\nThe value must be greater than `0` and less than or equal to `320`.\n\nNote: While the formatter will attempt to format lines such that they remain within the `line-length`, it isn't a hard upper bound, and formatted lines may exceed the `line-length`.\n\nSee [`pycodestyle.max-line-length`](#lint_pycodestyle_max-line-length) to configure different lengths for `E501` and the formatter.", + "description": "The line length to use when enforcing long-lines violations (like `E501`)\nand at which `isort` and the formatter prefers to wrap lines.\n\nThe length is determined by the number of characters per line, except for lines containing East Asian characters or emojis.\nFor these lines, the [unicode width](https://unicode.org/reports/tr11/) of each character is added up to determine the length.\n\nThe value must be greater than `0` and less than or equal to `320`.\n\nNote: While the formatter will attempt to format lines such that they remain\nwithin the `line-length`, it isn't a hard upper bound, and formatted lines may\nexceed the `line-length`.\n\nSee [`pycodestyle.max-line-length`](#lint_pycodestyle_max-line-length) to configure different lengths for `E501` and the formatter.", "anyOf": [ { "$ref": "#/definitions/LineLength" @@ -509,19 +509,18 @@ ] }, "logger-objects": { - "description": "A list of objects that should be treated equivalently to a `logging.Logger` object.\n\nThis is useful for ensuring proper diagnostics (e.g., to identify `logging` deprecations and other best-practices) for projects that re-export a `logging.Logger` object from a common module.\n\nFor example, if you have a module `logging_setup.py` with the following contents: ```python import logging\n\nlogger = logging.getLogger(__name__) ```\n\nAdding `\"logging_setup.logger\"` to `logger-objects` will ensure that `logging_setup.logger` is treated as a `logging.Logger` object when imported from other modules (e.g., `from logging_setup import logger`).", - "deprecated": true, + "description": "A list of objects that should be treated equivalently to a\n`logging.Logger` object.\n\nThis is useful for ensuring proper diagnostics (e.g., to identify\n`logging` deprecations and other best-practices) for projects that\nre-export a `logging.Logger` object from a common module.\n\nFor example, if you have a module `logging_setup.py` with the following\ncontents:\n```python\nimport logging\n\nlogger = logging.getLogger(__name__)\n```\n\nAdding `\"logging_setup.logger\"` to `logger-objects` will ensure that\n`logging_setup.logger` is treated as a `logging.Logger` object when\nimported from other modules (e.g., `from logging_setup import logger`).", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "mccabe": { "description": "Options for the `mccabe` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/McCabeOptions" @@ -529,10 +528,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "namespace-packages": { - "description": "Mark the specified directories as namespace packages. For the purpose of module resolution, Ruff will treat those directories and all their subdirectories as if they contained an `__init__.py` file.", + "description": "Mark the specified directories as namespace packages. For the purpose of\nmodule resolution, Ruff will treat those directories and all their subdirectories\nas if they contained an `__init__.py` file.", "type": [ "array", "null" @@ -542,7 +542,7 @@ } }, "output-format": { - "description": "The style in which violation messages should be formatted: `\"full\"` (default) (shows source), `\"concise\"`, `\"grouped\"` (group messages by file), `\"json\"` (machine-readable), `\"junit\"` (machine-readable XML), `\"github\"` (GitHub Actions annotations), `\"gitlab\"` (GitLab CI code quality report), `\"pylint\"` (Pylint text format) or `\"azure\"` (Azure Pipeline logging commands).", + "description": "The style in which violation messages should be formatted: `\"full\"` (default)\n(shows source), `\"concise\"`, `\"grouped\"` (group messages by file), `\"json\"`\n(machine-readable), `\"junit\"` (machine-readable XML), `\"github\"` (GitHub\nActions annotations), `\"gitlab\"` (GitLab CI code quality report),\n`\"pylint\"` (Pylint text format) or `\"azure\"` (Azure Pipeline logging commands).", "anyOf": [ { "$ref": "#/definitions/OutputFormat" @@ -554,7 +554,6 @@ }, "pep8-naming": { "description": "Options for the `pep8-naming` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/Pep8NamingOptions" @@ -562,11 +561,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, when considering any matching files. An initial '!' negates the file pattern.", - "deprecated": true, + "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, when considering any matching files. An initial '!' negates\nthe file pattern.", "type": [ "object", "null" @@ -576,10 +575,11 @@ "items": { "$ref": "#/definitions/RuleSelector" } - } + }, + "deprecated": true }, "per-file-target-version": { - "description": "A list of mappings from glob-style file pattern to Python version to use when checking the corresponding file(s).\n\nThis may be useful for overriding the global Python version settings in `target-version` or `requires-python` for a subset of files. For example, if you have a project with a minimum supported Python version of 3.9 but a subdirectory of developer scripts that want to use a newer feature like the `match` statement from Python 3.10, you can use `per-file-target-version` to specify `\"developer_scripts/*.py\" = \"py310\"`.\n\nThis setting is used by the linter to enforce any enabled version-specific lint rules, as well as by the formatter for any version-specific formatting options, such as parenthesizing context managers on Python 3.10+.", + "description": "A list of mappings from glob-style file pattern to Python version to use when checking the\ncorresponding file(s).\n\nThis may be useful for overriding the global Python version settings in `target-version` or\n`requires-python` for a subset of files. For example, if you have a project with a minimum\nsupported Python version of 3.9 but a subdirectory of developer scripts that want to use a\nnewer feature like the `match` statement from Python 3.10, you can use\n`per-file-target-version` to specify `\"developer_scripts/*.py\" = \"py310\"`.\n\nThis setting is used by the linter to enforce any enabled version-specific lint rules, as\nwell as by the formatter for any version-specific formatting options, such as parenthesizing\ncontext managers on Python 3.10+.", "type": [ "object", "null" @@ -589,7 +589,7 @@ } }, "preview": { - "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will use unstable rules, fixes, and formatting.", + "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will\nuse unstable rules, fixes, and formatting.", "type": [ "boolean", "null" @@ -597,7 +597,6 @@ }, "pycodestyle": { "description": "Options for the `pycodestyle` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/PycodestyleOptions" @@ -605,11 +604,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "pydocstyle": { "description": "Options for the `pydocstyle` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/PydocstyleOptions" @@ -617,11 +616,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "pyflakes": { "description": "Options for the `pyflakes` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/PyflakesOptions" @@ -629,11 +628,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "pylint": { "description": "Options for the `pylint` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/PylintOptions" @@ -641,11 +640,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "pyupgrade": { "description": "Options for the `pyupgrade` plugin.", - "deprecated": true, "anyOf": [ { "$ref": "#/definitions/PyUpgradeOptions" @@ -653,10 +652,11 @@ { "type": "null" } - ] + ], + "deprecated": true }, "required-version": { - "description": "Enforce a requirement on the version of Ruff, to enforce at runtime. If the version of Ruff does not meet the requirement, Ruff will exit with an error.\n\nUseful for unifying results across many environments, e.g., with a `pyproject.toml` file.\n\nAccepts a [PEP 440](https://peps.python.org/pep-0440/) specifier, like `==0.3.1` or `>=0.3.1`.", + "description": "Enforce a requirement on the version of Ruff, to enforce at runtime.\nIf the version of Ruff does not meet the requirement, Ruff will exit\nwith an error.\n\nUseful for unifying results across many environments, e.g., with a\n`pyproject.toml` file.\n\nAccepts a [PEP 440](https://peps.python.org/pep-0440/) specifier, like `==0.3.1` or `>=0.3.1`.", "anyOf": [ { "$ref": "#/definitions/RequiredVersion" @@ -667,32 +667,32 @@ ] }, "respect-gitignore": { - "description": "Whether to automatically exclude files that are ignored by `.ignore`, `.gitignore`, `.git/info/exclude`, and global `gitignore` files. Enabled by default.", + "description": "Whether to automatically exclude files that are ignored by `.ignore`,\n`.gitignore`, `.git/info/exclude`, and global `gitignore` files.\nEnabled by default.", "type": [ "boolean", "null" ] }, "select": { - "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact rules (like `F841`), entire categories (like `F`), or anything in between.\n\nWhen breaking ties between enabled and disabled rules (via `select` and `ignore`, respectively), more specific prefixes override less specific prefixes. `ignore` takes precedence over `select` if the same prefix appears in both.", - "deprecated": true, + "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the\nsame prefix appears in both.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "show-fixes": { - "description": "Whether to show an enumeration of all fixed lint violations (overridden by the `--show-fixes` command-line flag).", + "description": "Whether to show an enumeration of all fixed lint violations\n(overridden by the `--show-fixes` command-line flag).", "type": [ "boolean", "null" ] }, "src": { - "description": "The directories to consider when resolving first- vs. third-party imports.\n\nWhen omitted, the `src` directory will typically default to including both:\n\n1. The directory containing the nearest `pyproject.toml`, `ruff.toml`, or `.ruff.toml` file (the \"project root\"). 2. The `\"src\"` subdirectory of the project root.\n\nThese defaults ensure that Ruff supports both flat layouts and `src` layouts out-of-the-box. (If a configuration file is explicitly provided (e.g., via the `--config` command-line flag), the current working directory will be considered the project root.)\n\nAs an example, consider an alternative project structure, like:\n\n```text my_project ├── pyproject.toml └── lib └── my_package ├── __init__.py ├── foo.py └── bar.py ```\n\nIn this case, the `./lib` directory should be included in the `src` option (e.g., `src = [\"lib\"]`), such that when resolving imports, `my_package.foo` is considered first-party.\n\nThis field supports globs. For example, if you have a series of Python packages in a `python_modules` directory, `src = [\"python_modules/*\"]` would expand to incorporate all packages in that directory. User home directory and environment variables will also be expanded.", + "description": "The directories to consider when resolving first- vs. third-party\nimports.\n\nWhen omitted, the `src` directory will typically default to including both:\n\n1. The directory containing the nearest `pyproject.toml`, `ruff.toml`, or `.ruff.toml` file (the \"project root\").\n2. The `\"src\"` subdirectory of the project root.\n\nThese defaults ensure that Ruff supports both flat layouts and `src` layouts out-of-the-box.\n(If a configuration file is explicitly provided (e.g., via the `--config` command-line\nflag), the current working directory will be considered the project root.)\n\nAs an example, consider an alternative project structure, like:\n\n```text\nmy_project\n├── pyproject.toml\n└── lib\n └── my_package\n ├── __init__.py\n ├── foo.py\n └── bar.py\n```\n\nIn this case, the `./lib` directory should be included in the `src` option\n(e.g., `src = [\"lib\"]`), such that when resolving imports, `my_package.foo`\nis considered first-party.\n\nThis field supports globs. For example, if you have a series of Python\npackages in a `python_modules` directory, `src = [\"python_modules/*\"]`\nwould expand to incorporate all packages in that directory. User home\ndirectory and environment variables will also be expanded.", "type": [ "array", "null" @@ -702,7 +702,7 @@ } }, "target-version": { - "description": "The minimum Python version to target, e.g., when considering automatic code upgrades, like rewriting type annotations. Ruff will not propose changes using features that are not available in the given version.\n\nFor example, to represent supporting Python >=3.11 or ==3.11 specify `target-version = \"py311\"`.\n\nIf you're already using a `pyproject.toml` file, we recommend `project.requires-python` instead, as it's based on Python packaging standards, and will be respected by other tools. For example, Ruff treats the following as identical to `target-version = \"py38\"`:\n\n```toml [project] requires-python = \">=3.8\" ```\n\nIf both are specified, `target-version` takes precedence over `requires-python`. See [_Inferring the Python version_](https://docs.astral.sh/ruff/configuration/#inferring-the-python-version) for a complete description of how the `target-version` is determined when left unspecified.\n\nNote that a stub file can [sometimes make use of a typing feature](https://typing.python.org/en/latest/spec/distributing.html#syntax) before it is available at runtime, as long as the stub does not make use of new *syntax*. For example, a type checker will understand `int | str` in a stub as being a `Union` type annotation, even if the type checker is run using Python 3.9, despite the fact that the `|` operator can only be used to create union types at runtime on Python 3.10+. As such, Ruff will often recommend newer features in a stub file than it would for an equivalent runtime file with the same target version.", + "description": "The minimum Python version to target, e.g., when considering automatic\ncode upgrades, like rewriting type annotations. Ruff will not propose\nchanges using features that are not available in the given version.\n\nFor example, to represent supporting Python >=3.11 or ==3.11\nspecify `target-version = \"py311\"`.\n\nIf you're already using a `pyproject.toml` file, we recommend\n`project.requires-python` instead, as it's based on Python packaging\nstandards, and will be respected by other tools. For example, Ruff\ntreats the following as identical to `target-version = \"py38\"`:\n\n```toml\n[project]\nrequires-python = \">=3.8\"\n```\n\nIf both are specified, `target-version` takes precedence over\n`requires-python`. See [_Inferring the Python version_](https://docs.astral.sh/ruff/configuration/#inferring-the-python-version)\nfor a complete description of how the `target-version` is determined\nwhen left unspecified.\n\nNote that a stub file can [sometimes make use of a typing feature](https://typing.python.org/en/latest/spec/distributing.html#syntax)\nbefore it is available at runtime, as long as the stub does not make\nuse of new *syntax*. For example, a type checker will understand\n`int | str` in a stub as being a `Union` type annotation, even if the\ntype checker is run using Python 3.9, despite the fact that the `|`\noperator can only be used to create union types at runtime on Python\n3.10+. As such, Ruff will often recommend newer features in a stub\nfile than it would for an equivalent runtime file with the same target\nversion.", "anyOf": [ { "$ref": "#/definitions/PythonVersion" @@ -713,40 +713,40 @@ ] }, "task-tags": { - "description": "A list of task tags to recognize (e.g., \"TODO\", \"FIXME\", \"XXX\").\n\nComments starting with these tags will be ignored by commented-out code detection (`ERA`), and skipped by line-length rules (`E501`) if [`ignore-overlong-task-comments`](#lint_pycodestyle_ignore-overlong-task-comments) is set to `true`.", - "deprecated": true, + "description": "A list of task tags to recognize (e.g., \"TODO\", \"FIXME\", \"XXX\").\n\nComments starting with these tags will be ignored by commented-out code\ndetection (`ERA`), and skipped by line-length rules (`E501`) if\n[`ignore-overlong-task-comments`](#lint_pycodestyle_ignore-overlong-task-comments) is set to `true`.", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "typing-modules": { - "description": "A list of modules whose exports should be treated equivalently to members of the `typing` module.\n\nThis is useful for ensuring proper type annotation inference for projects that re-export `typing` and `typing_extensions` members from a compatibility module. If omitted, any members imported from modules apart from `typing` and `typing_extensions` will be treated as ordinary Python objects.", - "deprecated": true, + "description": "A list of modules whose exports should be treated equivalently to\nmembers of the `typing` module.\n\nThis is useful for ensuring proper type annotation inference for\nprojects that re-export `typing` and `typing_extensions` members\nfrom a compatibility module. If omitted, any members imported from\nmodules apart from `typing` and `typing_extensions` will be treated\nas ordinary Python objects.", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "unfixable": { "description": "A list of rule codes or prefixes to consider non-fixable.", - "deprecated": true, "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "unsafe-fixes": { - "description": "Enable application of unsafe fixes. If excluded, a hint will be displayed when unsafe fixes are available. If set to false, the hint will be hidden.", + "description": "Enable application of unsafe fixes.\nIf excluded, a hint will be displayed when unsafe fixes are available.\nIf set to false, the hint will be hidden.", "type": [ "boolean", "null" @@ -763,14 +763,14 @@ "type": "object", "properties": { "detect-string-imports": { - "description": "Whether to detect imports from string literals. When enabled, Ruff will search for string literals that \"look like\" import paths, and include them in the import map, if they resolve to valid Python modules.", + "description": "Whether to detect imports from string literals. When enabled, Ruff will search for string\nliterals that \"look like\" import paths, and include them in the import map, if they resolve\nto valid Python modules.", "type": [ "boolean", "null" ] }, "direction": { - "description": "Whether to generate a map from file to files that it depends on (dependencies) or files that depend on it (dependents).", + "description": "Whether to generate a map from file to files that it depends on (dependencies) or files that\ndepend on it (dependents).", "anyOf": [ { "$ref": "#/definitions/Direction" @@ -781,7 +781,7 @@ ] }, "exclude": { - "description": "A list of file patterns to exclude from analysis in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to exclude from analysis in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory\n named `.mypy_cache` in the tree), `foo.py` (to exclude any file named\n `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ).\n- Relative patterns, like `directory/foo.py` (to exclude that specific\n file) or `directory/*.py` (to exclude any Python files in\n `directory`). Note that these paths are relative to the project root\n (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -791,7 +791,7 @@ } }, "include-dependencies": { - "description": "A map from file path to the list of Python or non-Python file paths or globs that should be considered dependencies of that file, regardless of whether relevant imports are detected.", + "description": "A map from file path to the list of Python or non-Python file paths or globs that should be\nconsidered dependencies of that file, regardless of whether relevant imports are detected.", "type": [ "object", "null" @@ -804,36 +804,36 @@ } }, "preview": { - "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will expose unstable commands.", + "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will expose unstable\ncommands.", "type": [ "boolean", "null" ] }, "string-imports-min-dots": { - "description": "The minimum number of dots in a string to consider it a valid import.\n\nThis setting is only relevant when [`detect-string-imports`](#detect-string-imports) is enabled. For example, if this is set to `2`, then only strings with at least two dots (e.g., `\"path.to.module\"`) would be considered valid imports.", + "description": "The minimum number of dots in a string to consider it a valid import.\n\nThis setting is only relevant when [`detect-string-imports`](#detect-string-imports) is enabled.\nFor example, if this is set to `2`, then only strings with at least two dots (e.g., `\"path.to.module\"`)\nwould be considered valid imports.", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 } }, "additionalProperties": false }, "ApiBan": { "type": "object", - "required": [ - "msg" - ], "properties": { "msg": { "description": "The message to display when the API is used.", "type": "string" } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "msg" + ] }, "BannedAliases": { "type": "array", @@ -856,23 +856,17 @@ { "description": "Use Google-style docstrings.", "type": "string", - "enum": [ - "google" - ] + "const": "google" }, { "description": "Use NumPy-style docstrings.", "type": "string", - "enum": [ - "numpy" - ] + "const": "numpy" }, { "description": "Use PEP257-style docstrings.", "type": "string", - "enum": [ - "pep257" - ] + "const": "pep257" } ] }, @@ -881,16 +875,12 @@ { "description": "Construct a map from module to its dependencies (i.e., the modules that it imports).", "type": "string", - "enum": [ - "dependencies" - ] + "const": "dependencies" }, { "description": "Construct a map from module to its dependents (i.e., the modules that import it).", "type": "string", - "enum": [ - "dependents" - ] + "const": "dependents" } ] }, @@ -915,35 +905,35 @@ "type": "object", "properties": { "allow-star-arg-any": { - "description": "Whether to suppress `ANN401` for dynamically typed `*args` and `**kwargs` arguments.", + "description": "Whether to suppress `ANN401` for dynamically typed `*args` and\n`**kwargs` arguments.", "type": [ "boolean", "null" ] }, "ignore-fully-untyped": { - "description": "Whether to suppress `ANN*` rules for any declaration that hasn't been typed at all. This makes it easier to gradually add types to a codebase.", + "description": "Whether to suppress `ANN*` rules for any declaration\nthat hasn't been typed at all.\nThis makes it easier to gradually add types to a codebase.", "type": [ "boolean", "null" ] }, "mypy-init-return": { - "description": "Whether to allow the omission of a return type hint for `__init__` if at least one argument is annotated.", + "description": "Whether to allow the omission of a return type hint for `__init__` if at\nleast one argument is annotated.", "type": [ "boolean", "null" ] }, "suppress-dummy-args": { - "description": "Whether to suppress `ANN000`-level violations for arguments matching the \"dummy\" variable regex (like `_`).", + "description": "Whether to suppress `ANN000`-level violations for arguments matching the\n\"dummy\" variable regex (like `_`).", "type": [ "boolean", "null" ] }, "suppress-none-returning": { - "description": "Whether to suppress `ANN200`-level violations for functions that meet either of the following criteria:\n\n- Contain no `return` statement. - Explicit `return` statement(s) all return `None` (explicitly or implicitly).", + "description": "Whether to suppress `ANN200`-level violations for functions that meet\neither of the following criteria:\n\n- Contain no `return` statement.\n- Explicit `return` statement(s) all return `None` (explicitly or\n implicitly).", "type": [ "boolean", "null" @@ -957,7 +947,7 @@ "type": "object", "properties": { "allowed-markup-calls": { - "description": "A list of callable names, whose result may be safely passed into [`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `bleach.clean`, rather than `clean`).\n\nThis setting helps you avoid false positives in code like:\n\n```python from bleach import clean from markupsafe import Markup\n\ncleaned_markup = Markup(clean(some_user_input)) ```\n\nWhere the use of [`bleach.clean`](https://bleach.readthedocs.io/en/latest/clean.html) usually ensures that there's no XSS vulnerability.\n\nAlthough it is not recommended, you may also use this setting to whitelist other kinds of calls, e.g. calls to i18n translation functions, where how safe that is will depend on the implementation and how well the translations are audited.\n\nAnother common use-case is to wrap the output of functions that generate markup like [`xml.etree.ElementTree.tostring`](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.tostring) or template rendering engines where sanitization of potential user input is either already baked in or has to happen before rendering.", + "description": "A list of callable names, whose result may be safely passed into\n[`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `bleach.clean`, rather than `clean`).\n\nThis setting helps you avoid false positives in code like:\n\n```python\nfrom bleach import clean\nfrom markupsafe import Markup\n\ncleaned_markup = Markup(clean(some_user_input))\n```\n\nWhere the use of [`bleach.clean`](https://bleach.readthedocs.io/en/latest/clean.html)\nusually ensures that there's no XSS vulnerability.\n\nAlthough it is not recommended, you may also use this setting to whitelist other\nkinds of calls, e.g. calls to i18n translation functions, where how safe that is\nwill depend on the implementation and how well the translations are audited.\n\nAnother common use-case is to wrap the output of functions that generate markup\nlike [`xml.etree.ElementTree.tostring`](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.tostring)\nor template rendering engines where sanitization of potential user input is either\nalready baked in or has to happen before rendering.", "type": [ "array", "null" @@ -967,14 +957,14 @@ } }, "check-typed-exception": { - "description": "Whether to disallow `try`-`except`-`pass` (`S110`) for specific exception types. By default, `try`-`except`-`pass` is only disallowed for `Exception` and `BaseException`.", + "description": "Whether to disallow `try`-`except`-`pass` (`S110`) for specific\nexception types. By default, `try`-`except`-`pass` is only\ndisallowed for `Exception` and `BaseException`.", "type": [ "boolean", "null" ] }, "extend-markup-names": { - "description": "A list of additional callable names that behave like [`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `webhelpers.html.literal`, rather than `literal`).", + "description": "A list of additional callable names that behave like\n[`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `webhelpers.html.literal`, rather than\n`literal`).", "type": [ "array", "null" @@ -994,7 +984,7 @@ } }, "hardcoded-tmp-directory-extend": { - "description": "A list of directories to consider temporary, in addition to those specified by [`hardcoded-tmp-directory`](#lint_flake8-bandit_hardcoded-tmp-directory) (see `S108`).", + "description": "A list of directories to consider temporary, in addition to those\nspecified by [`hardcoded-tmp-directory`](#lint_flake8-bandit_hardcoded-tmp-directory) (see `S108`).", "type": [ "array", "null" @@ -1011,7 +1001,7 @@ "type": "object", "properties": { "extend-allowed-calls": { - "description": "Additional callable functions with which to allow boolean traps.\n\nExpects to receive a list of fully-qualified names (e.g., `pydantic.Field`, rather than `Field`).", + "description": "Additional callable functions with which to allow boolean traps.\n\nExpects to receive a list of fully-qualified names (e.g., `pydantic.Field`, rather than\n`Field`).", "type": [ "array", "null" @@ -1028,7 +1018,7 @@ "type": "object", "properties": { "extend-immutable-calls": { - "description": "Additional callable functions to consider \"immutable\" when evaluating, e.g., the `function-call-in-default-argument` rule (`B008`) or `function-call-in-dataclass-defaults` rule (`RUF009`).\n\nExpects to receive a list of fully-qualified names (e.g., `fastapi.Query`, rather than `Query`).", + "description": "Additional callable functions to consider \"immutable\" when evaluating, e.g., the\n`function-call-in-default-argument` rule (`B008`) or `function-call-in-dataclass-defaults`\nrule (`RUF009`).\n\nExpects to receive a list of fully-qualified names (e.g., `fastapi.Query`, rather than\n`Query`).", "type": [ "array", "null" @@ -1056,33 +1046,33 @@ }, "builtins-allowed-modules": { "description": "DEPRECATED: This option has been renamed to `allowed-modules`. Use `allowed-modules` instead.\n\nList of builtin module names to allow.\n\nThis option is ignored if both `allowed-modules` and `builtins-allowed-modules` are set.", - "deprecated": true, "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "builtins-ignorelist": { "description": "DEPRECATED: This option has been renamed to `ignorelist`. Use `ignorelist` instead.\n\nIgnore list of builtins.\n\nThis option is ignored if both `ignorelist` and `builtins-ignorelist` are set.", - "deprecated": true, "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "builtins-strict-checking": { "description": "DEPRECATED: This option has been renamed to `strict-checking`. Use `strict-checking` instead.\n\nCompare module names instead of full module paths.\n\nThis option is ignored if both `strict-checking` and `builtins-strict-checking` are set.", - "deprecated": true, "type": [ "boolean", "null" - ] + ], + "deprecated": true }, "ignorelist": { "description": "Ignore list of builtins.", @@ -1123,23 +1113,23 @@ "type": "object", "properties": { "author": { - "description": "Author to enforce within the copyright notice. If provided, the author must be present immediately following the copyright notice.", + "description": "Author to enforce within the copyright notice. If provided, the\nauthor must be present immediately following the copyright notice.", "type": [ "string", "null" ] }, "min-file-size": { - "description": "A minimum file size (in bytes) required for a copyright notice to be enforced. By default, all files are validated.", + "description": "A minimum file size (in bytes) required for a copyright notice to\nbe enforced. By default, all files are validated.", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "notice-rgx": { - "description": "The regular expression used to match the copyright notice, compiled with the [`regex`](https://docs.rs/regex/latest/regex/) crate. Defaults to `(?i)Copyright\\s+((?:\\(C\\)|©)\\s+)?\\d{4}((-|,\\s)\\d{4})*`, which matches the following:\n\n- `Copyright 2023` - `Copyright (C) 2023` - `Copyright 2021-2023` - `Copyright (C) 2021-2023` - `Copyright (C) 2021, 2023`", + "description": "The regular expression used to match the copyright notice, compiled\nwith the [`regex`](https://docs.rs/regex/latest/regex/) crate.\nDefaults to `(?i)Copyright\\s+((?:\\(C\\)|©)\\s+)?\\d{4}((-|,\\s)\\d{4})*`, which matches\nthe following:\n\n- `Copyright 2023`\n- `Copyright (C) 2023`\n- `Copyright 2021-2023`\n- `Copyright (C) 2021-2023`\n- `Copyright (C) 2021, 2023`", "type": [ "string", "null" @@ -1159,7 +1149,7 @@ "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 } }, "additionalProperties": false @@ -1169,7 +1159,7 @@ "type": "object", "properties": { "extend-function-names": { - "description": "Additional function names to consider as internationalization calls, in addition to those included in [`function-names`](#lint_flake8-gettext_function-names).", + "description": "Additional function names to consider as internationalization calls, in addition to those\nincluded in [`function-names`](#lint_flake8-gettext_function-names).", "type": [ "array", "null" @@ -1196,7 +1186,7 @@ "type": "object", "properties": { "allow-multiline": { - "description": "Whether to allow implicit string concatenations for multiline strings. By default, implicit concatenations of multiline strings are allowed (but continuation lines, delimited with a backslash, are prohibited).\n\nSetting `allow-multiline = false` will automatically disable the `explicit-string-concatenation` (`ISC003`) rule. Otherwise, both implicit and explicit multiline string concatenations would be seen as violations, making it impossible to write a linter-compliant multiline string.", + "description": "Whether to allow implicit string concatenations for multiline strings.\nBy default, implicit concatenations of multiline strings are\nallowed (but continuation lines, delimited with a backslash, are\nprohibited).\n\nSetting `allow-multiline = false` will automatically disable the\n`explicit-string-concatenation` (`ISC003`) rule. Otherwise, both\nimplicit and explicit multiline string concatenations would be seen\nas violations, making it impossible to write a linter-compliant multiline\nstring.", "type": [ "boolean", "null" @@ -1210,7 +1200,7 @@ "type": "object", "properties": { "aliases": { - "description": "The conventional aliases for imports. These aliases can be extended by the [`extend-aliases`](#lint_flake8-import-conventions_extend-aliases) option.", + "description": "The conventional aliases for imports. These aliases can be extended by\nthe [`extend-aliases`](#lint_flake8-import-conventions_extend-aliases) option.", "type": [ "object", "null" @@ -1230,7 +1220,7 @@ } }, "banned-from": { - "description": "A list of modules that should not be imported from using the `from ... import ...` syntax.\n\nFor example, given `banned-from = [\"pandas\"]`, `from pandas import DataFrame` would be disallowed, while `import pandas` would be allowed.", + "description": "A list of modules that should not be imported from using the\n`from ... import ...` syntax.\n\nFor example, given `banned-from = [\"pandas\"]`, `from pandas import DataFrame`\nwould be disallowed, while `import pandas` would be allowed.", "type": [ "array", "null" @@ -1241,7 +1231,7 @@ "uniqueItems": true }, "extend-aliases": { - "description": "A mapping from module to conventional import alias. These aliases will be added to the [`aliases`](#lint_flake8-import-conventions_aliases) mapping.", + "description": "A mapping from module to conventional import alias. These aliases will\nbe added to the [`aliases`](#lint_flake8-import-conventions_aliases) mapping.", "type": [ "object", "null" @@ -1258,21 +1248,21 @@ "type": "object", "properties": { "fixture-parentheses": { - "description": "Boolean flag specifying whether `@pytest.fixture()` without parameters should have parentheses. If the option is set to `false` (the default), `@pytest.fixture` is valid and `@pytest.fixture()` is invalid. If set to `true`, `@pytest.fixture()` is valid and `@pytest.fixture` is invalid.", + "description": "Boolean flag specifying whether `@pytest.fixture()` without parameters\nshould have parentheses. If the option is set to `false` (the default),\n`@pytest.fixture` is valid and `@pytest.fixture()` is invalid. If set\nto `true`, `@pytest.fixture()` is valid and `@pytest.fixture` is\ninvalid.", "type": [ "boolean", "null" ] }, "mark-parentheses": { - "description": "Boolean flag specifying whether `@pytest.mark.foo()` without parameters should have parentheses. If the option is set to `false` (the default), `@pytest.mark.foo` is valid and `@pytest.mark.foo()` is invalid. If set to `true`, `@pytest.mark.foo()` is valid and `@pytest.mark.foo` is invalid.", + "description": "Boolean flag specifying whether `@pytest.mark.foo()` without parameters\nshould have parentheses. If the option is set to `false` (the\ndefault), `@pytest.mark.foo` is valid and `@pytest.mark.foo()` is\ninvalid. If set to `true`, `@pytest.mark.foo()` is valid and\n`@pytest.mark.foo` is invalid.", "type": [ "boolean", "null" ] }, "parametrize-names-type": { - "description": "Expected type for multiple argument names in `@pytest.mark.parametrize`. The following values are supported:\n\n- `csv` — a comma-separated list, e.g. `@pytest.mark.parametrize(\"name1,name2\", ...)` - `tuple` (default) — e.g. `@pytest.mark.parametrize((\"name1\", \"name2\"), ...)` - `list` — e.g. `@pytest.mark.parametrize([\"name1\", \"name2\"], ...)`", + "description": "Expected type for multiple argument names in `@pytest.mark.parametrize`.\nThe following values are supported:\n\n- `csv` — a comma-separated list, e.g.\n `@pytest.mark.parametrize(\"name1,name2\", ...)`\n- `tuple` (default) — e.g.\n `@pytest.mark.parametrize((\"name1\", \"name2\"), ...)`\n- `list` — e.g. `@pytest.mark.parametrize([\"name1\", \"name2\"], ...)`", "anyOf": [ { "$ref": "#/definitions/ParametrizeNameType" @@ -1283,7 +1273,7 @@ ] }, "parametrize-values-row-type": { - "description": "Expected type for each row of values in `@pytest.mark.parametrize` in case of multiple parameters. The following values are supported:\n\n- `tuple` (default) — e.g. `@pytest.mark.parametrize((\"name1\", \"name2\"), [(1, 2), (3, 4)])` - `list` — e.g. `@pytest.mark.parametrize((\"name1\", \"name2\"), [[1, 2], [3, 4]])`", + "description": "Expected type for each row of values in `@pytest.mark.parametrize` in\ncase of multiple parameters. The following values are supported:\n\n- `tuple` (default) — e.g.\n `@pytest.mark.parametrize((\"name1\", \"name2\"), [(1, 2), (3, 4)])`\n- `list` — e.g.\n `@pytest.mark.parametrize((\"name1\", \"name2\"), [[1, 2], [3, 4]])`", "anyOf": [ { "$ref": "#/definitions/ParametrizeValuesRowType" @@ -1294,7 +1284,7 @@ ] }, "parametrize-values-type": { - "description": "Expected type for the list of values rows in `@pytest.mark.parametrize`. The following values are supported:\n\n- `tuple` — e.g. `@pytest.mark.parametrize(\"name\", (1, 2, 3))` - `list` (default) — e.g. `@pytest.mark.parametrize(\"name\", [1, 2, 3])`", + "description": "Expected type for the list of values rows in `@pytest.mark.parametrize`.\nThe following values are supported:\n\n- `tuple` — e.g. `@pytest.mark.parametrize(\"name\", (1, 2, 3))`\n- `list` (default) — e.g. `@pytest.mark.parametrize(\"name\", [1, 2, 3])`", "anyOf": [ { "$ref": "#/definitions/ParametrizeValuesType" @@ -1305,7 +1295,7 @@ ] }, "raises-extend-require-match-for": { - "description": "List of additional exception names that require a match= parameter in a `pytest.raises()` call. This extends the default list of exceptions that require a match= parameter. This option is useful if you want to extend the default list of exceptions that require a match= parameter without having to specify the entire list. Note that this option does not remove any exceptions from the default list.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "List of additional exception names that require a match= parameter in a\n`pytest.raises()` call. This extends the default list of exceptions\nthat require a match= parameter.\nThis option is useful if you want to extend the default list of\nexceptions that require a match= parameter without having to specify\nthe entire list.\nNote that this option does not remove any exceptions from the default\nlist.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1315,7 +1305,7 @@ } }, "raises-require-match-for": { - "description": "List of exception names that require a match= parameter in a `pytest.raises()` call.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "List of exception names that require a match= parameter in a\n`pytest.raises()` call.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1325,7 +1315,7 @@ } }, "warns-extend-require-match-for": { - "description": "List of additional warning names that require a match= parameter in a `pytest.warns()` call. This extends the default list of warnings that require a match= parameter.\n\nThis option is useful if you want to extend the default list of warnings that require a match= parameter without having to specify the entire list.\n\nNote that this option does not remove any warnings from the default list.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "List of additional warning names that require a match= parameter in a\n`pytest.warns()` call. This extends the default list of warnings that\nrequire a match= parameter.\n\nThis option is useful if you want to extend the default list of warnings\nthat require a match= parameter without having to specify the entire\nlist.\n\nNote that this option does not remove any warnings from the default\nlist.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1335,7 +1325,7 @@ } }, "warns-require-match-for": { - "description": "List of warning names that require a match= parameter in a `pytest.warns()` call.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "List of warning names that require a match= parameter in a\n`pytest.warns()` call.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1352,14 +1342,14 @@ "type": "object", "properties": { "avoid-escape": { - "description": "Whether to avoid using single quotes if a string contains single quotes, or vice-versa with double quotes, as per [PEP 8](https://peps.python.org/pep-0008/#string-quotes). This minimizes the need to escape quotation marks within strings.", + "description": "Whether to avoid using single quotes if a string contains single quotes,\nor vice-versa with double quotes, as per [PEP 8](https://peps.python.org/pep-0008/#string-quotes).\nThis minimizes the need to escape quotation marks within strings.", "type": [ "boolean", "null" ] }, "docstring-quotes": { - "description": "Quote style to prefer for docstrings (either \"single\" or \"double\").\n\nWhen using the formatter, only \"double\" is compatible, as the formatter enforces double quotes for docstrings strings.", + "description": "Quote style to prefer for docstrings (either \"single\" or \"double\").\n\nWhen using the formatter, only \"double\" is compatible, as the formatter\nenforces double quotes for docstrings strings.", "anyOf": [ { "$ref": "#/definitions/Quote" @@ -1370,7 +1360,7 @@ ] }, "inline-quotes": { - "description": "Quote style to prefer for inline strings (either \"single\" or \"double\").\n\nWhen using the formatter, ensure that [`format.quote-style`](#format_quote-style) is set to the same preferred quote style.", + "description": "Quote style to prefer for inline strings (either \"single\" or\n\"double\").\n\nWhen using the formatter, ensure that [`format.quote-style`](#format_quote-style) is set to\nthe same preferred quote style.", "anyOf": [ { "$ref": "#/definitions/Quote" @@ -1381,7 +1371,7 @@ ] }, "multiline-quotes": { - "description": "Quote style to prefer for multiline strings (either \"single\" or \"double\").\n\nWhen using the formatter, only \"double\" is compatible, as the formatter enforces double quotes for multiline strings.", + "description": "Quote style to prefer for multiline strings (either \"single\" or\n\"double\").\n\nWhen using the formatter, only \"double\" is compatible, as the formatter\nenforces double quotes for multiline strings.", "anyOf": [ { "$ref": "#/definitions/Quote" @@ -1399,7 +1389,7 @@ "type": "object", "properties": { "extend-ignore-names": { - "description": "Additional names to ignore when considering `flake8-self` violations, in addition to those included in [`ignore-names`](#lint_flake8-self_ignore-names).", + "description": "Additional names to ignore when considering `flake8-self` violations,\nin addition to those included in [`ignore-names`](#lint_flake8-self_ignore-names).", "type": [ "array", "null" @@ -1426,7 +1416,7 @@ "type": "object", "properties": { "ban-relative-imports": { - "description": "Whether to ban all relative imports (`\"all\"`), or only those imports that extend into the parent module or beyond (`\"parents\"`).", + "description": "Whether to ban all relative imports (`\"all\"`), or only those imports\nthat extend into the parent module or beyond (`\"parents\"`).", "anyOf": [ { "$ref": "#/definitions/Strictness" @@ -1437,7 +1427,7 @@ ] }, "banned-api": { - "description": "Specific modules or module members that may not be imported or accessed. Note that this rule is only meant to flag accidental uses, and can be circumvented via `eval` or `importlib`.", + "description": "Specific modules or module members that may not be imported or accessed.\nNote that this rule is only meant to flag accidental uses,\nand can be circumvented via `eval` or `importlib`.", "type": [ "object", "null" @@ -1447,7 +1437,7 @@ } }, "banned-module-level-imports": { - "description": "List of specific modules that may not be imported at module level, and should instead be imported lazily (e.g., within a function definition, or an `if TYPE_CHECKING:` block, or some other nested context). This also affects the rule `import-outside-top-level` if `banned-module-level-imports` is enabled.", + "description": "List of specific modules that may not be imported at module level, and should instead be\nimported lazily (e.g., within a function definition, or an `if TYPE_CHECKING:`\nblock, or some other nested context). This also affects the rule `import-outside-top-level`\nif `banned-module-level-imports` is enabled.", "type": [ "array", "null" @@ -1464,7 +1454,7 @@ "type": "object", "properties": { "exempt-modules": { - "description": "Exempt certain modules from needing to be moved into type-checking blocks.", + "description": "Exempt certain modules from needing to be moved into type-checking\nblocks.", "type": [ "array", "null" @@ -1474,14 +1464,14 @@ } }, "quote-annotations": { - "description": "Whether to add quotes around type annotations, if doing so would allow the corresponding import to be moved into a type-checking block.\n\nFor example, in the following, Python requires that `Sequence` be available at runtime, despite the fact that it's only used in a type annotation:\n\n```python from collections.abc import Sequence\n\ndef func(value: Sequence[int]) -> None: ... ```\n\nIn other words, moving `from collections.abc import Sequence` into an `if TYPE_CHECKING:` block above would cause a runtime error, as the type would no longer be available at runtime.\n\nBy default, Ruff will respect such runtime semantics and avoid moving the import to prevent such runtime errors.\n\nSetting `quote-annotations` to `true` will instruct Ruff to add quotes around the annotation (e.g., `\"Sequence[int]\"`), which in turn enables Ruff to move the import into an `if TYPE_CHECKING:` block, like so:\n\n```python from typing import TYPE_CHECKING\n\nif TYPE_CHECKING: from collections.abc import Sequence\n\ndef func(value: \"Sequence[int]\") -> None: ... ```\n\nNote that this setting has no effect when `from __future__ import annotations` is present, as `__future__` annotations are always treated equivalently to quoted annotations. Similarly, this setting has no effect on Python versions after 3.14 because these annotations are also deferred.", + "description": "Whether to add quotes around type annotations, if doing so would allow\nthe corresponding import to be moved into a type-checking block.\n\nFor example, in the following, Python requires that `Sequence` be\navailable at runtime, despite the fact that it's only used in a type\nannotation:\n\n```python\nfrom collections.abc import Sequence\n\n\ndef func(value: Sequence[int]) -> None:\n ...\n```\n\nIn other words, moving `from collections.abc import Sequence` into an\n`if TYPE_CHECKING:` block above would cause a runtime error, as the\ntype would no longer be available at runtime.\n\nBy default, Ruff will respect such runtime semantics and avoid moving\nthe import to prevent such runtime errors.\n\nSetting `quote-annotations` to `true` will instruct Ruff to add quotes\naround the annotation (e.g., `\"Sequence[int]\"`), which in turn enables\nRuff to move the import into an `if TYPE_CHECKING:` block, like so:\n\n```python\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n from collections.abc import Sequence\n\n\ndef func(value: \"Sequence[int]\") -> None:\n ...\n```\n\nNote that this setting has no effect when `from __future__ import annotations`\nis present, as `__future__` annotations are always treated equivalently\nto quoted annotations. Similarly, this setting has no effect on Python\nversions after 3.14 because these annotations are also deferred.", "type": [ "boolean", "null" ] }, "runtime-evaluated-base-classes": { - "description": "Exempt classes that list any of the enumerated classes as a base class from needing to be moved into type-checking blocks.\n\nCommon examples include Pydantic's `pydantic.BaseModel` and SQLAlchemy's `sqlalchemy.orm.DeclarativeBase`, but can also support user-defined classes that inherit from those base classes. For example, if you define a common `DeclarativeBase` subclass that's used throughout your project (e.g., `class Base(DeclarativeBase) ...` in `base.py`), you can add it to this list (`runtime-evaluated-base-classes = [\"base.Base\"]`) to exempt models from being moved into type-checking blocks.", + "description": "Exempt classes that list any of the enumerated classes as a base class\nfrom needing to be moved into type-checking blocks.\n\nCommon examples include Pydantic's `pydantic.BaseModel` and SQLAlchemy's\n`sqlalchemy.orm.DeclarativeBase`, but can also support user-defined\nclasses that inherit from those base classes. For example, if you define\na common `DeclarativeBase` subclass that's used throughout your project\n(e.g., `class Base(DeclarativeBase) ...` in `base.py`), you can add it to\nthis list (`runtime-evaluated-base-classes = [\"base.Base\"]`) to exempt\nmodels from being moved into type-checking blocks.", "type": [ "array", "null" @@ -1491,7 +1481,7 @@ } }, "runtime-evaluated-decorators": { - "description": "Exempt classes and functions decorated with any of the enumerated decorators from being moved into type-checking blocks.\n\nCommon examples include Pydantic's `@pydantic.validate_call` decorator (for functions) and attrs' `@attrs.define` decorator (for classes).\n\nThis also supports framework decorators like FastAPI's `fastapi.FastAPI.get` which will work across assignments in the same module.\n\nFor example: ```python import fastapi\n\napp = FastAPI(\"app\")\n\n@app.get(\"/home\") def home() -> str: ... ```\n\nHere `app.get` will correctly be identified as `fastapi.FastAPI.get`.", + "description": "Exempt classes and functions decorated with any of the enumerated\ndecorators from being moved into type-checking blocks.\n\nCommon examples include Pydantic's `@pydantic.validate_call` decorator\n(for functions) and attrs' `@attrs.define` decorator (for classes).\n\nThis also supports framework decorators like FastAPI's `fastapi.FastAPI.get`\nwhich will work across assignments in the same module.\n\nFor example:\n```python\nimport fastapi\n\napp = FastAPI(\"app\")\n\n@app.get(\"/home\")\ndef home() -> str: ...\n```\n\nHere `app.get` will correctly be identified as `fastapi.FastAPI.get`.", "type": [ "array", "null" @@ -1501,7 +1491,7 @@ } }, "strict": { - "description": "Enforce `TC001`, `TC002`, and `TC003` rules even when valid runtime imports are present for the same module.\n\nSee flake8-type-checking's [strict](https://github.com/snok/flake8-type-checking#strict) option.", + "description": "Enforce `TC001`, `TC002`, and `TC003` rules even when valid runtime imports\nare present for the same module.\n\nSee flake8-type-checking's [strict](https://github.com/snok/flake8-type-checking#strict) option.", "type": [ "boolean", "null" @@ -1529,14 +1519,14 @@ "type": "object", "properties": { "docstring-code-format": { - "description": "Whether to format code snippets in docstrings.\n\nWhen this is enabled, Python code examples within docstrings are automatically reformatted.\n\nFor example, when this is enabled, the following code:\n\n```python def f(x): \"\"\" Something about `f`. And an example in doctest format:\n\n>>> f( x )\n\nMarkdown is also supported:\n\n```py f( x ) ```\n\nAs are reStructuredText literal blocks::\n\nf( x )\n\nAnd reStructuredText code blocks:\n\n.. code-block:: python\n\nf( x ) \"\"\" pass ```\n\n... will be reformatted (assuming the rest of the options are set to their defaults) as:\n\n```python def f(x): \"\"\" Something about `f`. And an example in doctest format:\n\n>>> f(x)\n\nMarkdown is also supported:\n\n```py f(x) ```\n\nAs are reStructuredText literal blocks::\n\nf(x)\n\nAnd reStructuredText code blocks:\n\n.. code-block:: python\n\nf(x) \"\"\" pass ```\n\nIf a code snippet in a docstring contains invalid Python code or if the formatter would otherwise write invalid Python code, then the code example is ignored by the formatter and kept as-is.\n\nCurrently, doctest, Markdown, reStructuredText literal blocks, and reStructuredText code blocks are all supported and automatically recognized. In the case of unlabeled fenced code blocks in Markdown and reStructuredText literal blocks, the contents are assumed to be Python and reformatted. As with any other format, if the contents aren't valid Python, then the block is left untouched automatically.", + "description": "Whether to format code snippets in docstrings.\n\nWhen this is enabled, Python code examples within docstrings are\nautomatically reformatted.\n\nFor example, when this is enabled, the following code:\n\n```python\ndef f(x):\n \"\"\"\n Something about `f`. And an example in doctest format:\n\n >>> f( x )\n\n Markdown is also supported:\n\n ```py\n f( x )\n ```\n\n As are reStructuredText literal blocks::\n\n f( x )\n\n\n And reStructuredText code blocks:\n\n .. code-block:: python\n\n f( x )\n \"\"\"\n pass\n```\n\n... will be reformatted (assuming the rest of the options are set to\ntheir defaults) as:\n\n```python\ndef f(x):\n \"\"\"\n Something about `f`. And an example in doctest format:\n\n >>> f(x)\n\n Markdown is also supported:\n\n ```py\n f(x)\n ```\n\n As are reStructuredText literal blocks::\n\n f(x)\n\n\n And reStructuredText code blocks:\n\n .. code-block:: python\n\n f(x)\n \"\"\"\n pass\n```\n\nIf a code snippet in a docstring contains invalid Python code or if the\nformatter would otherwise write invalid Python code, then the code\nexample is ignored by the formatter and kept as-is.\n\nCurrently, doctest, Markdown, reStructuredText literal blocks, and\nreStructuredText code blocks are all supported and automatically\nrecognized. In the case of unlabeled fenced code blocks in Markdown and\nreStructuredText literal blocks, the contents are assumed to be Python\nand reformatted. As with any other format, if the contents aren't valid\nPython, then the block is left untouched automatically.", "type": [ "boolean", "null" ] }, "docstring-code-line-length": { - "description": "Set the line length used when formatting code snippets in docstrings.\n\nThis only has an effect when the `docstring-code-format` setting is enabled.\n\nThe default value for this setting is `\"dynamic\"`, which has the effect of ensuring that any reformatted code examples in docstrings adhere to the global line length configuration that is used for the surrounding Python code. The point of this setting is that it takes the indentation of the docstring into account when reformatting code examples.\n\nAlternatively, this can be set to a fixed integer, which will result in the same line length limit being applied to all reformatted code examples in docstrings. When set to a fixed integer, the indent of the docstring is not taken into account. That is, this may result in lines in the reformatted code example that exceed the globally configured line length limit.\n\nFor example, when this is set to `20` and [`docstring-code-format`](#docstring-code-format) is enabled, then this code:\n\n```python def f(x): ''' Something about `f`. And an example:\n\n.. code-block:: python\n\nfoo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) ''' pass ```\n\n... will be reformatted (assuming the rest of the options are set to their defaults) as:\n\n```python def f(x): \"\"\" Something about `f`. And an example:\n\n.. code-block:: python\n\n( foo, bar, quux, ) = this_is_a_long_line( lion, hippo, lemur, bear, ) \"\"\" pass ```", + "description": "Set the line length used when formatting code snippets in docstrings.\n\nThis only has an effect when the `docstring-code-format` setting is\nenabled.\n\nThe default value for this setting is `\"dynamic\"`, which has the effect\nof ensuring that any reformatted code examples in docstrings adhere to\nthe global line length configuration that is used for the surrounding\nPython code. The point of this setting is that it takes the indentation\nof the docstring into account when reformatting code examples.\n\nAlternatively, this can be set to a fixed integer, which will result\nin the same line length limit being applied to all reformatted code\nexamples in docstrings. When set to a fixed integer, the indent of the\ndocstring is not taken into account. That is, this may result in lines\nin the reformatted code example that exceed the globally configured\nline length limit.\n\nFor example, when this is set to `20` and [`docstring-code-format`](#docstring-code-format)\nis enabled, then this code:\n\n```python\ndef f(x):\n '''\n Something about `f`. And an example:\n\n .. code-block:: python\n\n foo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear)\n '''\n pass\n```\n\n... will be reformatted (assuming the rest of the options are set\nto their defaults) as:\n\n```python\ndef f(x):\n \"\"\"\n Something about `f`. And an example:\n\n .. code-block:: python\n\n (\n foo,\n bar,\n quux,\n ) = this_is_a_long_line(\n lion,\n hippo,\n lemur,\n bear,\n )\n \"\"\"\n pass\n```", "anyOf": [ { "$ref": "#/definitions/DocstringCodeLineWidth" @@ -1547,7 +1537,7 @@ ] }, "exclude": { - "description": "A list of file patterns to exclude from formatting in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to exclude from formatting in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory\n named `.mypy_cache` in the tree), `foo.py` (to exclude any file named\n `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ).\n- Relative patterns, like `directory/foo.py` (to exclude that specific\n file) or `directory/*.py` (to exclude any Python files in\n `directory`). Note that these paths are relative to the project root\n (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1557,7 +1547,7 @@ } }, "indent-style": { - "description": "Whether to use spaces or tabs for indentation.\n\n`indent-style = \"space\"` (default):\n\n```python def f(): print(\"Hello\") # Spaces indent the `print` statement. ```\n\n`indent-style = \"tab\"`:\n\n```python def f(): print(\"Hello\") # A tab `\\t` indents the `print` statement. ```\n\nPEP 8 recommends using spaces for [indentation](https://peps.python.org/pep-0008/#indentation). We care about accessibility; if you do not need tabs for accessibility, we do not recommend you use them.\n\nSee [`indent-width`](#indent-width) to configure the number of spaces per indentation and the tab width.", + "description": "Whether to use spaces or tabs for indentation.\n\n`indent-style = \"space\"` (default):\n\n```python\ndef f():\n print(\"Hello\") # Spaces indent the `print` statement.\n```\n\n`indent-style = \"tab\"`:\n\n```python\ndef f():\n print(\"Hello\") # A tab `\\t` indents the `print` statement.\n```\n\nPEP 8 recommends using spaces for [indentation](https://peps.python.org/pep-0008/#indentation).\nWe care about accessibility; if you do not need tabs for accessibility, we do not recommend you use them.\n\nSee [`indent-width`](#indent-width) to configure the number of spaces per indentation and the tab width.", "anyOf": [ { "$ref": "#/definitions/IndentStyle" @@ -1568,7 +1558,7 @@ ] }, "line-ending": { - "description": "The character Ruff uses at the end of a line.\n\n* `auto`: The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to `\\n` for files that contain no line endings. * `lf`: Line endings will be converted to `\\n`. The default line ending on Unix. * `cr-lf`: Line endings will be converted to `\\r\\n`. The default line ending on Windows. * `native`: Line endings will be converted to `\\n` on Unix and `\\r\\n` on Windows.", + "description": "The character Ruff uses at the end of a line.\n\n* `auto`: The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to `\\n` for files that contain no line endings.\n* `lf`: Line endings will be converted to `\\n`. The default line ending on Unix.\n* `cr-lf`: Line endings will be converted to `\\r\\n`. The default line ending on Windows.\n* `native`: Line endings will be converted to `\\n` on Unix and `\\r\\n` on Windows.", "anyOf": [ { "$ref": "#/definitions/LineEnding" @@ -1586,7 +1576,7 @@ ] }, "quote-style": { - "description": "Configures the preferred quote character for strings. The recommended options are\n\n* `double` (default): Use double quotes `\"` * `single`: Use single quotes `'`\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), Ruff prefers double quotes for triple quoted strings and docstrings even when using `quote-style = \"single\"`.\n\nRuff deviates from using the configured quotes if doing so prevents the need for escaping quote characters inside the string:\n\n```python a = \"a string without any quotes\" b = \"It's monday morning\" ```\n\nRuff will change the quotes of the string assigned to `a` to single quotes when using `quote-style = \"single\"`. However, Ruff uses double quotes for the string assigned to `b` because using single quotes would require escaping the `'`, which leads to the less readable code: `'It\\'s monday morning'`.\n\nIn addition, Ruff supports the quote style `preserve` for projects that already use a mixture of single and double quotes and can't migrate to the `double` or `single` style. The quote style `preserve` leaves the quotes of all strings unchanged.", + "description": "Configures the preferred quote character for strings. The recommended options are\n\n* `double` (default): Use double quotes `\"`\n* `single`: Use single quotes `'`\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/),\nRuff prefers double quotes for triple quoted strings and docstrings even when using `quote-style = \"single\"`.\n\nRuff deviates from using the configured quotes if doing so prevents the need for\nescaping quote characters inside the string:\n\n```python\na = \"a string without any quotes\"\nb = \"It's monday morning\"\n```\n\nRuff will change the quotes of the string assigned to `a` to single quotes when using `quote-style = \"single\"`.\nHowever, Ruff uses double quotes for the string assigned to `b` because using single quotes would require escaping the `'`,\nwhich leads to the less readable code: `'It\\'s monday morning'`.\n\nIn addition, Ruff supports the quote style `preserve` for projects that already use\na mixture of single and double quotes and can't migrate to the `double` or `single` style.\nThe quote style `preserve` leaves the quotes of all strings unchanged.", "anyOf": [ { "$ref": "#/definitions/QuoteStyle" @@ -1597,7 +1587,7 @@ ] }, "skip-magic-trailing-comma": { - "description": "Ruff uses existing trailing commas as an indication that short lines should be left separate. If this option is set to `true`, the magic trailing comma is ignored.\n\nFor example, Ruff leaves the arguments separate even though collapsing the arguments to a single line doesn't exceed the line length if `skip-magic-trailing-comma = false`:\n\n```python # The arguments remain on separate lines because of the trailing comma after `b` def test( a, b, ): pass ```\n\nSetting `skip-magic-trailing-comma = true` changes the formatting to:\n\n```python # The arguments are collapsed to a single line because the trailing comma is ignored def test(a, b): pass ```", + "description": "Ruff uses existing trailing commas as an indication that short lines should be left separate.\nIf this option is set to `true`, the magic trailing comma is ignored.\n\nFor example, Ruff leaves the arguments separate even though\ncollapsing the arguments to a single line doesn't exceed the line length if `skip-magic-trailing-comma = false`:\n\n```python\n# The arguments remain on separate lines because of the trailing comma after `b`\ndef test(\n a,\n b,\n): pass\n```\n\nSetting `skip-magic-trailing-comma = true` changes the formatting to:\n\n```python\n# The arguments are collapsed to a single line because the trailing comma is ignored\ndef test(a, b):\n pass\n```", "type": [ "boolean", "null" @@ -1631,16 +1621,12 @@ { "description": "Use tabs to indent code.", "type": "string", - "enum": [ - "tab" - ] + "const": "tab" }, { "description": "Use [`IndentWidth`] spaces to indent code.", "type": "string", - "enum": [ - "space" - ] + "const": "space" } ] }, @@ -1648,21 +1634,22 @@ "description": "The size of a tab.", "type": "integer", "format": "uint8", - "minimum": 1.0 + "maximum": 255, + "minimum": 1 }, "IsortOptions": { "description": "Options for the `isort` plugin.", "type": "object", "properties": { "case-sensitive": { - "description": "Sort imports taking into account case sensitivity.\n\nNote that the [`order-by-type`](#lint_isort_order-by-type) setting will take precedence over this one when enabled.", + "description": "Sort imports taking into account case sensitivity.\n\nNote that the [`order-by-type`](#lint_isort_order-by-type) setting will\ntake precedence over this one when enabled.", "type": [ "boolean", "null" ] }, "classes": { - "description": "An override list of tokens to always recognize as a Class for [`order-by-type`](#lint_isort_order-by-type) regardless of casing.", + "description": "An override list of tokens to always recognize as a Class for\n[`order-by-type`](#lint_isort_order-by-type) regardless of casing.", "type": [ "array", "null" @@ -1672,14 +1659,14 @@ } }, "combine-as-imports": { - "description": "Combines as imports on the same line. See isort's [`combine-as-imports`](https://pycqa.github.io/isort/docs/configuration/options.html#combine-as-imports) option.", + "description": "Combines as imports on the same line. See isort's [`combine-as-imports`](https://pycqa.github.io/isort/docs/configuration/options.html#combine-as-imports)\noption.", "type": [ "boolean", "null" ] }, "constants": { - "description": "An override list of tokens to always recognize as a CONSTANT for [`order-by-type`](#lint_isort_order-by-type) regardless of casing.", + "description": "An override list of tokens to always recognize as a CONSTANT\nfor [`order-by-type`](#lint_isort_order-by-type) regardless of casing.", "type": [ "array", "null" @@ -1700,14 +1687,14 @@ ] }, "detect-same-package": { - "description": "Whether to automatically mark imports from within the same package as first-party. For example, when `detect-same-package = true`, then when analyzing files within the `foo` package, any imports from within the `foo` package will be considered first-party.\n\nThis heuristic is often unnecessary when `src` is configured to detect all first-party sources; however, if `src` is _not_ configured, this heuristic can be useful to detect first-party imports from _within_ (but not _across_) first-party packages.", + "description": "Whether to automatically mark imports from within the same package as first-party.\nFor example, when `detect-same-package = true`, then when analyzing files within the\n`foo` package, any imports from within the `foo` package will be considered first-party.\n\nThis heuristic is often unnecessary when `src` is configured to detect all first-party\nsources; however, if `src` is _not_ configured, this heuristic can be useful to detect\nfirst-party imports from _within_ (but not _across_) first-party packages.", "type": [ "boolean", "null" ] }, "extra-standard-library": { - "description": "A list of modules to consider standard-library, in addition to those known to Ruff in advance.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of modules to consider standard-library, in addition to those\nknown to Ruff in advance.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1724,7 +1711,7 @@ ] }, "force-sort-within-sections": { - "description": "Don't sort straight-style imports (like `import sys`) before from-style imports (like `from itertools import groupby`). Instead, sort the imports by module, independent of import style.", + "description": "Don't sort straight-style imports (like `import sys`) before from-style\nimports (like `from itertools import groupby`). Instead, sort the\nimports by module, independent of import style.", "type": [ "boolean", "null" @@ -1741,14 +1728,14 @@ } }, "force-wrap-aliases": { - "description": "Force `import from` statements with multiple members and at least one alias (e.g., `import A as B`) to wrap such that every line contains exactly one member. For example, this formatting would be retained, rather than condensing to a single line:\n\n```python from .utils import ( test_directory as test_directory, test_id as test_id ) ```\n\nNote that this setting is only effective when combined with `combine-as-imports = true`. When [`combine-as-imports`](#lint_isort_combine-as-imports) isn't enabled, every aliased `import from` will be given its own line, in which case, wrapping is not necessary.\n\nWhen using the formatter, ensure that [`format.skip-magic-trailing-comma`](#format_skip-magic-trailing-comma) is set to `false` (default) when enabling `force-wrap-aliases` to avoid that the formatter collapses members if they all fit on a single line.", + "description": "Force `import from` statements with multiple members and at least one\nalias (e.g., `import A as B`) to wrap such that every line contains\nexactly one member. For example, this formatting would be retained,\nrather than condensing to a single line:\n\n```python\nfrom .utils import (\n test_directory as test_directory,\n test_id as test_id\n)\n```\n\nNote that this setting is only effective when combined with\n`combine-as-imports = true`. When [`combine-as-imports`](#lint_isort_combine-as-imports) isn't\nenabled, every aliased `import from` will be given its own line, in\nwhich case, wrapping is not necessary.\n\nWhen using the formatter, ensure that [`format.skip-magic-trailing-comma`](#format_skip-magic-trailing-comma) is set to `false` (default)\nwhen enabling `force-wrap-aliases` to avoid that the formatter collapses members if they all fit on a single line.", "type": [ "boolean", "null" ] }, "forced-separate": { - "description": "A list of modules to separate into auxiliary block(s) of imports, in the order specified.", + "description": "A list of modules to separate into auxiliary block(s) of imports,\nin the order specified.", "type": [ "array", "null" @@ -1758,14 +1745,14 @@ } }, "from-first": { - "description": "Whether to place `import from` imports before straight imports when sorting.\n\nFor example, by default, imports will be sorted such that straight imports appear before `import from` imports, as in: ```python import os import sys from typing import List ```\n\nSetting `from-first = true` will instead sort such that `import from` imports appear before straight imports, as in: ```python from typing import List import os import sys ```", + "description": "Whether to place `import from` imports before straight imports when sorting.\n\nFor example, by default, imports will be sorted such that straight imports appear\nbefore `import from` imports, as in:\n```python\nimport os\nimport sys\nfrom typing import List\n```\n\nSetting `from-first = true` will instead sort such that `import from` imports appear\nbefore straight imports, as in:\n```python\nfrom typing import List\nimport os\nimport sys\n```", "type": [ "boolean", "null" ] }, "known-first-party": { - "description": "A list of modules to consider first-party, regardless of whether they can be identified as such via introspection of the local filesystem.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of modules to consider first-party, regardless of whether they\ncan be identified as such via introspection of the local filesystem.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1775,7 +1762,7 @@ } }, "known-local-folder": { - "description": "A list of modules to consider being a local folder. Generally, this is reserved for relative imports (`from . import module`).\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of modules to consider being a local folder.\nGenerally, this is reserved for relative imports (`from . import module`).\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1785,7 +1772,7 @@ } }, "known-third-party": { - "description": "A list of modules to consider third-party, regardless of whether they can be identified as such via introspection of the local filesystem.\n\nSupports glob patterns. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of modules to consider third-party, regardless of whether they\ncan be identified as such via introspection of the local filesystem.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -1795,21 +1782,21 @@ } }, "length-sort": { - "description": "Sort imports by their string length, such that shorter imports appear before longer imports. For example, by default, imports will be sorted alphabetically, as in: ```python import collections import os ```\n\nSetting `length-sort = true` will instead sort such that shorter imports appear before longer imports, as in: ```python import os import collections ```", + "description": "Sort imports by their string length, such that shorter imports appear\nbefore longer imports. For example, by default, imports will be sorted\nalphabetically, as in:\n```python\nimport collections\nimport os\n```\n\nSetting `length-sort = true` will instead sort such that shorter imports\nappear before longer imports, as in:\n```python\nimport os\nimport collections\n```", "type": [ "boolean", "null" ] }, "length-sort-straight": { - "description": "Sort straight imports by their string length. Similar to [`length-sort`](#lint_isort_length-sort), but applies only to straight imports and doesn't affect `from` imports.", + "description": "Sort straight imports by their string length. Similar to [`length-sort`](#lint_isort_length-sort),\nbut applies only to straight imports and doesn't affect `from` imports.", "type": [ "boolean", "null" ] }, "lines-after-imports": { - "description": "The number of blank lines to place after imports. Use `-1` for automatic determination.\n\nRuff uses at most one blank line after imports in typing stub files (files with `.pyi` extension) in accordance to the typing style recommendations ([source](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines)).\n\nWhen using the formatter, only the values `-1`, `1`, and `2` are compatible because it enforces at least one empty and at most two empty lines after imports.", + "description": "The number of blank lines to place after imports.\nUse `-1` for automatic determination.\n\nRuff uses at most one blank line after imports in typing stub files (files with `.pyi` extension) in accordance to\nthe typing style recommendations ([source](https://typing.python.org/en/latest/guides/writing_stubs.html#blank-lines)).\n\nWhen using the formatter, only the values `-1`, `1`, and `2` are compatible because\nit enforces at least one empty and at most two empty lines after imports.", "type": [ "integer", "null" @@ -1817,16 +1804,16 @@ "format": "int" }, "lines-between-types": { - "description": "The number of lines to place between \"direct\" and `import from` imports.\n\nWhen using the formatter, only the values `0` and `1` are compatible because it preserves up to one empty line after imports in nested blocks.", + "description": "The number of lines to place between \"direct\" and `import from` imports.\n\nWhen using the formatter, only the values `0` and `1` are compatible because\nit preserves up to one empty line after imports in nested blocks.", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "no-lines-before": { - "description": "A list of sections that should _not_ be delineated from the previous section via empty lines.", + "description": "A list of sections that should _not_ be delineated from the previous\nsection via empty lines.", "type": [ "array", "null" @@ -1836,21 +1823,21 @@ } }, "no-sections": { - "description": "Put all imports into the same section bucket.\n\nFor example, rather than separating standard library and third-party imports, as in: ```python import os import sys\n\nimport numpy import pandas ```\n\nSetting `no-sections = true` will instead group all imports into a single section: ```python import numpy import os import pandas import sys ```", + "description": "Put all imports into the same section bucket.\n\nFor example, rather than separating standard library and third-party imports, as in:\n```python\nimport os\nimport sys\n\nimport numpy\nimport pandas\n```\n\nSetting `no-sections = true` will instead group all imports into a single section:\n```python\nimport numpy\nimport os\nimport pandas\nimport sys\n```", "type": [ "boolean", "null" ] }, "order-by-type": { - "description": "Order imports by type, which is determined by case, in addition to alphabetically.\n\nNote that this option takes precedence over the [`case-sensitive`](#lint_isort_case-sensitive) setting when enabled.", + "description": "Order imports by type, which is determined by case, in addition to\nalphabetically.\n\nNote that this option takes precedence over the\n[`case-sensitive`](#lint_isort_case-sensitive) setting when enabled.", "type": [ "boolean", "null" ] }, "relative-imports-order": { - "description": "Whether to place \"closer\" imports (fewer `.` characters, most local) before \"further\" imports (more `.` characters, least local), or vice versa.\n\nThe default (\"furthest-to-closest\") is equivalent to isort's [`reverse-relative`](https://pycqa.github.io/isort/docs/configuration/options.html#reverse-relative) default (`reverse-relative = false`); setting this to \"closest-to-furthest\" is equivalent to isort's `reverse-relative = true`.", + "description": "Whether to place \"closer\" imports (fewer `.` characters, most local)\nbefore \"further\" imports (more `.` characters, least local), or vice\nversa.\n\nThe default (\"furthest-to-closest\") is equivalent to isort's\n[`reverse-relative`](https://pycqa.github.io/isort/docs/configuration/options.html#reverse-relative) default (`reverse-relative = false`); setting\nthis to \"closest-to-furthest\" is equivalent to isort's\n`reverse-relative = true`.", "anyOf": [ { "$ref": "#/definitions/RelativeImportsOrder" @@ -1881,7 +1868,7 @@ } }, "sections": { - "description": "A list of mappings from section names to modules.\n\nBy default, imports are categorized according to their type (e.g., `future`, `third-party`, and so on). This setting allows you to group modules into custom sections, to augment or override the built-in sections.\n\nFor example, to group all testing utilities, you could create a `testing` section: ```toml testing = [\"pytest\", \"hypothesis\"] ```\n\nThe values in the list are treated as glob patterns. For example, to match all packages in the LangChain ecosystem (`langchain-core`, `langchain-openai`, etc.): ```toml langchain = [\"langchain-*\"] ```\n\nCustom sections should typically be inserted into the [`section-order`](#lint_isort_section-order) list to ensure that they're displayed as a standalone group and in the intended order, as in: ```toml section-order = [ \"future\", \"standard-library\", \"third-party\", \"first-party\", \"local-folder\", \"testing\" ] ```\n\nIf a custom section is omitted from [`section-order`](#lint_isort_section-order), imports in that section will be assigned to the [`default-section`](#lint_isort_default-section) (which defaults to `third-party`).", + "description": "A list of mappings from section names to modules.\n\nBy default, imports are categorized according to their type (e.g., `future`, `third-party`,\nand so on). This setting allows you to group modules into custom sections, to augment or\noverride the built-in sections.\n\nFor example, to group all testing utilities, you could create a `testing` section:\n```toml\ntesting = [\"pytest\", \"hypothesis\"]\n```\n\nThe values in the list are treated as glob patterns. For example, to match all packages in\nthe LangChain ecosystem (`langchain-core`, `langchain-openai`, etc.):\n```toml\nlangchain = [\"langchain-*\"]\n```\n\nCustom sections should typically be inserted into the [`section-order`](#lint_isort_section-order) list to ensure that\nthey're displayed as a standalone group and in the intended order, as in:\n```toml\nsection-order = [\n \"future\",\n \"standard-library\",\n \"third-party\",\n \"first-party\",\n \"local-folder\",\n \"testing\"\n]\n```\n\nIf a custom section is omitted from [`section-order`](#lint_isort_section-order), imports in that section will be\nassigned to the [`default-section`](#lint_isort_default-section) (which defaults to `third-party`).", "type": [ "object", "null" @@ -1904,14 +1891,14 @@ } }, "split-on-trailing-comma": { - "description": "If a comma is placed after the last member in a multi-line import, then the imports will never be folded into one line.\n\nSee isort's [`split-on-trailing-comma`](https://pycqa.github.io/isort/docs/configuration/options.html#split-on-trailing-comma) option.\n\nWhen using the formatter, ensure that [`format.skip-magic-trailing-comma`](#format_skip-magic-trailing-comma) is set to `false` (default) when enabling `split-on-trailing-comma` to avoid that the formatter removes the trailing commas.", + "description": "If a comma is placed after the last member in a multi-line import, then\nthe imports will never be folded into one line.\n\nSee isort's [`split-on-trailing-comma`](https://pycqa.github.io/isort/docs/configuration/options.html#split-on-trailing-comma) option.\n\nWhen using the formatter, ensure that [`format.skip-magic-trailing-comma`](#format_skip-magic-trailing-comma) is set to `false` (default) when enabling `split-on-trailing-comma`\nto avoid that the formatter removes the trailing commas.", "type": [ "boolean", "null" ] }, "variables": { - "description": "An override list of tokens to always recognize as a var for [`order-by-type`](#lint_isort_order-by-type) regardless of casing.", + "description": "An override list of tokens to always recognize as a var\nfor [`order-by-type`](#lint_isort_order-by-type) regardless of casing.", "type": [ "array", "null" @@ -1926,32 +1913,24 @@ "LineEnding": { "oneOf": [ { - "description": "The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to [`LineEnding::Lf`] for a files that contain no line endings.", + "description": "The newline style is detected automatically on a file per file basis.\nFiles with mixed line endings will be converted to the first detected line ending.\nDefaults to [`LineEnding::Lf`] for a files that contain no line endings.", "type": "string", - "enum": [ - "auto" - ] + "const": "auto" }, { "description": "Line endings will be converted to `\\n` as is common on Unix.", "type": "string", - "enum": [ - "lf" - ] + "const": "lf" }, { "description": "Line endings will be converted to `\\r\\n` as is common on Windows.", "type": "string", - "enum": [ - "cr-lf" - ] + "const": "cr-lf" }, { "description": "Line endings will be converted to `\\n` on Unix and `\\r\\n` on Windows.", "type": "string", - "enum": [ - "native" - ] + "const": "native" } ] }, @@ -1959,21 +1938,22 @@ "description": "The length of a line of text that is considered too long.\n\nThe allowed range of values is 1..=320", "type": "integer", "format": "uint16", - "maximum": 320.0, - "minimum": 1.0 + "maximum": 320, + "minimum": 1 }, "LineWidth": { "description": "The maximum visual width to which the formatter should try to limit a line.", "type": "integer", "format": "uint16", - "minimum": 1.0 + "maximum": 65535, + "minimum": 1 }, "LintOptions": { "description": "Configures how Ruff checks your code.\n\nOptions specified in the `lint` section take precedence over the deprecated top-level settings.", "type": "object", "properties": { "allowed-confusables": { - "description": "A list of allowed \"confusable\" Unicode characters to ignore when enforcing `RUF001`, `RUF002`, and `RUF003`.", + "description": "A list of allowed \"confusable\" Unicode characters to ignore when\nenforcing `RUF001`, `RUF002`, and `RUF003`.", "type": [ "array", "null" @@ -1985,14 +1965,14 @@ } }, "dummy-variable-rgx": { - "description": "A regular expression used to identify \"dummy\" variables, or those which should be ignored when enforcing (e.g.) unused-variable rules. The default expression matches `_`, `__`, and `_var`, but not `_var_`.", + "description": "A regular expression used to identify \"dummy\" variables, or those which\nshould be ignored when enforcing (e.g.) unused-variable rules. The\ndefault expression matches `_`, `__`, and `_var`, but not `_var_`.", "type": [ "string", "null" ] }, "exclude": { - "description": "A list of file patterns to exclude from linting in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to exclude from linting in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory\n named `.mypy_cache` in the tree), `foo.py` (to exclude any file named\n `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ).\n- Relative patterns, like `directory/foo.py` (to exclude that specific\n file) or `directory/*.py` (to exclude any Python files in\n `directory`). Note that these paths are relative to the project root\n (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -2002,14 +1982,14 @@ } }, "explicit-preview-rules": { - "description": "Whether to require exact codes to select preview rules. When enabled, preview rules will not be selected by prefixes — the full code of each preview rule will be required to enable the rule.", + "description": "Whether to require exact codes to select preview rules. When enabled,\npreview rules will not be selected by prefixes — the full code of each\npreview rule will be required to enable the rule.", "type": [ "boolean", "null" ] }, "extend-fixable": { - "description": "A list of rule codes or prefixes to consider fixable, in addition to those specified by [`fixable`](#lint_fixable).", + "description": "A list of rule codes or prefixes to consider fixable, in addition to those\nspecified by [`fixable`](#lint_fixable).", "type": [ "array", "null" @@ -2019,18 +1999,18 @@ } }, "extend-ignore": { - "description": "A list of rule codes or prefixes to ignore, in addition to those specified by `ignore`.", - "deprecated": true, + "description": "A list of rule codes or prefixes to ignore, in addition to those\nspecified by `ignore`.", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, in addition to any rules excluded by [`per-file-ignores`](#lint_per-file-ignores).", + "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, in addition to any rules excluded by [`per-file-ignores`](#lint_per-file-ignores).", "type": [ "object", "null" @@ -2043,7 +2023,7 @@ } }, "extend-safe-fixes": { - "description": "A list of rule codes or prefixes for which unsafe fixes should be considered safe.", + "description": "A list of rule codes or prefixes for which unsafe fixes should be considered\nsafe.", "type": [ "array", "null" @@ -2053,7 +2033,7 @@ } }, "extend-select": { - "description": "A list of rule codes or prefixes to enable, in addition to those specified by [`select`](#lint_select).", + "description": "A list of rule codes or prefixes to enable, in addition to those\nspecified by [`select`](#lint_select).", "type": [ "array", "null" @@ -2063,18 +2043,18 @@ } }, "extend-unfixable": { - "description": "A list of rule codes or prefixes to consider non-auto-fixable, in addition to those specified by [`unfixable`](#lint_unfixable).", - "deprecated": true, + "description": "A list of rule codes or prefixes to consider non-auto-fixable, in addition to those\nspecified by [`unfixable`](#lint_unfixable).", "type": [ "array", "null" ], + "deprecated": true, "items": { "$ref": "#/definitions/RuleSelector" } }, "extend-unsafe-fixes": { - "description": "A list of rule codes or prefixes for which safe fixes should be considered unsafe.", + "description": "A list of rule codes or prefixes for which safe fixes should be considered\nunsafe.", "type": [ "array", "null" @@ -2084,7 +2064,7 @@ } }, "external": { - "description": "A list of rule codes or prefixes that are unsupported by Ruff, but should be preserved when (e.g.) validating `# noqa` directives. Useful for retaining `# noqa` directives that cover plugins not yet implemented by Ruff.", + "description": "A list of rule codes or prefixes that are unsupported by Ruff, but should be\npreserved when (e.g.) validating `# noqa` directives. Useful for\nretaining `# noqa` directives that cover plugins not yet implemented\nby Ruff.", "type": [ "array", "null" @@ -2094,7 +2074,7 @@ } }, "fixable": { - "description": "A list of rule codes or prefixes to consider fixable. By default, all rules are considered fixable.", + "description": "A list of rule codes or prefixes to consider fixable. By default,\nall rules are considered fixable.", "type": [ "array", "null" @@ -2291,14 +2271,14 @@ ] }, "future-annotations": { - "description": "Whether to allow rules to add `from __future__ import annotations` in cases where this would simplify a fix or enable a new diagnostic.\n\nFor example, `TC001`, `TC002`, and `TC003` can move more imports into `TYPE_CHECKING` blocks if `__future__` annotations are enabled.", + "description": "Whether to allow rules to add `from __future__ import annotations` in cases where this would\nsimplify a fix or enable a new diagnostic.\n\nFor example, `TC001`, `TC002`, and `TC003` can move more imports into `TYPE_CHECKING` blocks\nif `__future__` annotations are enabled.", "type": [ "boolean", "null" ] }, "ignore": { - "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact rules (like `F841`), entire categories (like `F`), or anything in between.\n\nWhen breaking ties between enabled and disabled rules (via `select` and `ignore`, respectively), more specific prefixes override less specific prefixes. `ignore` takes precedence over `select` if the same prefix appears in both.", + "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the same\nprefix appears in both.", "type": [ "array", "null" @@ -2308,12 +2288,12 @@ } }, "ignore-init-module-imports": { - "description": "Avoid automatically removing unused imports in `__init__.py` files. Such imports will still be flagged, but with a dedicated message suggesting that the import is either added to the module's `__all__` symbol, or re-exported with a redundant alias (e.g., `import os as os`).\n\nThis option is enabled by default, but you can opt-in to removal of imports via an unsafe fix.", - "deprecated": true, + "description": "Avoid automatically removing unused imports in `__init__.py` files. Such\nimports will still be flagged, but with a dedicated message suggesting\nthat the import is either added to the module's `__all__` symbol, or\nre-exported with a redundant alias (e.g., `import os as os`).\n\nThis option is enabled by default, but you can opt-in to removal of imports\nvia an unsafe fix.", "type": [ "boolean", "null" - ] + ], + "deprecated": true }, "isort": { "description": "Options for the `isort` plugin.", @@ -2327,7 +2307,7 @@ ] }, "logger-objects": { - "description": "A list of objects that should be treated equivalently to a `logging.Logger` object.\n\nThis is useful for ensuring proper diagnostics (e.g., to identify `logging` deprecations and other best-practices) for projects that re-export a `logging.Logger` object from a common module.\n\nFor example, if you have a module `logging_setup.py` with the following contents: ```python import logging\n\nlogger = logging.getLogger(__name__) ```\n\nAdding `\"logging_setup.logger\"` to `logger-objects` will ensure that `logging_setup.logger` is treated as a `logging.Logger` object when imported from other modules (e.g., `from logging_setup import logger`).", + "description": "A list of objects that should be treated equivalently to a\n`logging.Logger` object.\n\nThis is useful for ensuring proper diagnostics (e.g., to identify\n`logging` deprecations and other best-practices) for projects that\nre-export a `logging.Logger` object from a common module.\n\nFor example, if you have a module `logging_setup.py` with the following\ncontents:\n```python\nimport logging\n\nlogger = logging.getLogger(__name__)\n```\n\nAdding `\"logging_setup.logger\"` to `logger-objects` will ensure that\n`logging_setup.logger` is treated as a `logging.Logger` object when\nimported from other modules (e.g., `from logging_setup import logger`).", "type": [ "array", "null" @@ -2359,7 +2339,7 @@ ] }, "per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, when considering any matching files. An initial '!' negates the file pattern.", + "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, when considering any matching files. An initial '!' negates\nthe file pattern.", "type": [ "object", "null" @@ -2372,7 +2352,7 @@ } }, "preview": { - "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will use unstable rules and fixes.", + "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will\nuse unstable rules and fixes.", "type": [ "boolean", "null" @@ -2456,7 +2436,7 @@ ] }, "select": { - "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact rules (like `F841`), entire categories (like `F`), or anything in between.\n\nWhen breaking ties between enabled and disabled rules (via `select` and `ignore`, respectively), more specific prefixes override less specific prefixes. `ignore` takes precedence over `select` if the same prefix appears in both.", + "description": "A list of rule codes or prefixes to enable. Prefixes can specify exact\nrules (like `F841`), entire categories (like `F`), or anything in\nbetween.\n\nWhen breaking ties between enabled and disabled rules (via `select` and\n`ignore`, respectively), more specific prefixes override less\nspecific prefixes. `ignore` takes precedence over `select` if the\nsame prefix appears in both.", "type": [ "array", "null" @@ -2466,7 +2446,7 @@ } }, "task-tags": { - "description": "A list of task tags to recognize (e.g., \"TODO\", \"FIXME\", \"XXX\").\n\nComments starting with these tags will be ignored by commented-out code detection (`ERA`), and skipped by line-length rules (`E501`) if [`ignore-overlong-task-comments`](#lint_pycodestyle_ignore-overlong-task-comments) is set to `true`.", + "description": "A list of task tags to recognize (e.g., \"TODO\", \"FIXME\", \"XXX\").\n\nComments starting with these tags will be ignored by commented-out code\ndetection (`ERA`), and skipped by line-length rules (`E501`) if\n[`ignore-overlong-task-comments`](#lint_pycodestyle_ignore-overlong-task-comments) is set to `true`.", "type": [ "array", "null" @@ -2476,14 +2456,14 @@ } }, "typing-extensions": { - "description": "Whether to allow imports from the third-party `typing_extensions` module for Python versions before a symbol was added to the first-party `typing` module.\n\nMany rules try to import symbols from the `typing` module but fall back to `typing_extensions` for earlier versions of Python. This option can be used to disable this fallback behavior in cases where `typing_extensions` is not installed.", + "description": "Whether to allow imports from the third-party `typing_extensions` module for Python versions\nbefore a symbol was added to the first-party `typing` module.\n\nMany rules try to import symbols from the `typing` module but fall back to\n`typing_extensions` for earlier versions of Python. This option can be used to disable this\nfallback behavior in cases where `typing_extensions` is not installed.", "type": [ "boolean", "null" ] }, "typing-modules": { - "description": "A list of modules whose exports should be treated equivalently to members of the `typing` module.\n\nThis is useful for ensuring proper type annotation inference for projects that re-export `typing` and `typing_extensions` members from a compatibility module. If omitted, any members imported from modules apart from `typing` and `typing_extensions` will be treated as ordinary Python objects.", + "description": "A list of modules whose exports should be treated equivalently to\nmembers of the `typing` module.\n\nThis is useful for ensuring proper type annotation inference for\nprojects that re-export `typing` and `typing_extensions` members\nfrom a compatibility module. If omitted, any members imported from\nmodules apart from `typing` and `typing_extensions` will be treated\nas ordinary Python objects.", "type": [ "array", "null" @@ -2516,7 +2496,7 @@ "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 } }, "additionalProperties": false @@ -2568,7 +2548,7 @@ "type": "object", "properties": { "classmethod-decorators": { - "description": "A list of decorators that, when applied to a method, indicate that the method should be treated as a class method (in addition to the builtin `@classmethod`).\n\nFor example, Ruff will expect that any method decorated by a decorator in this list takes a `cls` argument as its first argument.\n\nExpects to receive a list of fully-qualified names (e.g., `pydantic.validator`, rather than `validator`) or alternatively a plain name which is then matched against the last segment in case the decorator itself consists of a dotted name.", + "description": "A list of decorators that, when applied to a method, indicate that the\nmethod should be treated as a class method (in addition to the builtin\n`@classmethod`).\n\nFor example, Ruff will expect that any method decorated by a decorator\nin this list takes a `cls` argument as its first argument.\n\nExpects to receive a list of fully-qualified names (e.g., `pydantic.validator`,\nrather than `validator`) or alternatively a plain name which is then matched against\nthe last segment in case the decorator itself consists of a dotted name.", "type": [ "array", "null" @@ -2578,7 +2558,7 @@ } }, "extend-ignore-names": { - "description": "Additional names (or patterns) to ignore when considering `pep8-naming` violations, in addition to those included in [`ignore-names`](#lint_pep8-naming_ignore-names).\n\nSupports glob patterns. For example, to ignore all names starting with `test_` or ending with `_test`, you could use `ignore-names = [\"test_*\", \"*_test\"]`. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "Additional names (or patterns) to ignore when considering `pep8-naming` violations,\nin addition to those included in [`ignore-names`](#lint_pep8-naming_ignore-names).\n\nSupports glob patterns. For example, to ignore all names starting with `test_`\nor ending with `_test`, you could use `ignore-names = [\"test_*\", \"*_test\"]`.\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -2588,7 +2568,7 @@ } }, "ignore-names": { - "description": "A list of names (or patterns) to ignore when considering `pep8-naming` violations.\n\nSupports glob patterns. For example, to ignore all names starting with `test_` or ending with `_test`, you could use `ignore-names = [\"test_*\", \"*_test\"]`. For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of names (or patterns) to ignore when considering `pep8-naming` violations.\n\nSupports glob patterns. For example, to ignore all names starting with `test_`\nor ending with `_test`, you could use `ignore-names = [\"test_*\", \"*_test\"]`.\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -2598,7 +2578,7 @@ } }, "staticmethod-decorators": { - "description": "A list of decorators that, when applied to a method, indicate that the method should be treated as a static method (in addition to the builtin `@staticmethod`).\n\nFor example, Ruff will expect that any method decorated by a decorator in this list has no `self` or `cls` argument.\n\nExpects to receive a list of fully-qualified names (e.g., `belay.Device.teardown`, rather than `teardown`) or alternatively a plain name which is then matched against the last segment in case the decorator itself consists of a dotted name.", + "description": "A list of decorators that, when applied to a method, indicate that the\nmethod should be treated as a static method (in addition to the builtin\n`@staticmethod`).\n\nFor example, Ruff will expect that any method decorated by a decorator\nin this list has no `self` or `cls` argument.\n\nExpects to receive a list of fully-qualified names (e.g., `belay.Device.teardown`,\nrather than `teardown`) or alternatively a plain name which is then matched against\nthe last segment in case the decorator itself consists of a dotted name.", "type": [ "array", "null" @@ -2615,7 +2595,7 @@ "type": "object", "properties": { "keep-runtime-typing": { - "description": "Whether to avoid [PEP 585](https://peps.python.org/pep-0585/) (`List[int]` -> `list[int]`) and [PEP 604](https://peps.python.org/pep-0604/) (`Union[str, int]` -> `str | int`) rewrites even if a file imports `from __future__ import annotations`.\n\nThis setting is only applicable when the target Python version is below 3.9 and 3.10 respectively, and is most commonly used when working with libraries like Pydantic and FastAPI, which rely on the ability to parse type annotations at runtime. The use of `from __future__ import annotations` causes Python to treat the type annotations as strings, which typically allows for the use of language features that appear in later Python versions but are not yet supported by the current version (e.g., `str | int`). However, libraries that rely on runtime type annotations will break if the annotations are incompatible with the current Python version.\n\nFor example, while the following is valid Python 3.8 code due to the presence of `from __future__ import annotations`, the use of `str | int` prior to Python 3.10 will cause Pydantic to raise a `TypeError` at runtime:\n\n```python from __future__ import annotations\n\nimport pydantic\n\nclass Foo(pydantic.BaseModel): bar: str | int ```", + "description": "Whether to avoid [PEP 585](https://peps.python.org/pep-0585/) (`List[int]` -> `list[int]`) and [PEP 604](https://peps.python.org/pep-0604/)\n(`Union[str, int]` -> `str | int`) rewrites even if a file imports\n`from __future__ import annotations`.\n\nThis setting is only applicable when the target Python version is below\n3.9 and 3.10 respectively, and is most commonly used when working with\nlibraries like Pydantic and FastAPI, which rely on the ability to parse\ntype annotations at runtime. The use of `from __future__ import annotations`\ncauses Python to treat the type annotations as strings, which typically\nallows for the use of language features that appear in later Python\nversions but are not yet supported by the current version (e.g., `str |\nint`). However, libraries that rely on runtime type annotations will\nbreak if the annotations are incompatible with the current Python\nversion.\n\nFor example, while the following is valid Python 3.8 code due to the\npresence of `from __future__ import annotations`, the use of `str | int`\nprior to Python 3.10 will cause Pydantic to raise a `TypeError` at\nruntime:\n\n```python\nfrom __future__ import annotations\n\nimport pydantic\n\nclass Foo(pydantic.BaseModel):\n bar: str | int\n```", "type": [ "boolean", "null" @@ -2629,14 +2609,14 @@ "type": "object", "properties": { "ignore-overlong-task-comments": { - "description": "Whether line-length violations (`E501`) should be triggered for comments starting with [`task-tags`](#lint_task-tags) (by default: \"TODO\", \"FIXME\", and \"XXX\").", + "description": "Whether line-length violations (`E501`) should be triggered for\ncomments starting with [`task-tags`](#lint_task-tags) (by default: \"TODO\", \"FIXME\",\nand \"XXX\").", "type": [ "boolean", "null" ] }, "max-doc-length": { - "description": "The maximum line length to allow for [`doc-line-too-long`](https://docs.astral.sh/ruff/rules/doc-line-too-long/) violations within documentation (`W505`), including standalone comments. By default, this is set to `null` which disables reporting violations.\n\nThe length is determined by the number of characters per line, except for lines containing Asian characters or emojis. For these lines, the [unicode width](https://unicode.org/reports/tr11/) of each character is added up to determine the length.\n\nSee the [`doc-line-too-long`](https://docs.astral.sh/ruff/rules/doc-line-too-long/) rule for more information.", + "description": "The maximum line length to allow for [`doc-line-too-long`](https://docs.astral.sh/ruff/rules/doc-line-too-long/) violations within\ndocumentation (`W505`), including standalone comments. By default,\nthis is set to `null` which disables reporting violations.\n\nThe length is determined by the number of characters per line, except for lines containing Asian characters or emojis.\nFor these lines, the [unicode width](https://unicode.org/reports/tr11/) of each character is added up to determine the length.\n\nSee the [`doc-line-too-long`](https://docs.astral.sh/ruff/rules/doc-line-too-long/) rule for more information.", "anyOf": [ { "$ref": "#/definitions/LineLength" @@ -2647,7 +2627,7 @@ ] }, "max-line-length": { - "description": "The maximum line length to allow for [`line-too-long`](https://docs.astral.sh/ruff/rules/line-too-long/) violations. By default, this is set to the value of the [`line-length`](#line-length) option.\n\nUse this option when you want to detect extra-long lines that the formatter can't automatically split by setting `pycodestyle.line-length` to a value larger than [`line-length`](#line-length).\n\n```toml # The formatter wraps lines at a length of 88. line-length = 88\n\n[pycodestyle] # E501 reports lines that exceed the length of 100. max-line-length = 100 ```\n\nThe length is determined by the number of characters per line, except for lines containing East Asian characters or emojis. For these lines, the [unicode width](https://unicode.org/reports/tr11/) of each character is added up to determine the length.\n\nSee the [`line-too-long`](https://docs.astral.sh/ruff/rules/line-too-long/) rule for more information.", + "description": "The maximum line length to allow for [`line-too-long`](https://docs.astral.sh/ruff/rules/line-too-long/) violations. By default,\nthis is set to the value of the [`line-length`](#line-length) option.\n\nUse this option when you want to detect extra-long lines that the formatter can't automatically split by setting\n`pycodestyle.line-length` to a value larger than [`line-length`](#line-length).\n\n```toml\n# The formatter wraps lines at a length of 88.\nline-length = 88\n\n[pycodestyle]\n# E501 reports lines that exceed the length of 100.\nmax-line-length = 100\n```\n\nThe length is determined by the number of characters per line, except for lines containing East Asian characters or emojis.\nFor these lines, the [unicode width](https://unicode.org/reports/tr11/) of each character is added up to determine the length.\n\nSee the [`line-too-long`](https://docs.astral.sh/ruff/rules/line-too-long/) rule for more information.", "anyOf": [ { "$ref": "#/definitions/LineLength" @@ -2665,7 +2645,7 @@ "type": "object", "properties": { "ignore-one-line-docstrings": { - "description": "Skip docstrings which fit on a single line.\n\nNote: The corresponding setting in `pydoclint` is named `skip-checking-short-docstrings`.", + "description": "Skip docstrings which fit on a single line.\n\nNote: The corresponding setting in `pydoclint`\nis named `skip-checking-short-docstrings`.", "type": [ "boolean", "null" @@ -2679,7 +2659,7 @@ "type": "object", "properties": { "convention": { - "description": "Whether to use Google-style, NumPy-style conventions, or the [PEP 257](https://peps.python.org/pep-0257/) defaults when analyzing docstring sections.\n\nEnabling a convention will disable all rules that are not included in the specified convention. As such, the intended workflow is to enable a convention and then selectively enable or disable any additional rules on top of it.\n\nFor example, to use Google-style conventions but avoid requiring documentation for every function parameter:\n\n```toml [tool.ruff.lint] # Enable all `pydocstyle` rules, limiting to those that adhere to the # Google convention via `convention = \"google\"`, below. select = [\"D\"]\n\n# On top of the Google convention, disable `D417`, which requires # documentation for every function parameter. ignore = [\"D417\"]\n\n[tool.ruff.lint.pydocstyle] convention = \"google\" ```\n\nTo enable an additional rule that's excluded from the convention, select the desired rule via its fully qualified rule code (e.g., `D400` instead of `D4` or `D40`):\n\n```toml [tool.ruff.lint] # Enable D400 on top of the Google convention. extend-select = [\"D400\"]\n\n[tool.ruff.lint.pydocstyle] convention = \"google\" ```", + "description": "Whether to use Google-style, NumPy-style conventions, or the [PEP 257](https://peps.python.org/pep-0257/)\ndefaults when analyzing docstring sections.\n\nEnabling a convention will disable all rules that are not included in\nthe specified convention. As such, the intended workflow is to enable a\nconvention and then selectively enable or disable any additional rules\non top of it.\n\nFor example, to use Google-style conventions but avoid requiring\ndocumentation for every function parameter:\n\n```toml\n[tool.ruff.lint]\n# Enable all `pydocstyle` rules, limiting to those that adhere to the\n# Google convention via `convention = \"google\"`, below.\nselect = [\"D\"]\n\n# On top of the Google convention, disable `D417`, which requires\n# documentation for every function parameter.\nignore = [\"D417\"]\n\n[tool.ruff.lint.pydocstyle]\nconvention = \"google\"\n```\n\nTo enable an additional rule that's excluded from the convention,\nselect the desired rule via its fully qualified rule code (e.g.,\n`D400` instead of `D4` or `D40`):\n\n```toml\n[tool.ruff.lint]\n# Enable D400 on top of the Google convention.\nextend-select = [\"D400\"]\n\n[tool.ruff.lint.pydocstyle]\nconvention = \"google\"\n```", "anyOf": [ { "$ref": "#/definitions/Convention" @@ -2690,7 +2670,7 @@ ] }, "ignore-decorators": { - "description": "Ignore docstrings for functions or methods decorated with the specified fully-qualified decorators.", + "description": "Ignore docstrings for functions or methods decorated with the\nspecified fully-qualified decorators.", "type": [ "array", "null" @@ -2707,7 +2687,7 @@ ] }, "property-decorators": { - "description": "A list of decorators that, when applied to a method, indicate that the method should be treated as a property (in addition to the builtin `@property` and standard-library `@functools.cached_property`).\n\nFor example, Ruff will expect that any method decorated by a decorator in this list can use a non-imperative summary line.", + "description": "A list of decorators that, when applied to a method, indicate that the\nmethod should be treated as a property (in addition to the builtin\n`@property` and standard-library `@functools.cached_property`).\n\nFor example, Ruff will expect that any method decorated by a decorator\nin this list can use a non-imperative summary line.", "type": [ "array", "null" @@ -2724,7 +2704,7 @@ "type": "object", "properties": { "allowed-unused-imports": { - "description": "A list of modules to ignore when considering unused imports.\n\nUsed to prevent violations for specific modules that are known to have side effects on import (e.g., `hvplot.pandas`).\n\nModules in this list are expected to be fully-qualified names (e.g., `hvplot.pandas`). Any submodule of a given module will also be ignored (e.g., given `hvplot`, `hvplot.pandas` will also be ignored).", + "description": "A list of modules to ignore when considering unused imports.\n\nUsed to prevent violations for specific modules that are known to have side effects on\nimport (e.g., `hvplot.pandas`).\n\nModules in this list are expected to be fully-qualified names (e.g., `hvplot.pandas`). Any\nsubmodule of a given module will also be ignored (e.g., given `hvplot`, `hvplot.pandas`\nwill also be ignored).", "type": [ "array", "null" @@ -2734,7 +2714,7 @@ } }, "extend-generics": { - "description": "Additional functions or classes to consider generic, such that any subscripts should be treated as type annotation (e.g., `ForeignKey` in `django.db.models.ForeignKey[\"User\"]`.\n\nExpects to receive a list of fully-qualified names (e.g., `django.db.models.ForeignKey`, rather than `ForeignKey`).", + "description": "Additional functions or classes to consider generic, such that any\nsubscripts should be treated as type annotation (e.g., `ForeignKey` in\n`django.db.models.ForeignKey[\"User\"]`.\n\nExpects to receive a list of fully-qualified names (e.g., `django.db.models.ForeignKey`,\nrather than `ForeignKey`).", "type": [ "array", "null" @@ -2751,7 +2731,7 @@ "type": "object", "properties": { "allow-dunder-method-names": { - "description": "Dunder methods name to allow, in addition to the default set from the Python standard library (see `PLW3201`).", + "description": "Dunder methods name to allow, in addition to the default set from the\nPython standard library (see `PLW3201`).", "type": [ "array", "null" @@ -2772,22 +2752,22 @@ } }, "max-args": { - "description": "Maximum number of arguments allowed for a function or method definition (see `PLR0913`).", + "description": "Maximum number of arguments allowed for a function or method definition\n(see `PLR0913`).", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-bool-expr": { - "description": "Maximum number of Boolean expressions allowed within a single `if` statement (see `PLR0916`).", + "description": "Maximum number of Boolean expressions allowed within a single `if` statement\n(see `PLR0916`).", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-branches": { "description": "Maximum number of branches allowed for a function or method body (see `PLR0912`).", @@ -2796,7 +2776,7 @@ "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-locals": { "description": "Maximum number of local variables allowed for a function or method body (see `PLR0914`).", @@ -2805,25 +2785,25 @@ "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-nested-blocks": { - "description": "Maximum number of nested blocks allowed within a function or method body (see `PLR1702`).", + "description": "Maximum number of nested blocks allowed within a function or method body\n(see `PLR1702`).", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-positional-args": { - "description": "Maximum number of positional arguments allowed for a function or method definition (see `PLR0917`).\n\nIf not specified, defaults to the value of `max-args`.", + "description": "Maximum number of positional arguments allowed for a function or method definition\n(see `PLR0917`).\n\nIf not specified, defaults to the value of `max-args`.", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-public-methods": { "description": "Maximum number of public methods allowed for a class (see `PLR0904`).", @@ -2832,16 +2812,16 @@ "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-returns": { - "description": "Maximum number of return statements allowed for a function or method body (see `PLR0911`)", + "description": "Maximum number of return statements allowed for a function or method\nbody (see `PLR0911`)", "type": [ "integer", "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 }, "max-statements": { "description": "Maximum number of statements allowed for a function or method body (see `PLR0915`).", @@ -2850,7 +2830,7 @@ "null" ], "format": "uint", - "minimum": 0.0 + "minimum": 0 } }, "additionalProperties": false @@ -2873,16 +2853,12 @@ { "description": "Use double quotes.", "type": "string", - "enum": [ - "double" - ] + "const": "double" }, { "description": "Use single quotes.", "type": "string", - "enum": [ - "single" - ] + "const": "single" } ] }, @@ -2897,18 +2873,14 @@ "RelativeImportsOrder": { "oneOf": [ { - "description": "Place \"closer\" imports (fewer `.` characters, most local) before \"further\" imports (more `.` characters, least local).", + "description": "Place \"closer\" imports (fewer `.` characters, most local) before\n\"further\" imports (more `.` characters, least local).", "type": "string", - "enum": [ - "closest-to-furthest" - ] + "const": "closest-to-furthest" }, { - "description": "Place \"further\" imports (more `.` characters, least local) imports before \"closer\" imports (fewer `.` characters, most local).", + "description": "Place \"further\" imports (more `.` characters, least local) imports\nbefore \"closer\" imports (fewer `.` characters, most local).", "type": "string", - "enum": [ - "furthest-to-closest" - ] + "const": "furthest-to-closest" } ] }, @@ -2920,29 +2892,29 @@ "type": "object", "properties": { "allowed-markup-calls": { - "description": "A list of callable names, whose result may be safely passed into [`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `bleach.clean`, rather than `clean`).\n\nThis setting helps you avoid false positives in code like:\n\n```python from bleach import clean from markupsafe import Markup\n\ncleaned_markup = Markup(clean(some_user_input)) ```\n\nWhere the use of [`bleach.clean`](https://bleach.readthedocs.io/en/latest/clean.html) usually ensures that there's no XSS vulnerability.\n\nAlthough it is not recommended, you may also use this setting to whitelist other kinds of calls, e.g. calls to i18n translation functions, where how safe that is will depend on the implementation and how well the translations are audited.\n\nAnother common use-case is to wrap the output of functions that generate markup like [`xml.etree.ElementTree.tostring`](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.tostring) or template rendering engines where sanitization of potential user input is either already baked in or has to happen before rendering.", - "deprecated": true, + "description": "A list of callable names, whose result may be safely passed into\n[`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `bleach.clean`, rather than `clean`).\n\nThis setting helps you avoid false positives in code like:\n\n```python\nfrom bleach import clean\nfrom markupsafe import Markup\n\ncleaned_markup = Markup(clean(some_user_input))\n```\n\nWhere the use of [`bleach.clean`](https://bleach.readthedocs.io/en/latest/clean.html)\nusually ensures that there's no XSS vulnerability.\n\nAlthough it is not recommended, you may also use this setting to whitelist other\nkinds of calls, e.g. calls to i18n translation functions, where how safe that is\nwill depend on the implementation and how well the translations are audited.\n\nAnother common use-case is to wrap the output of functions that generate markup\nlike [`xml.etree.ElementTree.tostring`](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.tostring)\nor template rendering engines where sanitization of potential user input is either\nalready baked in or has to happen before rendering.", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "extend-markup-names": { - "description": "A list of additional callable names that behave like [`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `webhelpers.html.literal`, rather than `literal`).", - "deprecated": true, + "description": "A list of additional callable names that behave like\n[`markupsafe.Markup`](https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup).\n\nExpects to receive a list of fully-qualified names (e.g., `webhelpers.html.literal`, rather than\n`literal`).", "type": [ "array", "null" ], + "deprecated": true, "items": { "type": "string" } }, "parenthesize-tuple-in-subscript": { - "description": "Whether to prefer accessing items keyed by tuples with parentheses around the tuple (see `RUF031`).", + "description": "Whether to prefer accessing items keyed by tuples with\nparentheses around the tuple (see `RUF031`).", "type": [ "boolean", "null" @@ -4368,16 +4340,12 @@ { "description": "Ban imports that extend into the parent module or beyond.", "type": "string", - "enum": [ - "parents" - ] + "const": "parents" }, { "description": "Ban all relative imports.", "type": "string", - "enum": [ - "all" - ] + "const": "all" } ] } diff --git a/ty.schema.json b/ty.schema.json index 55cb190bb8..270241fb28 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -15,17 +15,18 @@ ] }, "overrides": { - "description": "Override configurations for specific file patterns.\n\nEach override specifies include/exclude patterns and rule configurations that apply to matching files. Multiple overrides can match the same file, with later overrides taking precedence.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/OverrideOptions" - } + "description": "Override configurations for specific file patterns.\n\nEach override specifies include/exclude patterns and rule configurations\nthat apply to matching files. Multiple overrides can match the same file,\nwith later overrides taking precedence.", + "anyOf": [ + { + "$ref": "#/definitions/OverridesOptions" + }, + { + "type": "null" + } + ] }, "rules": { - "description": "Configures the enabled rules and their severity.\n\nSee [the rules documentation](https://ty.dev/rules) for a list of all available rules.\n\nValid severities are:\n\n* `ignore`: Disable the rule. * `warn`: Enable the rule and create a warning diagnostic. * `error`: Enable the rule and create an error diagnostic. ty will exit with a non-zero code if any error diagnostics are emitted.", + "description": "Configures the enabled rules and their severity.\n\nSee [the rules documentation](https://ty.dev/rules) for a list of all available rules.\n\nValid severities are:\n\n* `ignore`: Disable the rule.\n* `warn`: Enable the rule and create a warning diagnostic.\n* `error`: Enable the rule and create an error diagnostic.\n ty will exit with a non-zero code if any error diagnostics are emitted.", "anyOf": [ { "$ref": "#/definitions/Rules" @@ -58,28 +59,38 @@ }, "additionalProperties": false, "definitions": { + "Array_of_string": { + "type": "array", + "items": { + "$ref": "#/definitions/string" + } + }, "EnvironmentOptions": { "type": "object", "properties": { "extra-paths": { - "description": "User-provided paths that should take first priority in module resolution.\n\nThis is an advanced option that should usually only be used for first-party or third-party modules that are not installed into your Python environment in a conventional way. Use the `python` option to specify the location of your Python environment.\n\nThis option is similar to mypy's `MYPYPATH` environment variable and pyright's `stubPath` configuration setting.", + "description": "User-provided paths that should take first priority in module resolution.\n\nThis is an advanced option that should usually only be used for first-party or third-party\nmodules that are not installed into your Python environment in a conventional way.\nUse the `python` option to specify the location of your Python environment.\n\nThis option is similar to mypy's `MYPYPATH` environment variable and pyright's `stubPath`\nconfiguration setting.", "type": [ "array", "null" ], "items": { - "type": "string" + "$ref": "#/definitions/RelativePathBuf" } }, "python": { - "description": "Path to your project's Python environment or interpreter.\n\nty uses the `site-packages` directory of your project's Python environment to resolve third-party (and, in some cases, first-party) imports in your code.\n\nIf you're using a project management tool such as uv, you should not generally need to specify this option, as commands such as `uv run` will set the `VIRTUAL_ENV` environment variable to point to your project's virtual environment. ty can also infer the location of your environment from an activated Conda environment, and will look for a `.venv` directory in the project root if none of the above apply.\n\nPassing a path to a Python executable is supported, but passing a path to a dynamic executable (such as a shim) is not currently supported.\n\nThis option can be used to point to virtual or system Python environments.", - "type": [ - "string", - "null" + "description": "Path to your project's Python environment or interpreter.\n\nty uses the `site-packages` directory of your project's Python environment\nto resolve third-party (and, in some cases, first-party) imports in your code.\n\nIf you're using a project management tool such as uv, you should not generally need\nto specify this option, as commands such as `uv run` will set the `VIRTUAL_ENV`\nenvironment variable to point to your project's virtual environment. ty can also infer\nthe location of your environment from an activated Conda environment, and will look for\na `.venv` directory in the project root if none of the above apply.\n\nPassing a path to a Python executable is supported, but passing a path to a dynamic executable\n(such as a shim) is not currently supported.\n\nThis option can be used to point to virtual or system Python environments.", + "anyOf": [ + { + "$ref": "#/definitions/RelativePathBuf" + }, + { + "type": "null" + } ] }, "python-platform": { - "description": "Specifies the target platform that will be used to analyze the source code. If specified, ty will understand conditions based on comparisons with `sys.platform`, such as are commonly found in typeshed to reflect the differing contents of the standard library across platforms. If `all` is specified, ty will assume that the source code can run on any platform.\n\nIf no platform is specified, ty will use the current platform: - `win32` for Windows - `darwin` for macOS - `android` for Android - `ios` for iOS - `linux` for everything else", + "description": "Specifies the target platform that will be used to analyze the source code.\nIf specified, ty will understand conditions based on comparisons with `sys.platform`, such\nas are commonly found in typeshed to reflect the differing contents of the standard library across platforms.\nIf `all` is specified, ty will assume that the source code can run on any platform.\n\nIf no platform is specified, ty will use the current platform:\n- `win32` for Windows\n- `darwin` for macOS\n- `android` for Android\n- `ios` for iOS\n- `linux` for everything else", "anyOf": [ { "$ref": "#/definitions/PythonPlatform" @@ -90,7 +101,7 @@ ] }, "python-version": { - "description": "Specifies the version of Python that will be used to analyze the source code. The version should be specified as a string in the format `M.m` where `M` is the major version and `m` is the minor (e.g. `\"3.0\"` or `\"3.6\"`). If a version is provided, ty will generate errors if the source code makes use of language features that are not supported in that version.\n\nIf a version is not specified, ty will try the following techniques in order of preference to determine a value: 1. Check for the `project.requires-python` setting in a `pyproject.toml` file and use the minimum version from the specified range 2. Check for an activated or configured Python environment and attempt to infer the Python version of that environment 3. Fall back to the default value (see below)\n\nFor some language features, ty can also understand conditionals based on comparisons with `sys.version_info`. These are commonly found in typeshed, for example, to reflect the differing contents of the standard library across Python versions.", + "description": "Specifies the version of Python that will be used to analyze the source code.\nThe version should be specified as a string in the format `M.m` where `M` is the major version\nand `m` is the minor (e.g. `\"3.0\"` or `\"3.6\"`).\nIf a version is provided, ty will generate errors if the source code makes use of language features\nthat are not supported in that version.\n\nIf a version is not specified, ty will try the following techniques in order of preference\nto determine a value:\n1. Check for the `project.requires-python` setting in a `pyproject.toml` file\n and use the minimum version from the specified range\n2. Check for an activated or configured Python environment\n and attempt to infer the Python version of that environment\n3. Fall back to the default value (see below)\n\nFor some language features, ty can also understand conditionals based on comparisons\nwith `sys.version_info`. These are commonly found in typeshed, for example,\nto reflect the differing contents of the standard library across Python versions.", "anyOf": [ { "$ref": "#/definitions/PythonVersion" @@ -101,20 +112,24 @@ ] }, "root": { - "description": "The root paths of the project, used for finding first-party modules.\n\nAccepts a list of directory paths searched in priority order (first has highest priority).\n\nIf left unspecified, ty will try to detect common project layouts and initialize `root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout)\n\nBesides, if a `./python` or `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` or `__init__.pyi` file), it will also be included in the first party search path.", + "description": "The root paths of the project, used for finding first-party modules.\n\nAccepts a list of directory paths searched in priority order (first has highest priority).\n\nIf left unspecified, ty will try to detect common project layouts and initialize `root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat)\n* if a `.//` directory exists, include `.` and `./` in the first party search path\n* otherwise, default to `.` (flat layout)\n\nBesides, if a `./python` or `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` or `__init__.pyi` file),\nit will also be included in the first party search path.", "type": [ "array", "null" ], "items": { - "type": "string" + "$ref": "#/definitions/RelativePathBuf" } }, "typeshed": { - "description": "Optional path to a \"typeshed\" directory on disk for us to use for standard-library types. If this is not provided, we will fallback to our vendored typeshed stubs for the stdlib, bundled as a zip file in the binary", - "type": [ - "string", - "null" + "description": "Optional path to a \"typeshed\" directory on disk for us to use for standard-library types.\nIf this is not provided, we will fallback to our vendored typeshed stubs for the stdlib,\nbundled as a zip file in the binary", + "anyOf": [ + { + "$ref": "#/definitions/RelativePathBuf" + }, + { + "type": "null" + } ] } }, @@ -126,25 +141,19 @@ "title": "Ignore", "description": "The lint is disabled and should not run.", "type": "string", - "enum": [ - "ignore" - ] + "const": "ignore" }, { "title": "Warn", "description": "The lint is enabled and diagnostic should have a warning severity.", "type": "string", - "enum": [ - "warn" - ] + "const": "warn" }, { "title": "Error", "description": "The lint is enabled and diagnostics have an error severity.", "type": "string", - "enum": [ - "error" - ] + "const": "error" } ] }, @@ -152,32 +161,24 @@ "description": "The diagnostic output format.", "oneOf": [ { - "description": "The default full mode will print \"pretty\" diagnostics.\n\nThat is, color will be used when printing to a `tty`. Moreover, diagnostic messages may include additional context and annotations on the input to help understand the message.", + "description": "The default full mode will print \"pretty\" diagnostics.\n\nThat is, color will be used when printing to a `tty`.\nMoreover, diagnostic messages may include additional\ncontext and annotations on the input to help understand\nthe message.", "type": "string", - "enum": [ - "full" - ] + "const": "full" }, { - "description": "Print diagnostics in a concise mode.\n\nThis will guarantee that each diagnostic is printed on a single line. Only the most important or primary aspects of the diagnostic are included. Contextual information is dropped.\n\nThis may use color when printing to a `tty`.", + "description": "Print diagnostics in a concise mode.\n\nThis will guarantee that each diagnostic is printed on\na single line. Only the most important or primary aspects\nof the diagnostic are included. Contextual information is\ndropped.\n\nThis may use color when printing to a `tty`.", "type": "string", - "enum": [ - "concise" - ] + "const": "concise" }, { "description": "Print diagnostics in the JSON format expected by GitLab [Code Quality] reports.\n\n[Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format", "type": "string", - "enum": [ - "gitlab" - ] + "const": "gitlab" }, { "description": "Print diagnostics in the format used by [GitHub Actions] workflow error annotations.\n\n[GitHub Actions]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-error-message", "type": "string", - "enum": [ - "github" - ] + "const": "github" } ] }, @@ -185,27 +186,29 @@ "type": "object", "properties": { "exclude": { - "description": "A list of file and directory patterns to exclude from this override.\n\nPatterns follow a syntax similar to `.gitignore`. Exclude patterns take precedence over include patterns within the same override.\n\nIf not specified, defaults to `[]` (excludes no files).", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "description": "A list of file and directory patterns to exclude from this override.\n\nPatterns follow a syntax similar to `.gitignore`.\nExclude patterns take precedence over include patterns within the same override.\n\nIf not specified, defaults to `[]` (excludes no files).", + "anyOf": [ + { + "$ref": "#/definitions/Array_of_string" + }, + { + "type": "null" + } + ] }, "include": { - "description": "A list of file and directory patterns to include for this override.\n\nThe `include` option follows a similar syntax to `.gitignore` but reversed: Including a file or directory will make it so that it (and its contents) are affected by this override.\n\nIf not specified, defaults to `[\"**\"]` (matches all files).", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "description": "A list of file and directory patterns to include for this override.\n\nThe `include` option follows a similar syntax to `.gitignore` but reversed:\nIncluding a file or directory will make it so that it (and its contents)\nare affected by this override.\n\nIf not specified, defaults to `[\"**\"]` (matches all files).", + "anyOf": [ + { + "$ref": "#/definitions/Array_of_string" + }, + { + "type": "null" + } + ] }, "rules": { - "description": "Rule overrides for files matching the include/exclude patterns.\n\nThese rules will be merged with the global rules, with override rules taking precedence for matching files. You can set rules to different severity levels or disable them entirely.", + "description": "Rule overrides for files matching the include/exclude patterns.\n\nThese rules will be merged with the global rules, with override rules\ntaking precedence for matching files. You can set rules to different\nseverity levels or disable them entirely.", "anyOf": [ { "$ref": "#/definitions/Rules" @@ -218,6 +221,13 @@ }, "additionalProperties": false }, + "OverridesOptions": { + "description": "Configuration override that applies to specific files based on glob patterns.\n\nAn override allows you to apply different rule configurations to specific\nfiles or directories. Multiple overrides can match the same file, with\nlater overrides take precedence.\n\n### Precedence\n\n- Later overrides in the array take precedence over earlier ones\n- Override rules take precedence over global rules for matching files\n\n### Examples\n\n```toml\n# Relax rules for test files\n[[tool.ty.overrides]]\ninclude = [\"tests/**\", \"**/test_*.py\"]\n\n[tool.ty.overrides.rules]\npossibly-unresolved-reference = \"warn\"\n\n# Ignore generated files but still check important ones\n[[tool.ty.overrides]]\ninclude = [\"generated/**\"]\nexclude = [\"generated/important.py\"]\n\n[tool.ty.overrides.rules]\npossibly-unresolved-reference = \"ignore\"\n```", + "type": "array", + "items": { + "$ref": "#/definitions/OverrideOptions" + } + }, "PythonPlatform": { "description": "The target platform to assume when resolving types.\n", "anyOf": [ @@ -282,6 +292,14 @@ } ] }, + "RelativePathBuf": { + "description": "A possibly relative path in a configuration file.\n\nRelative paths in configuration files or from CLI options\nrequire different anchoring:\n\n* CLI: The path is relative to the current working directory\n* Configuration file: The path is relative to the project's root.", + "allOf": [ + { + "$ref": "#/definitions/SystemPathBuf" + } + ] + }, "Rules": { "type": "object", "properties": { @@ -1044,43 +1062,53 @@ "type": "object", "properties": { "exclude": { - "description": "A list of file and directory patterns to exclude from type checking.\n\nPatterns follow a syntax similar to `.gitignore`:\n\n- `./src/` matches only a directory - `./src` matches both files and directories - `src` matches files or directories named `src` - `*` matches any (possibly empty) sequence of characters (except `/`). - `**` matches zero or more path components. This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error. A sequence of more than two consecutive `*` characters is also invalid. - `?` matches any single character except `/` - `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode, so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid. - `!pattern` negates a pattern (undoes the exclusion of files that would otherwise be excluded)\n\nAll paths are anchored relative to the project root (`src` only matches `/src` and not `/test/src`). To exclude any directory or file named `src`, use `**/src` instead.\n\nBy default, ty excludes commonly ignored directories:\n\n- `**/.bzr/` - `**/.direnv/` - `**/.eggs/` - `**/.git/` - `**/.git-rewrite/` - `**/.hg/` - `**/.mypy_cache/` - `**/.nox/` - `**/.pants.d/` - `**/.pytype/` - `**/.ruff_cache/` - `**/.svn/` - `**/.tox/` - `**/.venv/` - `**/__pypackages__/` - `**/_build/` - `**/buck-out/` - `**/dist/` - `**/node_modules/` - `**/venv/`\n\nYou can override any default exclude by using a negated pattern. For example, to re-include `dist` use `exclude = [\"!dist\"]`", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "description": "A list of file and directory patterns to exclude from type checking.\n\nPatterns follow a syntax similar to `.gitignore`:\n\n- `./src/` matches only a directory\n- `./src` matches both files and directories\n- `src` matches files or directories named `src`\n- `*` matches any (possibly empty) sequence of characters (except `/`).\n- `**` matches zero or more path components.\n This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error.\n A sequence of more than two consecutive `*` characters is also invalid.\n- `?` matches any single character except `/`\n- `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode,\n so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.\n- `!pattern` negates a pattern (undoes the exclusion of files that would otherwise be excluded)\n\nAll paths are anchored relative to the project root (`src` only\nmatches `/src` and not `/test/src`).\nTo exclude any directory or file named `src`, use `**/src` instead.\n\nBy default, ty excludes commonly ignored directories:\n\n- `**/.bzr/`\n- `**/.direnv/`\n- `**/.eggs/`\n- `**/.git/`\n- `**/.git-rewrite/`\n- `**/.hg/`\n- `**/.mypy_cache/`\n- `**/.nox/`\n- `**/.pants.d/`\n- `**/.pytype/`\n- `**/.ruff_cache/`\n- `**/.svn/`\n- `**/.tox/`\n- `**/.venv/`\n- `**/__pypackages__/`\n- `**/_build/`\n- `**/buck-out/`\n- `**/dist/`\n- `**/node_modules/`\n- `**/venv/`\n\nYou can override any default exclude by using a negated pattern. For example,\nto re-include `dist` use `exclude = [\"!dist\"]`", + "anyOf": [ + { + "$ref": "#/definitions/Array_of_string" + }, + { + "type": "null" + } + ] }, "include": { - "description": "A list of files and directories to check. The `include` option follows a similar syntax to `.gitignore` but reversed: Including a file or directory will make it so that it (and its contents) are type checked.\n\n- `./src/` matches only a directory - `./src` matches both files and directories - `src` matches a file or directory named `src` - `*` matches any (possibly empty) sequence of characters (except `/`). - `**` matches zero or more path components. This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error. A sequence of more than two consecutive `*` characters is also invalid. - `?` matches any single character except `/` - `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode, so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.\n\nAll paths are anchored relative to the project root (`src` only matches `/src` and not `/test/src`).\n\n`exclude` takes precedence over `include`.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "description": "A list of files and directories to check. The `include` option\nfollows a similar syntax to `.gitignore` but reversed:\nIncluding a file or directory will make it so that it (and its contents)\nare type checked.\n\n- `./src/` matches only a directory\n- `./src` matches both files and directories\n- `src` matches a file or directory named `src`\n- `*` matches any (possibly empty) sequence of characters (except `/`).\n- `**` matches zero or more path components.\n This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error.\n A sequence of more than two consecutive `*` characters is also invalid.\n- `?` matches any single character except `/`\n- `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode,\n so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.\n\nAll paths are anchored relative to the project root (`src` only\nmatches `/src` and not `/test/src`).\n\n`exclude` takes precedence over `include`.", + "anyOf": [ + { + "$ref": "#/definitions/Array_of_string" + }, + { + "type": "null" + } + ] }, "respect-ignore-files": { - "description": "Whether to automatically exclude files that are ignored by `.ignore`, `.gitignore`, `.git/info/exclude`, and global `gitignore` files. Enabled by default.", + "description": "Whether to automatically exclude files that are ignored by `.ignore`,\n`.gitignore`, `.git/info/exclude`, and global `gitignore` files.\nEnabled by default.", "type": [ "boolean", "null" ] }, "root": { - "description": "The root of the project, used for finding first-party modules.\n\nIf left unspecified, ty will try to detect common project layouts and initialize `src.root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout)\n\nBesides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), it will also be included in the first party search path.", - "deprecated": true, - "type": [ - "string", - "null" - ] + "description": "The root of the project, used for finding first-party modules.\n\nIf left unspecified, ty will try to detect common project layouts and initialize `src.root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat)\n* if a `.//` directory exists, include `.` and `./` in the first party search path\n* otherwise, default to `.` (flat layout)\n\nBesides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file),\nit will also be included in the first party search path.", + "anyOf": [ + { + "$ref": "#/definitions/RelativePathBuf" + }, + { + "type": "null" + } + ], + "deprecated": true } }, "additionalProperties": false }, + "SystemPathBuf": { + "description": "An owned, mutable path on [`System`](`super::System`) (akin to [`String`]).\n\nThe path is guaranteed to be valid UTF-8.", + "type": "string" + }, "TerminalOptions": { "type": "object", "properties": { @@ -1104,6 +1132,9 @@ } }, "additionalProperties": false + }, + "string": { + "type": "string" } } } \ No newline at end of file From 5a1201b86866176be8f4f6ed773e53dac7b13adf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 07:00:08 +0000 Subject: [PATCH 112/113] Update Rust crate getrandom to v0.3.4 (#20977) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Micha Reiser --- .cargo/config.toml | 4 ---- Cargo.lock | 31 +++++++++++-------------------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 5e3900f082..f7d8e616f8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -8,7 +8,3 @@ benchmark = "bench -p ruff_benchmark --bench linter --bench formatter --" # See: https://github.com/astral-sh/ruff/issues/11503 [target.'cfg(all(target_env="msvc", target_os = "windows"))'] rustflags = ["-C", "target-feature=+crt-static"] - -[target.'wasm32-unknown-unknown'] -# See https://docs.rs/getrandom/latest/getrandom/#webassembly-support -rustflags = ["--cfg", 'getrandom_backend="wasm_js"'] \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 25fa2b9632..7850570a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,20 +1262,20 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] @@ -1789,7 +1789,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] @@ -2072,7 +2072,7 @@ checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.59.0", ] @@ -2724,7 +2724,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -3451,7 +3451,7 @@ version = "0.14.1" dependencies = [ "console_error_panic_hook", "console_log", - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "log", "ruff_formatter", @@ -3938,7 +3938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.52.0", @@ -4564,7 +4564,7 @@ version = "0.0.0" dependencies = [ "console_error_panic_hook", "console_log", - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "log", "ruff_db", @@ -4757,7 +4757,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "rand 0.9.2", "uuid-macro-internal", @@ -4869,15 +4869,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" From c2ae9c7806b716a688ec62f84abad9152fee809c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Riegel?= <96702577+LoicRiegel@users.noreply.github.com> Date: Mon, 20 Oct 2025 09:09:51 +0200 Subject: [PATCH 113/113] feat: 'ruff rule' provides more easily parsable JSON ouput (#20168) --- crates/ruff/src/commands/rule.rs | 5 + crates/ruff/tests/integration_test.rs | 38 +++++ ...tegration_test__rule_f401_output_json.snap | 30 ++++ ...tegration_test__rule_f401_output_text.snap | 140 ++++++++++++++++++ crates/ruff_linter/src/codes.rs | 3 +- crates/ruff_linter/src/violation.rs | 4 +- 6 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 crates/ruff/tests/snapshots/integration_test__rule_f401_output_json.snap create mode 100644 crates/ruff/tests/snapshots/integration_test__rule_f401_output_text.snap diff --git a/crates/ruff/src/commands/rule.rs b/crates/ruff/src/commands/rule.rs index adc761b3e5..6d797f8744 100644 --- a/crates/ruff/src/commands/rule.rs +++ b/crates/ruff/src/commands/rule.rs @@ -7,6 +7,7 @@ use serde::{Serialize, Serializer}; use strum::IntoEnumIterator; use ruff_linter::FixAvailability; +use ruff_linter::codes::RuleGroup; use ruff_linter::registry::{Linter, Rule, RuleNamespace}; use crate::args::HelpFormat; @@ -19,9 +20,11 @@ struct Explanation<'a> { summary: &'a str, message_formats: &'a [&'a str], fix: String, + fix_availability: FixAvailability, #[expect(clippy::struct_field_names)] explanation: Option<&'a str>, preview: bool, + status: RuleGroup, } impl<'a> Explanation<'a> { @@ -36,8 +39,10 @@ impl<'a> Explanation<'a> { summary: rule.message_formats()[0], message_formats: rule.message_formats(), fix, + fix_availability: rule.fixable(), explanation: rule.explanation(), preview: rule.is_preview(), + status: rule.group(), } } } diff --git a/crates/ruff/tests/integration_test.rs b/crates/ruff/tests/integration_test.rs index edca13cd72..62221419fe 100644 --- a/crates/ruff/tests/integration_test.rs +++ b/crates/ruff/tests/integration_test.rs @@ -951,6 +951,16 @@ fn rule_f401() { assert_cmd_snapshot!(ruff_cmd().args(["rule", "F401"])); } +#[test] +fn rule_f401_output_json() { + assert_cmd_snapshot!(ruff_cmd().args(["rule", "F401", "--output-format", "json"])); +} + +#[test] +fn rule_f401_output_text() { + assert_cmd_snapshot!(ruff_cmd().args(["rule", "F401", "--output-format", "text"])); +} + #[test] fn rule_invalid_rule_name() { assert_cmd_snapshot!(ruff_cmd().args(["rule", "RUF404"]), @r" @@ -965,6 +975,34 @@ fn rule_invalid_rule_name() { "); } +#[test] +fn rule_invalid_rule_name_output_json() { + assert_cmd_snapshot!(ruff_cmd().args(["rule", "RUF404", "--output-format", "json"]), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'RUF404' for '[RULE]' + + For more information, try '--help'. + "); +} + +#[test] +fn rule_invalid_rule_name_output_text() { + assert_cmd_snapshot!(ruff_cmd().args(["rule", "RUF404", "--output-format", "text"]), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'RUF404' for '[RULE]' + + For more information, try '--help'. + "); +} + #[test] fn show_statistics() { let mut cmd = RuffCheck::default() diff --git a/crates/ruff/tests/snapshots/integration_test__rule_f401_output_json.snap b/crates/ruff/tests/snapshots/integration_test__rule_f401_output_json.snap new file mode 100644 index 0000000000..51dffdf4a7 --- /dev/null +++ b/crates/ruff/tests/snapshots/integration_test__rule_f401_output_json.snap @@ -0,0 +1,30 @@ +--- +source: crates/ruff/tests/integration_test.rs +info: + program: ruff + args: + - rule + - F401 + - "--output-format" + - json +--- +success: true +exit_code: 0 +----- stdout ----- +{ + "name": "unused-import", + "code": "F401", + "linter": "Pyflakes", + "summary": "`{name}` imported but unused; consider using `importlib.util.find_spec` to test for availability", + "message_formats": [ + "`{name}` imported but unused; consider using `importlib.util.find_spec` to test for availability", + "`{name}` imported but unused; consider removing, adding to `__all__`, or using a redundant alias", + "`{name}` imported but unused" + ], + "fix": "Fix is sometimes available.", + "fix_availability": "Sometimes", + "explanation": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Preview\nWhen [preview] is enabled (and certain simplifying assumptions\nare met), we analyze all import statements for a given module\nwhen determining whether an import is used, rather than simply\nthe last of these statements. This can result in both different and\nmore import statements being marked as unused.\n\nFor example, if a module consists of\n\n```python\nimport a\nimport a.b\n```\n\nthen both statements are marked as unused under [preview], whereas\nonly the second is marked as unused under stable behavior.\n\nAs another example, if a module consists of\n\n```python\nimport a.b\nimport a\n\na.b.foo()\n```\n\nthen a diagnostic will only be emitted for the first line under [preview],\nwhereas a diagnostic would only be emitted for the second line under\nstable behavior.\n\nNote that this behavior is somewhat subjective and is designed\nto conform to the developer's intuition rather than Python's actual\nexecution. To wit, the statement `import a.b` automatically executes\n`import a`, so in some sense `import a` is _always_ redundant\nin the presence of `import a.b`.\n\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\nSee [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc)\nfor more details on how Ruff\ndetermines whether an import is first or third-party.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols)\n\n[preview]: https://docs.astral.sh/ruff/preview/\n", + "preview": false, + "status": "Stable" +} +----- stderr ----- diff --git a/crates/ruff/tests/snapshots/integration_test__rule_f401_output_text.snap b/crates/ruff/tests/snapshots/integration_test__rule_f401_output_text.snap new file mode 100644 index 0000000000..120e96d9ac --- /dev/null +++ b/crates/ruff/tests/snapshots/integration_test__rule_f401_output_text.snap @@ -0,0 +1,140 @@ +--- +source: crates/ruff/tests/integration_test.rs +info: + program: ruff + args: + - rule + - F401 + - "--output-format" + - text +--- +success: true +exit_code: 0 +----- stdout ----- +# unused-import (F401) + +Derived from the **Pyflakes** linter. + +Fix is sometimes available. + +## What it does +Checks for unused imports. + +## Why is this bad? +Unused imports add a performance overhead at runtime, and risk creating +import cycles. They also increase the cognitive load of reading the code. + +If an import statement is used to check for the availability or existence +of a module, consider using `importlib.util.find_spec` instead. + +If an import statement is used to re-export a symbol as part of a module's +public interface, consider using a "redundant" import alias, which +instructs Ruff (and other tools) to respect the re-export, and avoid +marking it as unused, as in: + +```python +from module import member as member +``` + +Alternatively, you can use `__all__` to declare a symbol as part of the module's +interface, as in: + +```python +# __init__.py +import some_module + +__all__ = ["some_module"] +``` + +## Preview +When [preview] is enabled (and certain simplifying assumptions +are met), we analyze all import statements for a given module +when determining whether an import is used, rather than simply +the last of these statements. This can result in both different and +more import statements being marked as unused. + +For example, if a module consists of + +```python +import a +import a.b +``` + +then both statements are marked as unused under [preview], whereas +only the second is marked as unused under stable behavior. + +As another example, if a module consists of + +```python +import a.b +import a + +a.b.foo() +``` + +then a diagnostic will only be emitted for the first line under [preview], +whereas a diagnostic would only be emitted for the second line under +stable behavior. + +Note that this behavior is somewhat subjective and is designed +to conform to the developer's intuition rather than Python's actual +execution. To wit, the statement `import a.b` automatically executes +`import a`, so in some sense `import a` is _always_ redundant +in the presence of `import a.b`. + + +## Fix safety + +Fixes to remove unused imports are safe, except in `__init__.py` files. + +Applying fixes to `__init__.py` files is currently in preview. The fix offered depends on the +type of the unused import. Ruff will suggest a safe fix to export first-party imports with +either a redundant alias or, if already present in the file, an `__all__` entry. If multiple +`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix +to remove third-party and standard library imports -- the fix is unsafe because the module's +interface changes. + +See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) +for more details on how Ruff +determines whether an import is first or third-party. + +## Example + +```python +import numpy as np # unused import + + +def area(radius): + return 3.14 * radius**2 +``` + +Use instead: + +```python +def area(radius): + return 3.14 * radius**2 +``` + +To check the availability of a module, use `importlib.util.find_spec`: + +```python +from importlib.util import find_spec + +if find_spec("numpy") is not None: + print("numpy is installed") +else: + print("numpy is not installed") +``` + +## Options +- `lint.ignore-init-module-imports` +- `lint.pyflakes.allowed-unused-imports` + +## References +- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement) +- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec) +- [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols) + +[preview]: https://docs.astral.sh/ruff/preview/ + +----- stderr ----- diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 86e3bf8ebc..8b762c2c72 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -5,6 +5,7 @@ use std::fmt::Formatter; use ruff_db::diagnostic::SecondaryCode; +use serde::Serialize; use strum_macros::EnumIter; use crate::registry::Linter; @@ -74,7 +75,7 @@ impl serde::Serialize for NoqaCode { } } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, Serialize)] pub enum RuleGroup { /// The rule is stable. Stable, diff --git a/crates/ruff_linter/src/violation.rs b/crates/ruff_linter/src/violation.rs index f32fa40e9a..bcf671d489 100644 --- a/crates/ruff_linter/src/violation.rs +++ b/crates/ruff_linter/src/violation.rs @@ -1,12 +1,14 @@ use std::fmt::{Debug, Display}; +use serde::Serialize; + use ruff_db::diagnostic::Diagnostic; use ruff_source_file::SourceFile; use ruff_text_size::TextRange; use crate::{codes::Rule, message::create_lint_diagnostic}; -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, Serialize)] pub enum FixAvailability { Sometimes, Always,