From be8eb92946bb6bcc83674be050f118aa00d92d6a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 13 Dec 2025 17:10:25 -0500 Subject: [PATCH 1/8] [ty] Add support for `__qualname__` and other implicit class attributes (#21966) ## Summary Closes https://github.com/astral-sh/ty/issues/1873 --- .../mdtest/scopes/class_implicit_attrs.md | 120 ++++++++++++++++++ crates/ty_python_semantic/src/place.rs | 30 +++++ .../src/types/infer/builder.rs | 25 +++- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md diff --git a/crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md b/crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md new file mode 100644 index 0000000000..7130538acf --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md @@ -0,0 +1,120 @@ +# Implicit class body attributes + +## Class body implicit attributes + +Python makes certain names available implicitly inside class body scopes. These are `__qualname__`, +`__module__`, and `__doc__`, as documented at +. + +```py +class Foo: + reveal_type(__qualname__) # revealed: str + reveal_type(__module__) # revealed: str + reveal_type(__doc__) # revealed: str | None +``` + +## `__firstlineno__` (Python 3.13+) + +Python 3.13 added `__firstlineno__` to the class body namespace. + +### Available in Python 3.13+ + +```toml +[environment] +python-version = "3.13" +``` + +```py +class Foo: + reveal_type(__firstlineno__) # revealed: int +``` + +### Not available in Python 3.12 and earlier + +```toml +[environment] +python-version = "3.12" +``` + +```py +class Foo: + # error: [unresolved-reference] + __firstlineno__ +``` + +## Nested classes + +These implicit attributes are also available in nested classes, and refer to the nested class: + +```py +class Outer: + class Inner: + reveal_type(__qualname__) # revealed: str + reveal_type(__module__) # revealed: str +``` + +## Class body implicit attributes have priority over globals + +If a global variable with the same name exists, the class body implicit attribute takes priority +within the class body: + +```py +__qualname__ = 42 +__module__ = 42 + +class Foo: + # Inside the class body, these are the implicit class attributes + reveal_type(__qualname__) # revealed: str + reveal_type(__module__) # revealed: str + +# Outside the class, the globals are visible +reveal_type(__qualname__) # revealed: Literal[42] +reveal_type(__module__) # revealed: Literal[42] +``` + +## `__firstlineno__` has priority over globals (Python 3.13+) + +The same applies to `__firstlineno__` on Python 3.13+: + +```toml +[environment] +python-version = "3.13" +``` + +```py +__firstlineno__ = "not an int" + +class Foo: + reveal_type(__firstlineno__) # revealed: int + +reveal_type(__firstlineno__) # revealed: Literal["not an int"] +``` + +## Class body implicit attributes are not visible in methods + +The implicit class body attributes are only available directly in the class body, not in nested +function scopes (methods): + +```py +class Foo: + # Available directly in the class body + x = __qualname__ + reveal_type(x) # revealed: str + + def method(self): + # Not available in methods - falls back to builtins/globals + # error: [unresolved-reference] + __qualname__ +``` + +## Real-world use case: logging + +A common use case is defining a logger with the class name: + +```py +import logging + +class MyClass: + logger = logging.getLogger(__qualname__) + reveal_type(logger) # revealed: Logger +``` diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index a1319a3250..21dfb955a9 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1,4 +1,5 @@ use ruff_db::files::File; +use ruff_python_ast::PythonVersion; use crate::dunder_all::dunder_all_names; use crate::module_resolver::{KnownModule, file_to_module, resolve_module_confident}; @@ -1633,6 +1634,35 @@ mod implicit_globals { } } +/// Looks up the type of an "implicit class body symbol". Returns [`Place::Undefined`] if +/// `name` is not present as an implicit symbol in class bodies. +/// +/// Implicit class body symbols are symbols such as `__qualname__`, `__module__`, `__doc__`, +/// and `__firstlineno__` that Python implicitly makes available inside a class body during +/// class creation. +/// +/// See +pub(crate) fn class_body_implicit_symbol<'db>( + db: &'db dyn Db, + name: &str, +) -> PlaceAndQualifiers<'db> { + match name { + "__qualname__" => Place::bound(KnownClass::Str.to_instance(db)).into(), + "__module__" => Place::bound(KnownClass::Str.to_instance(db)).into(), + // __doc__ is `str` if there's a docstring, `None` if there isn't + "__doc__" => Place::bound(UnionType::from_elements( + db, + [KnownClass::Str.to_instance(db), Type::none(db)], + )) + .into(), + // __firstlineno__ was added in Python 3.13 + "__firstlineno__" if Program::get(db).python_version(db) >= PythonVersion::PY313 => { + Place::bound(KnownClass::Int.to_instance(db)).into() + } + _ => Place::Undefined.into(), + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub(crate) enum RequiresExplicitReExport { Yes, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 43940a4d88..9ae325a9ae 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -28,9 +28,9 @@ use crate::module_resolver::{ use crate::node_key::NodeKey; use crate::place::{ 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, + builtins_module_scope, builtins_symbol, class_body_implicit_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, }; use crate::semantic_index::ast_ids::node_key::ExpressionNodeKey; use crate::semantic_index::ast_ids::{HasScopedUseId, ScopedUseId}; @@ -9210,6 +9210,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } PlaceAndQualifiers::from(Place::Undefined) + // If we're in a class body, check for implicit class body symbols first. + // These take precedence over globals. + .or_fall_back_to(db, || { + if scope.node(db).scope_kind().is_class() + && let Some(symbol) = place_expr.as_symbol() + { + let implicit = class_body_implicit_symbol(db, symbol.name()); + if implicit.place.is_definitely_bound() { + return implicit.map_type(|ty| { + self.narrow_place_with_applicable_constraints( + place_expr, + ty, + &constraint_keys, + ) + }); + } + } + Place::Undefined.into() + }) // 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, || { From c7eea1f2e33ba67480d4367a45559bf8f2bd1e49 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 13 Dec 2025 22:56:59 +0000 Subject: [PATCH 2/8] Update debug_assert which pointed at missing method (#21969) ## Summary I assume that the class has been renamed or split since this assertion was created. ## Test Plan Compiled locally, nothing more. Relying on CI given the triviality of this change. --- crates/ruff_diagnostics/src/edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_diagnostics/src/edit.rs b/crates/ruff_diagnostics/src/edit.rs index 194b4e4494..ae52088608 100644 --- a/crates/ruff_diagnostics/src/edit.rs +++ b/crates/ruff_diagnostics/src/edit.rs @@ -39,7 +39,7 @@ impl Edit { /// Creates an edit that replaces the content in `range` with `content`. pub fn range_replacement(content: String, range: TextRange) -> Self { - debug_assert!(!content.is_empty(), "Prefer `Fix::deletion`"); + debug_assert!(!content.is_empty(), "Prefer `Edit::deletion`"); Self { content: Some(Box::from(content)), From 8bc753b842967b53dc2698fdff8e1a410ffafa46 Mon Sep 17 00:00:00 2001 From: Leandro Braga <18340809+leandrobbraga@users.noreply.github.com> Date: Sun, 14 Dec 2025 06:21:54 -0300 Subject: [PATCH 3/8] [ty] Fix callout syntax in configuration mkdocs (#1875) (#21961) --- crates/ruff_dev/src/generate_ty_options.rs | 4 ++-- crates/ty/docs/configuration.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ruff_dev/src/generate_ty_options.rs b/crates/ruff_dev/src/generate_ty_options.rs index 4e4ab0a949..733af8b00a 100644 --- a/crates/ruff_dev/src/generate_ty_options.rs +++ b/crates/ruff_dev/src/generate_ty_options.rs @@ -144,8 +144,8 @@ fn emit_field(output: &mut String, name: &str, field: &OptionField, parents: &[S output.push('\n'); if let Some(deprecated) = &field.deprecated { - output.push_str("> [!WARN] \"Deprecated\"\n"); - output.push_str("> This option has been deprecated"); + output.push_str("!!! warning \"Deprecated\"\n"); + output.push_str(" This option has been deprecated"); if let Some(since) = deprecated.since { write!(output, " in {since}").unwrap(); diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 0768f1b26a..edbea9c803 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -432,8 +432,8 @@ respect-ignore-files = false ### `root` -> [!WARN] "Deprecated" -> This option has been deprecated. Use `environment.root` instead. +!!! warning "Deprecated" + This option has been deprecated. Use `environment.root` instead. The root of the project, used for finding first-party modules. From 04f9949711ef7048d93da6bde00e9cb9dea4d3f5 Mon Sep 17 00:00:00 2001 From: Bhuminjay Soni Date: Mon, 15 Dec 2025 01:05:37 +0530 Subject: [PATCH 4/8] [ty] Emit diagnostic when a type variable with a default is followed by one without a default (#21787) Co-authored-by: Alex Waygood --- crates/ty/docs/rules.md | 103 +++++----- .../invalid_type_parameter_order.md | 43 ++++ .../mdtest/generics/legacy/paramspec.md | 3 +- ...nvalid_Order_of_Leg…_(eaa359e8d6b3031d).snap | 190 ++++++++++++++++++ .../src/types/diagnostic.rs | 95 ++++++++- .../src/types/infer/builder.rs | 75 +++++-- ty.schema.json | 2 +- 7 files changed, 438 insertions(+), 73 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_type_parameter_order.md create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/invalid_type_paramet…_-_Invalid_Order_of_Leg…_(eaa359e8d6b3031d).snap diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index c2b450ae10..8b35be52a6 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -557,7 +557,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -751,7 +751,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -793,7 +793,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -848,16 +848,21 @@ Checks for the creation of invalid generic classes **Why is this bad?** There are several requirements that you must follow when defining a generic class. +Many of these result in `TypeError` being raised at runtime if they are violated. **Examples** ```python -from typing import Generic, TypeVar +from typing_extensions import Generic, TypeVar -T = TypeVar("T") # okay +T = TypeVar("T") +U = TypeVar("U", default=int) # error: class uses both PEP-695 syntax and legacy syntax class C[U](Generic[T]): ... + +# error: type parameter with default comes before type parameter without default +class D(Generic[U, T]): ... ``` **References** @@ -909,7 +914,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -944,7 +949,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -978,7 +983,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1139,7 +1144,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -1169,7 +1174,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1219,7 +1224,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1245,7 +1250,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1310,7 +1315,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 @@ -1384,7 +1389,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1442,7 +1447,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1469,7 +1474,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1516,7 +1521,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1546,7 +1551,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1576,7 +1581,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 @@ -1610,7 +1615,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1644,7 +1649,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1679,7 +1684,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 @@ -1704,7 +1709,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 @@ -1737,7 +1742,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1766,7 +1771,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1790,7 +1795,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 @@ -1816,7 +1821,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1849,7 +1854,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1876,7 +1881,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1934,7 +1939,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1964,7 +1969,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 @@ -1993,7 +1998,7 @@ class B(A): ... # Error raised here Default level: error · Preview (since 0.0.1-alpha.30) · Related issues · -View source +View source @@ -2027,7 +2032,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2054,7 +2059,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2082,7 +2087,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2128,7 +2133,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2155,7 +2160,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 @@ -2183,7 +2188,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 @@ -2208,7 +2213,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2233,7 +2238,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 @@ -2270,7 +2275,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2298,7 +2303,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 @@ -2452,7 +2457,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2512,7 +2517,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 @@ -2544,7 +2549,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 @@ -2571,7 +2576,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2595,7 +2600,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2692,7 +2697,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2779,7 +2784,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_python_semantic/resources/mdtest/diagnostics/invalid_type_parameter_order.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_type_parameter_order.md new file mode 100644 index 0000000000..705ceae6b6 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_type_parameter_order.md @@ -0,0 +1,43 @@ +# Invalid Order of Legacy Type Parameters + + + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import TypeVar, Generic, Protocol + +T1 = TypeVar("T1", default=int) + +T2 = TypeVar("T2") +T3 = TypeVar("T3") + +DefaultStrT = TypeVar("DefaultStrT", default=str) + +class SubclassMe(Generic[T1, DefaultStrT]): + x: DefaultStrT + +class Baz(SubclassMe[int, DefaultStrT]): + pass + +# error: [invalid-generic-class] "Type parameter `T2` without a default cannot follow earlier parameter `T1` with a default" +class Foo(Generic[T1, T2]): + pass + +class Bar(Generic[T2, T1, T3]): # error: [invalid-generic-class] + pass + +class Spam(Generic[T1, T2, DefaultStrT, T3]): # error: [invalid-generic-class] + pass + +class Ham(Protocol[T1, T2, DefaultStrT, T3]): # error: [invalid-generic-class] + pass + +class VeryBad( + Protocol[T1, T2, DefaultStrT, T3], # error: [invalid-generic-class] + Generic[T1, T2, DefaultStrT, T3], +): ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index 7a365b6405..5e3cbe3888 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -424,9 +424,8 @@ p3 = ParamSpecWithDefault4[[int], [str]]() reveal_type(p3.attr1) # revealed: (int, /) -> None reveal_type(p3.attr2) # revealed: (str, /) -> None -# TODO: error # Un-ordered type variables as the default of `PAnother` is `P` -class ParamSpecWithDefault5(Generic[PAnother, P]): +class ParamSpecWithDefault5(Generic[PAnother, P]): # error: [invalid-generic-class] attr: Callable[PAnother, None] # TODO: error diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_type_paramet…_-_Invalid_Order_of_Leg…_(eaa359e8d6b3031d).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_type_paramet…_-_Invalid_Order_of_Leg…_(eaa359e8d6b3031d).snap new file mode 100644 index 0000000000..290fbb0ae0 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_type_paramet…_-_Invalid_Order_of_Leg…_(eaa359e8d6b3031d).snap @@ -0,0 +1,190 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: invalid_type_parameter_order.md - Invalid Order of Legacy Type Parameters +mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_type_parameter_order.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from typing import TypeVar, Generic, Protocol + 2 | + 3 | T1 = TypeVar("T1", default=int) + 4 | + 5 | T2 = TypeVar("T2") + 6 | T3 = TypeVar("T3") + 7 | + 8 | DefaultStrT = TypeVar("DefaultStrT", default=str) + 9 | +10 | class SubclassMe(Generic[T1, DefaultStrT]): +11 | x: DefaultStrT +12 | +13 | class Baz(SubclassMe[int, DefaultStrT]): +14 | pass +15 | +16 | # error: [invalid-generic-class] "Type parameter `T2` without a default cannot follow earlier parameter `T1` with a default" +17 | class Foo(Generic[T1, T2]): +18 | pass +19 | +20 | class Bar(Generic[T2, T1, T3]): # error: [invalid-generic-class] +21 | pass +22 | +23 | class Spam(Generic[T1, T2, DefaultStrT, T3]): # error: [invalid-generic-class] +24 | pass +25 | +26 | class Ham(Protocol[T1, T2, DefaultStrT, T3]): # error: [invalid-generic-class] +27 | pass +28 | +29 | class VeryBad( +30 | Protocol[T1, T2, DefaultStrT, T3], # error: [invalid-generic-class] +31 | Generic[T1, T2, DefaultStrT, T3], +32 | ): ... +``` + +# Diagnostics + +``` +error[invalid-generic-class]: Type parameters without defaults cannot follow type parameters with defaults + --> src/mdtest_snippet.py:17:19 + | +16 | # error: [invalid-generic-class] "Type parameter `T2` without a default cannot follow earlier parameter `T1` with a default" +17 | class Foo(Generic[T1, T2]): + | ^^^^^^ + | | + | Type variable `T2` does not have a default + | Earlier TypeVar `T1` does +18 | pass + | + ::: src/mdtest_snippet.py:3:1 + | + 1 | from typing import TypeVar, Generic, Protocol + 2 | + 3 | T1 = TypeVar("T1", default=int) + | ------------------------------- `T1` defined here + 4 | + 5 | T2 = TypeVar("T2") + | ------------------ `T2` defined here + 6 | T3 = TypeVar("T3") + | +info: rule `invalid-generic-class` is enabled by default + +``` + +``` +error[invalid-generic-class]: Type parameters without defaults cannot follow type parameters with defaults + --> src/mdtest_snippet.py:20:19 + | +18 | pass +19 | +20 | class Bar(Generic[T2, T1, T3]): # error: [invalid-generic-class] + | ^^^^^^^^^^ + | | + | Type variable `T3` does not have a default + | Earlier TypeVar `T1` does +21 | pass + | + ::: src/mdtest_snippet.py:3:1 + | + 1 | from typing import TypeVar, Generic, Protocol + 2 | + 3 | T1 = TypeVar("T1", default=int) + | ------------------------------- `T1` defined here + 4 | + 5 | T2 = TypeVar("T2") + 6 | T3 = TypeVar("T3") + | ------------------ `T3` defined here + 7 | + 8 | DefaultStrT = TypeVar("DefaultStrT", default=str) + | +info: rule `invalid-generic-class` is enabled by default + +``` + +``` +error[invalid-generic-class]: Type parameters without defaults cannot follow type parameters with defaults + --> src/mdtest_snippet.py:23:20 + | +21 | pass +22 | +23 | class Spam(Generic[T1, T2, DefaultStrT, T3]): # error: [invalid-generic-class] + | ^^^^^^^^^^^^^^^^^^^^^^^ + | | + | Type variables `T2` and `T3` do not have defaults + | Earlier TypeVar `T1` does +24 | pass + | + ::: src/mdtest_snippet.py:3:1 + | + 1 | from typing import TypeVar, Generic, Protocol + 2 | + 3 | T1 = TypeVar("T1", default=int) + | ------------------------------- `T1` defined here + 4 | + 5 | T2 = TypeVar("T2") + | ------------------ `T2` defined here + 6 | T3 = TypeVar("T3") + | +info: rule `invalid-generic-class` is enabled by default + +``` + +``` +error[invalid-generic-class]: Type parameters without defaults cannot follow type parameters with defaults + --> src/mdtest_snippet.py:26:20 + | +24 | pass +25 | +26 | class Ham(Protocol[T1, T2, DefaultStrT, T3]): # error: [invalid-generic-class] + | ^^^^^^^^^^^^^^^^^^^^^^^ + | | + | Type variables `T2` and `T3` do not have defaults + | Earlier TypeVar `T1` does +27 | pass + | + ::: src/mdtest_snippet.py:3:1 + | + 1 | from typing import TypeVar, Generic, Protocol + 2 | + 3 | T1 = TypeVar("T1", default=int) + | ------------------------------- `T1` defined here + 4 | + 5 | T2 = TypeVar("T2") + | ------------------ `T2` defined here + 6 | T3 = TypeVar("T3") + | +info: rule `invalid-generic-class` is enabled by default + +``` + +``` +error[invalid-generic-class]: Type parameters without defaults cannot follow type parameters with defaults + --> src/mdtest_snippet.py:30:14 + | +29 | class VeryBad( +30 | Protocol[T1, T2, DefaultStrT, T3], # error: [invalid-generic-class] + | ^^^^^^^^^^^^^^^^^^^^^^^ + | | + | Type variables `T2` and `T3` do not have defaults + | Earlier TypeVar `T1` does +31 | Generic[T1, T2, DefaultStrT, T3], +32 | ): ... + | + ::: src/mdtest_snippet.py:3:1 + | + 1 | from typing import TypeVar, Generic, Protocol + 2 | + 3 | T1 = TypeVar("T1", default=int) + | ------------------------------- `T1` defined here + 4 | + 5 | T2 = TypeVar("T2") + | ------------------ `T2` defined here + 6 | T3 = TypeVar("T3") + | +info: rule `invalid-generic-class` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 3acd7b0a64..e136057d45 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -30,7 +30,7 @@ use crate::types::{ ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type, protocol_class::ProtocolClass, }; -use crate::types::{DataclassFlags, KnownInstanceType, MemberLookupPolicy}; +use crate::types::{DataclassFlags, KnownInstanceType, MemberLookupPolicy, TypeVarInstance}; use crate::{Db, DisplaySettings, FxIndexMap, Module, ModuleName, Program, declare_lint}; use itertools::Itertools; use ruff_db::{ @@ -894,15 +894,20 @@ declare_lint! { /// /// ## Why is this bad? /// There are several requirements that you must follow when defining a generic class. + /// Many of these result in `TypeError` being raised at runtime if they are violated. /// /// ## Examples /// ```python - /// from typing import Generic, TypeVar + /// from typing_extensions import Generic, TypeVar /// - /// T = TypeVar("T") # okay + /// T = TypeVar("T") + /// U = TypeVar("U", default=int) /// /// # error: class uses both PEP-695 syntax and legacy syntax /// class C[U](Generic[T]): ... + /// + /// # error: type parameter with default comes before type parameter without default + /// class D(Generic[U, T]): ... /// ``` /// /// ## References @@ -3695,6 +3700,90 @@ pub(crate) fn report_cannot_pop_required_field_on_typed_dict<'db>( } } +pub(crate) fn report_invalid_type_param_order<'db>( + context: &InferContext<'db, '_>, + class: ClassLiteral<'db>, + node: &ast::StmtClassDef, + typevar_with_default: TypeVarInstance<'db>, + invalid_later_typevars: &[TypeVarInstance<'db>], +) { + let db = context.db(); + + let base_index = class + .explicit_bases(db) + .iter() + .position(|base| { + matches!( + base, + Type::KnownInstance( + KnownInstanceType::SubscriptedProtocol(_) + | KnownInstanceType::SubscriptedGeneric(_) + ) + ) + }) + .expect( + "It should not be possible for a class to have a legacy generic context \ + if it does not inherit from `Protocol[]` or `Generic[]`", + ); + + let base_node = &node.bases()[base_index]; + + let primary_diagnostic_range = base_node + .as_subscript_expr() + .map(|subscript| &*subscript.slice) + .unwrap_or(base_node) + .range(); + + let Some(builder) = context.report_lint(&INVALID_GENERIC_CLASS, primary_diagnostic_range) + else { + return; + }; + + let mut diagnostic = builder.into_diagnostic( + "Type parameters without defaults cannot follow type parameters with defaults", + ); + + diagnostic.set_concise_message(format_args!( + "Type parameter `{}` without a default cannot follow earlier parameter `{}` with a default", + invalid_later_typevars[0].name(db), + typevar_with_default.name(db), + )); + + if let [single_typevar] = invalid_later_typevars { + diagnostic.set_primary_message(format_args!( + "Type variable `{}` does not have a default", + single_typevar.name(db), + )); + } else { + let later_typevars = + format_enumeration(invalid_later_typevars.iter().map(|tv| tv.name(db))); + diagnostic.set_primary_message(format_args!( + "Type variables {later_typevars} do not have defaults", + )); + } + + diagnostic.annotate( + Annotation::primary(Span::from(context.file()).with_range(primary_diagnostic_range)) + .message(format_args!( + "Earlier TypeVar `{}` does", + typevar_with_default.name(db) + )), + ); + + for tvar in [typevar_with_default, invalid_later_typevars[0]] { + let Some(definition) = tvar.definition(db) else { + continue; + }; + let file = definition.file(db); + diagnostic.annotate( + Annotation::secondary(Span::from( + definition.full_range(db, &parsed_module(db, file).load(db)), + )) + .message(format_args!("`{}` defined here", tvar.name(db))), + ); + } +} + pub(crate) fn report_rebound_typevar<'db>( context: &InferContext<'db, '_>, typevar_name: &ast::name::Name, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 9ae325a9ae..723e89519c 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -78,7 +78,7 @@ use crate::types::diagnostic::{ report_invalid_exception_tuple_caught, 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_named_tuple_field_with_leading_underscore, + report_invalid_type_param_order, report_named_tuple_field_with_leading_underscore, 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, report_unsupported_augmented_assignment, @@ -949,23 +949,62 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - 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, - ); + if self.context.is_lint_enabled(&INVALID_GENERIC_CLASS) { + if !class.has_pep_695_type_params(self.db()) + && let Some(generic_context) = class.legacy_generic_context(self.db()) + { + struct State<'db> { + typevar_with_default: TypeVarInstance<'db>, + invalid_later_tvars: Vec>, + } + + let mut state: Option> = None; + + for bound_typevar in generic_context.variables(self.db()) { + let typevar = bound_typevar.typevar(self.db()); + let has_default = typevar.default_type(self.db()).is_some(); + + if let Some(state) = state.as_mut() { + if !has_default { + state.invalid_later_tvars.push(typevar); + } + } else if has_default { + state = Some(State { + typevar_with_default: typevar, + invalid_later_tvars: vec![], + }); + } + } + + if let Some(state) = state + && !state.invalid_later_tvars.is_empty() + { + report_invalid_type_param_order( + &self.context, + class, + class_node, + state.typevar_with_default, + &state.invalid_later_tvars, + ); + } + } + + let scope = class.body_scope(self.db()).scope(self.db()); + if 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, + ); + } } } } diff --git a/ty.schema.json b/ty.schema.json index 87feeb2507..74c142ec4c 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -595,7 +595,7 @@ }, "invalid-generic-class": { "title": "detects invalid generic classes", - "description": "## What it does\nChecks for the creation of invalid generic classes\n\n## Why is this bad?\nThere are several requirements that you must follow when defining a generic class.\n\n## Examples\n```python\nfrom typing import Generic, TypeVar\n\nT = TypeVar(\"T\") # okay\n\n# error: class uses both PEP-695 syntax and legacy syntax\nclass C[U](Generic[T]): ...\n```\n\n## References\n- [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction)", + "description": "## What it does\nChecks for the creation of invalid generic classes\n\n## Why is this bad?\nThere are several requirements that you must follow when defining a generic class.\nMany of these result in `TypeError` being raised at runtime if they are violated.\n\n## Examples\n```python\nfrom typing_extensions import Generic, TypeVar\n\nT = TypeVar(\"T\")\nU = TypeVar(\"U\", default=int)\n\n# error: class uses both PEP-695 syntax and legacy syntax\nclass C[U](Generic[T]): ...\n\n# error: type parameter with default comes before type parameter without default\nclass D(Generic[U, T]): ...\n```\n\n## References\n- [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction)", "default": "error", "oneOf": [ { From ba47349c2e874677ddc996f7c817c7b978e07a2f Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Mon, 15 Dec 2025 11:04:28 +0530 Subject: [PATCH 5/8] [ty] Use `ParamSpec` without the attr for inferable check (#21934) ## Summary fixes: https://github.com/astral-sh/ty/issues/1820 ## Test Plan Add new mdtests. Ecosystem changes removes all false positives. --- .../mdtest/generics/pep695/paramspec.md | 56 +++++++++++++++++++ .../src/types/signatures.rs | 23 ++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index 354521288e..e6e6acd35c 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -670,3 +670,59 @@ reveal_type(with_parameters(int_int, 1)) # revealed: Overload[(x: int) -> str, # error: [invalid-argument-type] reveal_type(with_parameters(int_int, "a")) # revealed: Overload[(x: int) -> str, (x: str) -> str] ``` + +## ParamSpec attribute assignability + +When comparing signatures with `ParamSpec` attributes (`P.args` and `P.kwargs`), two different +inferable `ParamSpec` attributes with the same kind are assignable to each other. This enables +method overrides where both methods have their own `ParamSpec`. + +### Same attribute kind, both inferable + +```py +from typing import Callable + +class Parent: + def method[**P](self, callback: Callable[P, None]) -> Callable[P, None]: + return callback + +class Child1(Parent): + # This is a valid override: Q.args matches P.args, Q.kwargs matches P.kwargs + def method[**Q](self, callback: Callable[Q, None]) -> Callable[Q, None]: + return callback + +# Both signatures use ParamSpec, so they should be compatible +def outer[**P](f: Callable[P, int]) -> Callable[P, int]: + def inner[**Q](g: Callable[Q, int]) -> Callable[Q, int]: + return g + return inner(f) +``` + +We can explicitly mark it as an override using the `@override` decorator. + +```py +from typing import override + +class Child2(Parent): + @override + def method[**Q](self, callback: Callable[Q, None]) -> Callable[Q, None]: + return callback +``` + +### One `ParamSpec` not inferable + +Here, `P` is in a non-inferable position while `Q` is inferable. So, they are not considered +assignable. + +```py +from typing import Callable + +class Container[**P]: + def method(self, f: Callable[P, None]) -> Callable[P, None]: + return f + + def try_assign[**Q](self, f: Callable[Q, None]) -> Callable[Q, None]: + # error: [invalid-return-type] "Return type does not match returned value: expected `(**Q@try_assign) -> None`, found `(**P@Container) -> None`" + # error: [invalid-argument-type] "Argument to bound method `method` is incorrect: Expected `(**P@Container) -> None`, found `(**Q@try_assign) -> None`" + return self.method(f) +``` diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 45c3f81de2..76fe3a35d4 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -1072,6 +1072,29 @@ impl<'db> Signature<'db> { let mut check_types = |type1: Option>, type2: Option>| { let type1 = type1.unwrap_or(Type::unknown()); let type2 = type2.unwrap_or(Type::unknown()); + + match (type1, type2) { + // This is a special case where the _same_ components of two different `ParamSpec` + // type variables are assignable to each other when they're both in an inferable + // position. + // + // `ParamSpec` type variables can only occur in parameter lists so this special case + // is present here instead of in `Type::has_relation_to_impl`. + (Type::TypeVar(typevar1), Type::TypeVar(typevar2)) + if typevar1.paramspec_attr(db).is_some() + && typevar1.paramspec_attr(db) == typevar2.paramspec_attr(db) + && typevar1 + .without_paramspec_attr(db) + .is_inferable(db, inferable) + && typevar2 + .without_paramspec_attr(db) + .is_inferable(db, inferable) => + { + return true; + } + _ => {} + } + !result .intersect( db, From 9838f81bafd521019ac715437cafa13a84e1eb19 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:52:52 +0000 Subject: [PATCH 6/8] Update actions/checkout digest to 8e8c483 (#21982) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Micha Reiser --- .github/workflows/release.yml | 8 ++++---- dist-workspace.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cec5a828f..abee17bfc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 with: persist-credentials: false submodules: recursive @@ -123,7 +123,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 with: persist-credentials: false submodules: recursive @@ -174,7 +174,7 @@ jobs: outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 with: persist-credentials: false submodules: recursive @@ -250,7 +250,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 with: persist-credentials: false submodules: recursive diff --git a/dist-workspace.toml b/dist-workspace.toml index 809beefa73..10bec6d868 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -66,7 +66,7 @@ install-path = ["$XDG_BIN_HOME/", "$XDG_DATA_HOME/../bin", "~/.local/bin"] global = "depot-ubuntu-latest-4" [dist.github-action-commits] -"actions/checkout" = "1af3b93b6815bc44a9784bd300feb67ff0d1eeb3" # v6.0.0 +"actions/checkout" = "8e8c483db84b4bee98b60c0593521ed34d9990e8" # v6.0.1 "actions/upload-artifact" = "330a01c490aca151604b8cf639adc76d48f6c5d4" # v5.0.0 "actions/download-artifact" = "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53" # v6.0.0 "actions/attest-build-provenance" = "c074443f1aee8d4aeeae555aebba3282517141b2" #v2.2.3 From 0b918ae4d53e68ce4b1ab34be87054597f845d0c Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 15 Dec 2025 08:56:35 +0000 Subject: [PATCH 7/8] [ty] Improve check enforcing that an overloaded function must have an implementation (#21978) ## Summary - Treat `if TYPE_CHECKING` blocks the same as stub files (the feature requested in https://github.com/astral-sh/ty/issues/1216) - We currently only allow `@abstractmethod`-decorated methods to omit the implementation if they're methods in classes that have _exactly_ `ABCMeta` as their metaclass. That seems wrong -- `@abstractmethod` has the same semantics if a class has a subclass of `ABCMeta` as its metaclass. This PR fixes that too. (I'm actually not _totally_ sure we should care what the class's metaclass is at all -- see discussion in https://github.com/astral-sh/ty/issues/1877#issue-3725937441... but the change this PR is making seems less wrong than what we have currently, anyway.) Fixes https://github.com/astral-sh/ty/issues/1216 ## Test Plan Mdtests and snapshots --- .../resources/mdtest/overloads.md | 58 +++++++++++++++++++ ...€¦_-_Regular_modules_(5c8e81664d1c7470).snap | 12 +++- crates/ty_python_semantic/src/types/class.rs | 7 --- .../src/types/infer/builder.rs | 23 ++++++-- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/overloads.md b/crates/ty_python_semantic/resources/mdtest/overloads.md index f74cafb80b..f2fd9b7595 100644 --- a/crates/ty_python_semantic/resources/mdtest/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/overloads.md @@ -418,6 +418,18 @@ Using the `@abstractmethod` decorator requires that the class's metaclass is `AB from it. ```py +from abc import ABCMeta + +class CustomAbstractMetaclass(ABCMeta): ... + +class Fine(metaclass=CustomAbstractMetaclass): + @overload + @abstractmethod + def f(self, x: int) -> int: ... + @overload + @abstractmethod + def f(self, x: str) -> str: ... + class Foo: @overload @abstractmethod @@ -448,6 +460,52 @@ class PartialFoo(ABC): def f(self, x: str) -> str: ... ``` +#### `TYPE_CHECKING` blocks + +As in other areas of ty, we treat `TYPE_CHECKING` blocks the same as "inline stub files", so we +permit overloaded functions to exist without an implementation if all overloads are defined inside +an `if TYPE_CHECKING` block: + +```py +from typing import overload, TYPE_CHECKING + +if TYPE_CHECKING: + @overload + def a() -> str: ... + @overload + def a(x: int) -> int: ... + + class F: + @overload + def method(self) -> None: ... + @overload + def method(self, x: int) -> int: ... + +class G: + if TYPE_CHECKING: + @overload + def method(self) -> None: ... + @overload + def method(self, x: int) -> int: ... + +if TYPE_CHECKING: + @overload + def b() -> str: ... + +if TYPE_CHECKING: + @overload + def b(x: int) -> int: ... + +if TYPE_CHECKING: + @overload + def c() -> None: ... + +# not all overloads are in a `TYPE_CHECKING` block, so this is an error +@overload +# error: [invalid-overload] +def c(x: int) -> int: ... +``` + ### `@overload`-decorated functions with non-stub bodies diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Overload_without_an_…_-_Regular_modules_(5c8e81664d1c7470).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Overload_without_an_…_-_Regular_modules_(5c8e81664d1c7470).snap index 21cc311cd8..257cc54b78 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Overload_without_an_…_-_Regular_modules_(5c8e81664d1c7470).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/overloads.md_-_Overloads_-_Invalid_-_Overload_without_an_…_-_Regular_modules_(5c8e81664d1c7470).snap @@ -42,7 +42,11 @@ error[invalid-overload]: Overloads for function `func` must be followed by a non 9 | class Foo: | info: Attempting to call `func` will raise `TypeError` at runtime -info: Overloaded functions without implementations are only permitted in stub files, on protocols, or for abstract methods +info: Overloaded functions without implementations are only permitted: +info: - in stub files +info: - in `if TYPE_CHECKING` blocks +info: - as methods on protocol classes +info: - or as `@abstractmethod`-decorated methods on abstract classes info: See https://docs.python.org/3/library/typing.html#typing.overload for more details info: rule `invalid-overload` is enabled by default @@ -58,7 +62,11 @@ error[invalid-overload]: Overloads for function `method` must be followed by a n | ^^^^^^ | info: Attempting to call `method` will raise `TypeError` at runtime -info: Overloaded functions without implementations are only permitted in stub files, on protocols, or for abstract methods +info: Overloaded functions without implementations are only permitted: +info: - in stub files +info: - in `if TYPE_CHECKING` blocks +info: - as methods on protocol classes +info: - or as `@abstractmethod`-decorated methods on abstract classes info: See https://docs.python.org/3/library/typing.html#typing.overload for more details info: rule `invalid-overload` is enabled by default diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 022e63fe33..61ee82e030 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1815,13 +1815,6 @@ 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) - .as_class_literal() - .is_some_and(|metaclass| metaclass.is_known(db, KnownClass::ABCMeta)) - } - /// Return the types of the decorators on this class #[salsa::tracked(returns(deref), cycle_initial=decorators_cycle_initial, heap_size=ruff_memory_usage::heap_size)] fn decorators(self, db: &'db dyn Db) -> Box<[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 723e89519c..d691b46cf9 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1143,7 +1143,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if implementation.is_none() && !self.in_stub() { let mut implementation_required = true; - if let NodeWithScopeKind::Class(class_node_ref) = scope { + if function + .iter_overloads_and_implementation(self.db()) + .all(|f| { + f.body_scope(self.db()) + .scope(self.db()) + .in_type_checking_block() + }) + { + implementation_required = false; + } else if let NodeWithScopeKind::Class(class_node_ref) = scope { let class = binding_type( self.db(), self.index @@ -1152,7 +1161,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .expect_class_literal(); if class.is_protocol(self.db()) - || (class.is_abstract(self.db()) + || (Type::ClassLiteral(class) + .is_subtype_of(self.db(), KnownClass::ABCMeta.to_instance(self.db())) && overloads.iter().all(|overload| { overload.has_known_decorator( self.db(), @@ -1179,8 +1189,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &function_node.name )); diagnostic.info( - "Overloaded functions without implementations are only permitted \ - in stub files, on protocols, or for abstract methods", + "Overloaded functions without implementations are only permitted:", + ); + diagnostic.info(" - in stub files"); + diagnostic.info(" - in `if TYPE_CHECKING` blocks"); + diagnostic.info(" - as methods on protocol classes"); + diagnostic.info( + " - or as `@abstractmethod`-decorated methods on abstract classes", ); diagnostic.info( "See https://docs.python.org/3/library/typing.html#typing.overload \ From d08e41417971f1d05b9daa75f794536a1dd4bedf Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 15 Dec 2025 14:29:11 +0100 Subject: [PATCH 8/8] Update MSRV to 1.90 (#21987) --- Cargo.toml | 2 +- crates/ruff/src/args.rs | 2 +- crates/ruff/src/lib.rs | 2 +- crates/ruff_formatter/src/macros.rs | 4 ++-- crates/ruff_linter/src/fix/edits.rs | 7 +------ .../src/rules/flake8_simplify/rules/yoda_conditions.rs | 2 +- crates/ruff_python_codegen/src/generator.rs | 1 + crates/ruff_python_formatter/src/cli.rs | 2 +- crates/ty_ide/src/completion.rs | 3 +-- crates/ty_project/src/lib.rs | 1 - crates/ty_python_semantic/src/types.rs | 1 - rust-toolchain.toml | 2 +- 12 files changed, 11 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dbd9808fdd..bd06571cd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] # Please update rustfmt.toml when bumping the Rust edition edition = "2024" -rust-version = "1.89" +rust-version = "1.90" homepage = "https://docs.astral.sh/ruff" documentation = "https://docs.astral.sh/ruff" repository = "https://github.com/astral-sh/ruff" diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index 370186a0b4..da6d3833b7 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -10,7 +10,7 @@ use anyhow::bail; use clap::builder::Styles; use clap::builder::styling::{AnsiColor, Effects}; use clap::builder::{TypedValueParser, ValueParserFactory}; -use clap::{Parser, Subcommand, command}; +use clap::{Parser, Subcommand}; use colored::Colorize; use itertools::Itertools; use path_absolutize::path_dedot; diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index 3ea0d94fad..3ecefd6dde 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -9,7 +9,7 @@ use std::sync::mpsc::channel; use anyhow::Result; use clap::CommandFactory; use colored::Colorize; -use log::{error, warn}; +use log::error; use notify::{RecursiveMode, Watcher, recommended_watcher}; use args::{GlobalConfigArgs, ServerCommand}; diff --git a/crates/ruff_formatter/src/macros.rs b/crates/ruff_formatter/src/macros.rs index 4d0d3ef234..8090d41397 100644 --- a/crates/ruff_formatter/src/macros.rs +++ b/crates/ruff_formatter/src/macros.rs @@ -337,7 +337,7 @@ macro_rules! best_fitting { #[cfg(test)] mod tests { use crate::prelude::*; - use crate::{FormatState, SimpleFormatOptions, VecBuffer, write}; + use crate::{FormatState, SimpleFormatOptions, VecBuffer}; struct TestFormat; @@ -385,8 +385,8 @@ mod tests { #[test] fn best_fitting_variants_print_as_lists() { + use crate::Formatted; use crate::prelude::*; - use crate::{Formatted, format, format_args}; // The second variant below should be selected when printing at a width of 30 let formatted_best_fitting = format!( diff --git a/crates/ruff_linter/src/fix/edits.rs b/crates/ruff_linter/src/fix/edits.rs index 20f50d6e10..b5420e4ee1 100644 --- a/crates/ruff_linter/src/fix/edits.rs +++ b/crates/ruff_linter/src/fix/edits.rs @@ -286,12 +286,7 @@ pub(crate) fn add_argument(argument: &str, arguments: &Arguments, tokens: &Token /// Generic function to add a (regular) parameter to a function definition. pub(crate) fn add_parameter(parameter: &str, parameters: &Parameters, source: &str) -> Edit { - if let Some(last) = parameters - .args - .iter() - .filter(|arg| arg.default.is_none()) - .next_back() - { + if let Some(last) = parameters.args.iter().rfind(|arg| arg.default.is_none()) { // Case 1: at least one regular parameter, so append after the last one. Edit::insertion(format!(", {parameter}"), last.end()) } else if !parameters.args.is_empty() { diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs index 687729c20c..84aff5ce5e 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs @@ -146,7 +146,7 @@ fn reverse_comparison(expr: &Expr, locator: &Locator, stylist: &Stylist) -> Resu let left = (*comparison.left).clone(); // Copy the right side to the left side. - comparison.left = Box::new(comparison.comparisons[0].comparator.clone()); + *comparison.left = comparison.comparisons[0].comparator.clone(); // Copy the left side to the right side. comparison.comparisons[0].comparator = left; diff --git a/crates/ruff_python_codegen/src/generator.rs b/crates/ruff_python_codegen/src/generator.rs index 710e295f62..362d00d235 100644 --- a/crates/ruff_python_codegen/src/generator.rs +++ b/crates/ruff_python_codegen/src/generator.rs @@ -1247,6 +1247,7 @@ impl<'a> Generator<'a> { self.p_bytes_repr(&bytes_literal.value, bytes_literal.flags); } } + #[expect(clippy::eq_op)] Expr::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => { static INF_STR: &str = "1e309"; assert_eq!(f64::MAX_10_EXP, 308); diff --git a/crates/ruff_python_formatter/src/cli.rs b/crates/ruff_python_formatter/src/cli.rs index 96da64e86e..df289f08d8 100644 --- a/crates/ruff_python_formatter/src/cli.rs +++ b/crates/ruff_python_formatter/src/cli.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use clap::{Parser, ValueEnum, command}; +use clap::{Parser, ValueEnum}; use ruff_formatter::SourceCode; use ruff_python_ast::{PySourceType, PythonVersion}; diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index ca2305df0c..4e5051ddcc 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -4711,8 +4711,7 @@ from os. let last_nonunderscore = test .completions() .iter() - .filter(|c| !c.name.starts_with('_')) - .next_back() + .rfind(|c| !c.name.starts_with('_')) .unwrap(); assert_eq!(&last_nonunderscore.name, "type_check_only"); diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index fe034b0a07..2bcb287b40 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -27,7 +27,6 @@ use std::iter::FusedIterator; use std::panic::{AssertUnwindSafe, UnwindSafe}; use std::sync::Arc; use thiserror::Error; -use tracing::error; use ty_python_semantic::add_inferred_python_version_hint_to_diagnostic; use ty_python_semantic::lint::RuleSelection; use ty_python_semantic::types::check_types; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 726decfc07..b318638742 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -10691,7 +10691,6 @@ pub struct UnionTypeInstance<'db> { /// ``. For `Union[int, str]`, this field is `None`, as we infer /// the elements as type expressions. Use `value_expression_types` to get the /// corresponding value expression types. - #[expect(clippy::ref_option)] #[returns(ref)] _value_expr_types: Option]>>, diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 1a35d66439..50b3f5d474 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.91" +channel = "1.92"