From e8ac6b3a649a2adb0d9653fb8f89ac692c308080 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:37:10 +1000 Subject: [PATCH 1/8] type an extension method's self as Self bounded by the extended type, so returning self keeps the receiver's type --- crates/ty_ide/src/semantic_tokens.rs | 2 +- .../mdtest/basedpython_extensions.md | 51 ++++++++++++++++++- .../src/types/extensions.rs | 18 ++++--- .../ty_python_semantic/src/types/function.rs | 32 ++++++------ .../src/types/infer/builder/function.rs | 13 ++--- 5 files changed, 81 insertions(+), 35 deletions(-) diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index 7277712f5b..3fe31e917d 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -6781,7 +6781,7 @@ extension str(Show): "show" @ 81..85: Method [definition] "self" @ 86..90: SelfParameter [definition] "str" @ 95..98: Class - "self" @ 115..119: Parameter + "self" @ 115..119: SelfParameter "#); } diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_extensions.md b/crates/ty_python_semantic/resources/mdtest/basedpython_extensions.md index cc178f4183..68873cc082 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_extensions.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_extensions.md @@ -18,17 +18,55 @@ names = ["a", "b"] reveal_type(names.second()) # revealed: str ``` -## `self` in the body is the extended type +## `self` in the body is `Self`, bounded by the extended type + +`self` is typed the way it is in a method the class declares itself: as `Self`, whose bound is the +extended type. everything the extended type has is available on it ```by extension list: def first_or(self, default: Element) -> Element: - reveal_type(self) # revealed: list[Element@list] + reveal_type(self) # revealed: Self@first_or if len(self) > 0: return self[0] return default ``` +so a method that hands back `self` hands back the receiver's own type, including a subclass and the +receiver's type arguments + +```by +class Shape: ... +class Square(Shape): ... + +extension Shape: + def itself(self): + return self + +reveal_type(Shape().itself()) # revealed: Shape +reveal_type(Square().itself()) # revealed: Square + +extension list: + def same(self): + return self + +reveal_type([1].same()) # revealed: list[int] +``` + +a conditional extension's `Self` is bounded by the extended type at its bracket bound, and a +subclass of the extended type is still the receiver it hands back + +```by +class Flags[T](list[T]): ... + +extension list[Element: int]: + def same_ints(self): + return self + +reveal_type(Flags[bool]().same_ints()) # revealed: Flags[bool] +reveal_type([True].same_ints()) # revealed: list[bool] +``` + ## a method may declare its own fresh type parameters ```by @@ -232,6 +270,15 @@ extension missing: # error: [invalid-extension] return 0 ``` +such an extension extends nothing, so its `self` is gradual rather than a source of further errors + +```by +extension absent: # error: [invalid-extension] + def m(self): + reveal_type(self) # revealed: Unknown + return self.anything +``` + bracket parameters must reuse names the extended type declares ```by diff --git a/crates/ty_python_semantic/src/types/extensions.rs b/crates/ty_python_semantic/src/types/extensions.rs index 381f2037f1..23093d9ccd 100644 --- a/crates/ty_python_semantic/src/types/extensions.rs +++ b/crates/ty_python_semantic/src/types/extensions.rs @@ -822,21 +822,27 @@ impl<'db> ExtensionApplication<'db> { /// substitute this application's type arguments into a member's type: first /// the extension's own bracket typevars (matched to the extended type's /// parameters by name), then the extended class's typevars the member's - /// signature reuses directly + /// signature reuses directly. both are a member projection like a declared + /// member's, so they also rewrite the bound of the method's `Self` fn apply( &self, db: &'db dyn Db, + env: &ProgramEnvironment<'db>, extension: StaticClassLiteral<'db>, member: Type<'db>, ) -> Type<'db> { let mut member = member; if let Some(context) = extension.generic_context(db) { - member = member.apply_specialization( + member = member.apply_optional_owner_specialization_to_member( db, - context.specialize(db, self.bracket_substitution.clone()), + Some(context.specialize(db, self.bracket_substitution.clone())), ); } - member.apply_optional_specialization(db, self.receiver_specialization) + member.apply_projected_optional_owner_specialization_to_member( + db, + env, + self.receiver_specialization, + ) } } @@ -959,7 +965,7 @@ fn applicable_member<'db>( if let Some(application) = extension_applies(db, env, extension, receiver_class) { let member = own_member(db, extension, name)?; return Some(ApplicableMember { - member: application.apply(db, extension, member), + member: application.apply(db, env, extension, member), conformed_as: None, }); } @@ -968,7 +974,7 @@ fn applicable_member<'db>( let application = applied_at(db, env, extension, target, conformed)?; let member = own_member(db, extension, name)?; Some(ApplicableMember { - member: application.apply(db, extension, member), + member: application.apply(db, env, extension, member), conformed_as: Some(conformed), }) } diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 4391a16605..a41f434bc0 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1292,21 +1292,17 @@ impl<'db> OverloadLiteral<'db> { let class_def = index.expect_single_definition(class_node); let class_literal = original_class_type(db, class_def)?; - // basedpython: an extension method's `self` is the *extended* type, - // specialized at the extension's view of its type parameters. the - // receiver's own type arguments are substituted in when the member - // is resolved on a receiver, exactly like ordinary generic members - if let ClassLiteral::Static(static_literal) = class_literal - && static_literal.is_extension(db) - { - let body_view = crate::types::extensions::body_view_class(db, static_literal)?; - return Some(if self.takes_implicit_class_receiver(db) { - SubclassOfType::from(db, env, SubclassOfInner::Class(body_view)) - } else { - Type::instance(db, env, body_view) - }); - } - + // basedpython: an extension method's `self` is a `Self` bounded by the + // *extended* type (see `typing_self`), so it always needs the typevar. an + // extension of something that is not a class extends nothing, and `invalid-extension` + // has already said so, so its `self` is left gradual + let class_is_extension = match class_literal { + ClassLiteral::Static(static_literal) if static_literal.is_extension(db) => { + crate::types::extensions::body_view_class(db, static_literal)?; + true + } + _ => false, + }; let class_is_generic = class_literal.generic_context(db).is_some(); let class_is_fallback = class_literal .known(db) @@ -1328,7 +1324,11 @@ impl<'db> OverloadLiteral<'db> { // - The class cannot be a "fallback class". A fallback class is used like a mixin, // and so we need specialization inference to determine the "real" class that the // fallback is augmenting. (See KnownClass::is_fallback_class for more details.) - if method_has_explicit_self || class_is_generic || class_is_fallback { + if method_has_explicit_self + || class_is_generic + || class_is_fallback + || class_is_extension + { let scope_id = definition.scope(db); let typevar_binding_context = Some(definition); let index = semantic_index(db, scope_id.program_file(db)); diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index 64e9c46a1e..dde10d8215 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -2454,19 +2454,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let class_definition = self.index.expect_single_definition(class); let class_literal = original_class_type(db, class_definition)?; - // basedpython: an extension method's `self` is the *extended* type, - // specialized at the extension's view of its type parameters — not a - // `Self` typevar of the synthetic extension class (whose own body holds - // only the extension members) + // basedpython: an extension of something that is not a class extends nothing, and + // `invalid-extension` has already said so, so its `self` is left gradual if let ClassLiteral::Static(static_literal) = class_literal && static_literal.is_extension(db) { - let body_view = extensions::body_view_class(db, static_literal)?; - return Some(if is_classmethod || function_name == "__new__" { - SubclassOfType::from(db, env, SubclassOfInner::Class(body_view)) - } else { - Type::instance(db, env, body_view) - }); + extensions::body_view_class(db, static_literal)?; } let typing_self = typing_self(db, self.scope(), Some(method_definition), class_literal); From 8dc9f321a3a7fb4862c166c6c88b18647e901bc4 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:37:12 +1000 Subject: [PATCH 2/8] compare each element of a union or intersection against a symbolic return type, so `a + 1 or a + 2` satisfies `-> a + 1 | a + 2` --- .../mdtest/basedpython_deferred_type_ops.md | 23 +++++++++++++++++++ .../ty_python_semantic/src/types/relation.rs | 14 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_deferred_type_ops.md b/crates/ty_python_semantic/resources/mdtest/basedpython_deferred_type_ops.md index 8f576d11df..37b7771998 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_deferred_type_ops.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_deferred_type_ops.md @@ -302,6 +302,29 @@ def not_that_value[I: int](i: I) -> (I + 1) | None: return i ``` +## a narrowed value still names the same value + +`i + 1 or i + 2` narrows its left arm to `(I + 1) & ~AlwaysFalsy`: the same value, known to be +truthy. each element of an intersection or a union is compared on its own, so the narrowing does not +hide which value the left arm names + +```by +def either[I: int](i: I) -> (I + 1) | (I + 2): + return i + 1 or i + 2 + +def truthy[I: int](i: I) -> I + 1: + if result := i + 1: + return result + return i + 1 + +def some_parameter(a: some int) -> a + 1 | a + 2: + return a + 1 or a + 2 + +def still_not_that_value[I: int](i: I) -> I + 1: + # error: [invalid-return-type] + return i or i + 1 +``` + ## a gradual value still satisfies a symbolic return type `Unknown` stands for anything, including the value the annotation names — rejecting it would make an diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 597382aecd..5179c54e5d 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1857,6 +1857,20 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // this has to precede the source arm: a source that is itself a deferral is // exactly the interesting case, and reducing it first would compare `int` // against `int` and always agree + // + // a union or intersection source is taken apart first, so each element is + // compared on its own: `(I + 1) & ~AlwaysFalsy` (what `i + 1 or ...` narrows + // to) names the same value as `I + 1`, but flattened whole it is one opaque atom + (Type::Union(union), Type::Deferred(target_deferred)) + if target_deferred.is_checked(db) => + { + self.check_source_union(db, union, target) + } + (Type::Intersection(intersection), Type::Deferred(target_deferred)) + if target_deferred.is_checked(db) => + { + self.check_source_intersection(db, intersection, target) + } (_, Type::Deferred(target_deferred)) if target_deferred.is_checked(db) => { let names_another_value = !any_over_type(db, env, source, false, |ty| ty.is_dynamic()) From 7e8582b6ac79e673bef6bc330fe514abf3f3513b Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:54:22 +1000 Subject: [PATCH 3/8] add read-only `let name: T` members to inline protocols, lowered to a getter-only property --- .../src/transforms/protocol_type.rs | 35 ++++- crates/ruff_linter/src/checkers/ast/mod.rs | 1 + crates/ruff_python_ast/ast.toml | 15 +- crates/ruff_python_ast/src/comparable.rs | 3 + crates/ruff_python_ast/src/generated.rs | 12 +- crates/ruff_python_ast/src/helpers.rs | 1 + crates/ruff_python_ast/src/visitor.rs | 1 + .../src/visitor/transformer.rs | 1 + crates/ruff_python_codegen/src/generator.rs | 5 + .../test/fixtures/ruff/inline_protocol.by | 3 + .../src/expression/expr_named.rs | 2 + .../snapshots/format@inline_protocol.by.snap | 6 + .../src/parser/expression.rs | 45 +++++- ...parser__tests__anon_named_tuple_alias.snap | 2 + ...__anon_named_tuple_function_signature.snap | 4 + ...tests__anon_named_tuple_in_annotation.snap | 2 + ...r__tests__anon_named_tuple_mixed_type.snap | 1 + ...__tests__anon_named_tuple_mixed_value.snap | 1 + ..._tests__anon_named_tuple_single_field.snap | 1 + ...ests__anon_named_tuple_trailing_comma.snap | 2 + ..._anon_named_tuple_value_complex_value.snap | 2 + ...__anon_named_tuple_value_construction.snap | 2 + ...anon_named_tuple_value_trailing_comma.snap | 2 + ...er__tests__inline_protocol_let_member.snap | 128 ++++++++++++++++++ ...inline_protocol_method_parameter_spec.snap | 3 + ...protocol_multiline_trailing_semicolon.snap | 1 + ...__parser__tests__inline_protocol_type.snap | 2 + ..._protocol_call_is_not_inline_protocol.snap | 1 + crates/ruff_python_parser/src/parser/tests.rs | 9 ++ ...ax@decorator_named_expression_py37.py.snap | 1 + ...ressions__dict__named_expression_0.py.snap | 1 + ...__list__star_expression_precedence.py.snap | 1 + ...expressions__named__invalid_target.py.snap | 4 + ...sions__named__missing_expression_1.py.snap | 1 + ...sions__named__missing_expression_2.py.snap | 1 + ...sions__named__missing_expression_3.py.snap | 1 + ...sions__named__missing_expression_4.py.snap | 1 + ...ressions__parenthesized__generator.py.snap | 1 + ..._parenthesized__tuple_starred_expr.py.snap | 2 + ...s__set__star_expression_precedence.py.snap | 1 + ...__subscript__invalid_slice_element.py.snap | 2 + ...id_syntax@invalid_annotation_class.py.snap | 2 + ...syntax@invalid_annotation_function.py.snap | 6 + ...@invalid_annotation_function_py314.py.snap | 2 + ...id_syntax@invalid_annotation_py314.py.snap | 1 + ...ntax@invalid_annotation_type_alias.py.snap | 1 + .../invalid_syntax@named_expr_slice.py.snap | 1 + ...yntax@named_expr_slice_parse_error.py.snap | 1 + .../invalid_syntax@re_lexing__ty_1828.py.snap | 1 + ...tax@rebound_comprehension_variable.py.snap | 10 ++ ...ements__invalid_assignment_targets.py.snap | 1 + ...nvalid_augmented_assignment_target.py.snap | 1 + ...s__with__ambiguous_lpar_with_items.py.snap | 3 + ...arenthesized_named_expr_index_py38.py.snap | 1 + ...nthesized_named_expr_set_comp_py38.py.snap | 1 + ...esized_named_expr_set_literal_py38.py.snap | 3 + .../invalid_syntax@walrus_py37.py.snap | 1 + ...yntax@while_stmt_invalid_test_expr.py.snap | 1 + ..._items_parenthesized_missing_comma.py.snap | 1 + ...d_syntax@decorator_expression_py39.py.snap | 1 + ...alid_syntax@expressions__arguments.py.snap | 3 + ...lid_syntax@expressions__dictionary.py.snap | 3 + ...alid_syntax@expressions__generator.py.snap | 1 + .../valid_syntax@expressions__list.py.snap | 4 + .../valid_syntax@expressions__named.py.snap | 8 ++ ..._syntax@expressions__parenthesized.py.snap | 1 + .../valid_syntax@expressions__set.py.snap | 3 + .../valid_syntax@expressions__slice.py.snap | 3 + ...alid_syntax@expressions__subscript.py.snap | 4 + .../valid_syntax@expressions__tuple.py.snap | 4 + ...valid_syntax@expressions__unary_op.py.snap | 1 + .../valid_syntax@expressions__yield.py.snap | 1 + ...lid_syntax@expressions__yield_from.py.snap | 1 + ...lid_syntax@match_stmt_subject_expr.py.snap | 2 + ...syntax@match_stmt_valid_guard_expr.py.snap | 1 + ...non_rebound_comprehension_variable.py.snap | 1 + .../valid_syntax@other__decorator.py.snap | 1 + ...arenthesized_named_expr_index_py38.py.snap | 1 + ...ntax@parenthesized_named_expr_py38.py.snap | 2 + ...atement__ambiguous_lpar_with_items.py.snap | 15 ++ .../valid_syntax@statement__if.py.snap | 2 + .../valid_syntax@statement__match.py.snap | 5 + .../valid_syntax@statement__return.py.snap | 1 + .../valid_syntax@statement__type.py.snap | 1 + .../valid_syntax@statement__while.py.snap | 2 + ...arenthesized_named_expr_index_py39.py.snap | 1 + ...ax@unparenthesized_named_expr_py39.py.snap | 2 + ...alid_syntax@valid_annotation_class.py.snap | 1 + ...ax@valid_annotation_function_py313.py.snap | 2 + ...alid_syntax@valid_annotation_py313.py.snap | 1 + .../valid_syntax@walrus_py38.py.snap | 1 + crates/ty_ide/src/semantic_tokens.rs | 30 ++++ crates/ty_python_core/src/re_exports.rs | 1 + .../mdtest/basedpython_inferred_narrowing.md | 8 +- .../mdtest/basedpython_inline_protocol.md | 49 ++++++- .../mdtest/basedpython_sound_types.md | 2 +- .../ty_python_semantic/src/types/display.rs | 5 + .../src/types/infer/builder.rs | 11 +- .../src/types/protocol_class.rs | 9 +- docs/basedpython/features/inline-protocol.md | 16 ++- 100 files changed, 530 insertions(+), 29 deletions(-) create mode 100644 crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_let_member.snap diff --git a/crates/by_transforms/src/transforms/protocol_type.rs b/crates/by_transforms/src/transforms/protocol_type.rs index 3fd6f53ddc..4c2e0c3d29 100644 --- a/crates/by_transforms/src/transforms/protocol_type.rs +++ b/crates/by_transforms/src/transforms/protocol_type.rs @@ -205,12 +205,18 @@ impl<'src> ProtocolTypeLowering<'src> { let Some(name) = named.target.as_name_expr() else { continue; }; - let ty = self.render_type(&named.value); - members.push(format!( - "{name}: {ty}", - name = name.id, - ty = quote_forward_ref(&ty) - )); + let ty = quote_forward_ref(&self.render_type(&named.value)); + // a member that only has to be readable is a getter-only property, + // which is how a protocol class body says read-only + members.push(if named.is_let { + let property = self.written.builtin("property"); + format!( + "@{property}\n def {name}(self) -> {ty}: ...", + name = name.id + ) + } else { + format!("{name}: {ty}", name = name.id) + }); } Expr::ProtocolMethod(method) => match self.render_method(method) { Some(rendered) => members.push(rendered), @@ -546,6 +552,23 @@ mod tests { ); } + #[test] + fn a_let_member_is_a_getter_only_property() { + check( + indoc! {" + def f(x: protocol(let a: int; b: str)) -> None: ... + "}, + indoc! {r#" + from typing import Protocol + class _Protocol_4e1bfe56(Protocol): + @property + def a(self) -> "int": ... + b: "str" + def f(x: _Protocol_4e1bfe56) -> None: ... + "#}, + ); + } + /// the synthesized class is written into the preamble and names `Protocol` as it is /// defined, so that import stays above it even when the module imports `Protocol` itself — /// folding it into the module's own line would leave the class with no such name diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index eca5b18b1e..724deb6e37 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -2448,6 +2448,7 @@ impl<'a> Visitor<'a> for Checker<'a> { Expr::Named(ast::ExprNamed { target, value, + is_let: _, range: _, node_index: _, }) => { diff --git a/crates/ruff_python_ast/ast.toml b/crates/ruff_python_ast/ast.toml index 2f385f33f1..4af7709c59 100644 --- a/crates/ruff_python_ast/ast.toml +++ b/crates/ruff_python_ast/ast.toml @@ -389,7 +389,13 @@ custom_source_order = true [Expr.nodes.ExprNamed] doc = "See also [NamedExpr](https://docs.python.org/3/library/ast.html#ast.NamedExpr)" -fields = [{ name = "target", type = "Expr" }, { name = "value", type = "Expr" }] +fields = [ + { name = "target", type = "Expr" }, + { name = "value", type = "Expr" }, + { name = "is_let", type = "bool", doc = """basedpython: when true, this is a data member of an inline +protocol type written `let name: T`, which only has to be readable. a member written `name: T` is a +mutable attribute, as in a protocol class body. always false outside an inline protocol""" }, +] [Expr.nodes.ExprBinOp] doc = "See also [BinOp](https://docs.python.org/3/library/ast.html#ast.BinOp)" @@ -702,9 +708,10 @@ doc = """basedpython inline protocol type expression: `protocol(a: int; b: str; def f(self) -> int; **Kwargs)`. Each member of `members` is one of: -- `Expr::Named` — a data member `a: int`, where `target` is the member name - (an `Expr::Name` with `ExprContext::Invalid`, since the name is a label and - neither a binding nor a reference) and `value` is its type expression, +- `Expr::Named` — a data member `a: int`, or a read-only one `let a: int` + (see [`ExprNamed::is_let`](crate::ExprNamed::is_let)), where `target` is the + member name (an `Expr::Name` with `ExprContext::Invalid`, since the name is a + label and neither a binding nor a reference) and `value` is its type expression, - `Expr::ProtocolMethod` — a method member `def f(self) -> int`, - `Expr::Starred(Expr::Starred(..))` — a `**Kwargs` keyword-pack unpack, matching the `**` encoding used by dict-literal types and callable arrows.""" diff --git a/crates/ruff_python_ast/src/comparable.rs b/crates/ruff_python_ast/src/comparable.rs index 765c7cb2aa..d588c9e54f 100644 --- a/crates/ruff_python_ast/src/comparable.rs +++ b/crates/ruff_python_ast/src/comparable.rs @@ -872,6 +872,7 @@ pub struct ExprBoolOp<'a> { pub struct ExprNamed<'a> { target: Box>, value: Box>, + is_let: bool, } #[derive(Debug, PartialEq, Eq, Hash)] @@ -1159,11 +1160,13 @@ impl<'a> From<&'a ast::Expr> for ComparableExpr<'a> { ast::Expr::Named(ast::ExprNamed { target, value, + is_let, range: _, node_index: _, }) => Self::NamedExpr(ExprNamed { target: target.into(), value: value.into(), + is_let: *is_let, }), ast::Expr::BinOp(ast::ExprBinOp { left, diff --git a/crates/ruff_python_ast/src/generated.rs b/crates/ruff_python_ast/src/generated.rs index 086f1078ba..c0cbcd6cfd 100644 --- a/crates/ruff_python_ast/src/generated.rs +++ b/crates/ruff_python_ast/src/generated.rs @@ -10295,6 +10295,10 @@ pub struct ExprNamed { pub range: ruff_text_size::TextRange, pub target: Box, pub value: Box, + /// basedpython: when true, this is a data member of an inline + /// protocol type written `let name: T`, which only has to be readable. a member written `name: T` is a + /// mutable attribute, as in a protocol class body. always false outside an inline protocol + pub is_let: bool, } /// See also [BinOp](https://docs.python.org/3/library/ast.html#ast.BinOp) @@ -10721,9 +10725,10 @@ pub struct ExprCallableType { /// `protocol(a: int; b: str; def f(self) -> int; **Kwargs)`. /// /// Each member of `members` is one of: -/// - `Expr::Named` — a data member `a: int`, where `target` is the member name -/// (an `Expr::Name` with `ExprContext::Invalid`, since the name is a label and -/// neither a binding nor a reference) and `value` is its type expression, +/// - `Expr::Named` — a data member `a: int`, or a read-only one `let a: int` +/// (see [`ExprNamed::is_let`](crate::ExprNamed::is_let)), where `target` is the +/// member name (an `Expr::Name` with `ExprContext::Invalid`, since the name is a +/// label and neither a binding nor a reference) and `value` is its type expression, /// - `Expr::ProtocolMethod` — a method member `def f(self) -> int`, /// - `Expr::Starred(Expr::Starred(..))` — a `**Kwargs` keyword-pack unpack, /// matching the `**` encoding used by dict-literal types and callable arrows. @@ -11523,6 +11528,7 @@ impl ExprNamed { let ExprNamed { target, value, + is_let: _, range: _, node_index: _, } = self; diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs index 9c8c6bff97..5f781d900e 100644 --- a/crates/ruff_python_ast/src/helpers.rs +++ b/crates/ruff_python_ast/src/helpers.rs @@ -282,6 +282,7 @@ where Expr::Named(ast::ExprNamed { target, value, + is_let: _, range: _, node_index: _, }) => any_over_expr(target, &mut *func) || any_over_expr(value, &mut *func), diff --git a/crates/ruff_python_ast/src/visitor.rs b/crates/ruff_python_ast/src/visitor.rs index b22b57ca74..f3908d17a5 100644 --- a/crates/ruff_python_ast/src/visitor.rs +++ b/crates/ruff_python_ast/src/visitor.rs @@ -423,6 +423,7 @@ pub fn walk_expr<'a, V: Visitor<'a> + ?Sized>(visitor: &mut V, expr: &'a Expr) { Expr::Named(ast::ExprNamed { target, value, + is_let: _, range: _, node_index: _, }) => { diff --git a/crates/ruff_python_ast/src/visitor/transformer.rs b/crates/ruff_python_ast/src/visitor/transformer.rs index f189d53375..520b2532ce 100644 --- a/crates/ruff_python_ast/src/visitor/transformer.rs +++ b/crates/ruff_python_ast/src/visitor/transformer.rs @@ -410,6 +410,7 @@ pub fn walk_expr(visitor: &V, expr: &mut Expr) { Expr::Named(ast::ExprNamed { target, value, + is_let: _, range: _, node_index: _, }) => { diff --git a/crates/ruff_python_codegen/src/generator.rs b/crates/ruff_python_codegen/src/generator.rs index 887832b328..1bc14402d6 100644 --- a/crates/ruff_python_codegen/src/generator.rs +++ b/crates/ruff_python_codegen/src/generator.rs @@ -1237,6 +1237,8 @@ impl<'a> Generator<'a> { named @ ast::ExprNamed { target, value, + // an inline protocol member is rendered by `ProtocolType` + is_let: _, range: _, node_index: _, }, @@ -1821,6 +1823,9 @@ impl<'a> Generator<'a> { // a data member is `Named`, whose default rendering is the // walrus `target := value` rather than the `name: type` label form if let Expr::Named(named) = member { + if named.is_let { + self.p("let "); + } self.unparse_expr(&named.target, precedence::MAX); self.p(": "); self.unparse_expr(&named.value, precedence::EXPR); diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/inline_protocol.by b/crates/ruff_python_formatter/resources/test/fixtures/ruff/inline_protocol.by index 694afe8407..824a246706 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/inline_protocol.by +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/inline_protocol.by @@ -3,6 +3,9 @@ # verbatim from source rather than being rebuilt from the AST type Data = protocol(a: int; b: str) +type ReadOnly = protocol(let a: int; b: str) +# a member that is itself named `let` +type NamedLet = protocol(let: int) type Method = protocol(def f(self) -> int) type Mixed = protocol(a: int; def f(self, x: str) -> bool) type Pack = protocol(**Kwargs) diff --git a/crates/ruff_python_formatter/src/expression/expr_named.rs b/crates/ruff_python_formatter/src/expression/expr_named.rs index 61941f0eea..6241d6e632 100644 --- a/crates/ruff_python_formatter/src/expression/expr_named.rs +++ b/crates/ruff_python_formatter/src/expression/expr_named.rs @@ -16,6 +16,8 @@ impl FormatNodeRule for FormatExprNamed { let ExprNamed { target, value, + // an inline protocol is formatted verbatim, so its members never reach here + is_let: _, range: _, node_index: _, } = item; diff --git a/crates/ruff_python_formatter/tests/snapshots/format@inline_protocol.by.snap b/crates/ruff_python_formatter/tests/snapshots/format@inline_protocol.by.snap index c27984713e..3517cdde1f 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@inline_protocol.by.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@inline_protocol.by.snap @@ -9,6 +9,9 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/inline_pro # verbatim from source rather than being rebuilt from the AST type Data = protocol(a: int; b: str) +type ReadOnly = protocol(let a: int; b: str) +# a member that is itself named `let` +type NamedLet = protocol(let: int) type Method = protocol(def f(self) -> int) type Mixed = protocol(a: int; def f(self, x: str) -> bool) type Pack = protocol(**Kwargs) @@ -50,6 +53,9 @@ type Commented = protocol( # verbatim from source rather than being rebuilt from the AST type Data = protocol(a: int; b: str) +type ReadOnly = protocol(let a: int; b: str) +# a member that is itself named `let` +type NamedLet = protocol(let: int) type Method = protocol(def f(self) -> int) type Mixed = protocol(a: int; def f(self, x: str) -> bool) type Pack = protocol(**Kwargs) diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index a47d9cbc9e..f9df346a4a 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -144,6 +144,12 @@ pub(super) const fn starts_statement_expression(kind: TokenKind) -> bool { /// meaning of `!=` inside a field depend on the whitespace around it. `f"{a!=b}"` is a /// comparison and has to stay one, and python's grammar has no other place where a space /// decides which operator was written. +/// whether a token can name an inline protocol member: a name, or a soft keyword such as `match` +/// or `type`, which is a name everywhere its keyword meaning does not apply +fn is_member_name(kind: TokenKind) -> bool { + kind == TokenKind::Name || kind.is_soft_keyword() +} + fn force_unwrap_before_debug_equal() -> ParseErrorType { ParseErrorType::BasedPythonOnly( "`!=` is the comparison operator inside a replacement field too, so a `!` written \ @@ -1882,6 +1888,7 @@ impl<'src> Parser<'src> { self.parse_conditional_expression_or_higher_impl(ExpressionContext::default()); let field_range = self.node_range(field_start); Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Name(target_name)), value: Box::new(inner.expr), range: field_range, @@ -1952,6 +1959,7 @@ impl<'src> Parser<'src> { .parse_conditional_expression_or_higher_impl(ExpressionContext::default()); let field_range = parser.node_range(field_start); parser.expr_scratch.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Name(target_name)), value: Box::new(inner.expr), range: field_range, @@ -3620,10 +3628,20 @@ impl<'src> Parser<'src> { { return false; } - match self.peek_nth(1).0 { + let (first, first_range) = self.peek_nth(1); + match first { TokenKind::Def => true, TokenKind::DoubleStar => self.options.is_basedpython, - TokenKind::Name => self.peek_nth(2).0 == TokenKind::Colon, + first if is_member_name(first) => match self.peek_nth(2).0 { + TokenKind::Colon => true, + // `protocol(let a: T)` + second if is_member_name(second) => { + self.options.is_basedpython + && self.src_text(first_range) == "let" + && self.peek_nth(3).0 == TokenKind::Colon + } + _ => false, + }, _ => false, } } @@ -3755,11 +3773,19 @@ impl<'src> Parser<'src> { node_index: AtomicNodeIndex::NONE, }) } else { - if !(self.at(TokenKind::Name) && self.peek() == TokenKind::Colon) { + // `let name: T` is a read-only member. a member that is itself named `let` is + // still `let: T`, since the keyword is followed by a name rather than a `:` + let is_let = self.at(TokenKind::Name) + && self.src_text(self.current_token_range()) == "let" + && is_member_name(self.peek()); + if is_let { + self.bump(TokenKind::Name); + } + if !(is_member_name(self.current_token_kind()) && self.peek() == TokenKind::Colon) { self.add_error( ParseErrorType::BasedPythonOnly( - "expected a protocol member: `name: T`, `def name(...) -> T`, \ - or `**Pack`" + "expected a protocol member: `name: T`, `let name: T`, \ + `def name(...) -> T`, or `**Pack`" .to_string(), ), self.current_token_range(), @@ -3779,6 +3805,7 @@ impl<'src> Parser<'src> { Expr::Named(ast::ExprNamed { target: Box::new(Expr::Name(target)), value: Box::new(value), + is_let, range: self.node_range(member_start), node_index: AtomicNodeIndex::NONE, }) @@ -4262,6 +4289,7 @@ impl<'src> Parser<'src> { let inner = self.parse_conditional_expression_or_higher_impl(field_context); let field_range = self.node_range(field_start); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Name(target_name)), value: Box::new(inner.expr), range: field_range, @@ -4289,6 +4317,7 @@ impl<'src> Parser<'src> { let inner = self.parse_conditional_expression_or_higher_impl(field_context); let field_range = self.node_range(field_start); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Name(target_name)), value: Box::new(inner.expr), range: field_range, @@ -4464,6 +4493,7 @@ impl<'src> Parser<'src> { self.parse_conditional_expression_or_higher_impl(ExpressionContext::default()); let field_range = self.node_range(field_start); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Name(target_name)), value: Box::new(inner.expr), range: field_range, @@ -4613,6 +4643,7 @@ impl<'src> Parser<'src> { node_index: AtomicNodeIndex::NONE, }); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Starred(ast::ExprStarred { value: Box::new(anonymous), ctx: ExprContext::Load, @@ -4649,6 +4680,7 @@ impl<'src> Parser<'src> { ); let starred_range = TextRange::new(field_start, target_name.range.end()); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Starred(ast::ExprStarred { value: Box::new(Expr::Name(target_name)), ctx: ExprContext::Load, @@ -4712,6 +4744,7 @@ impl<'src> Parser<'src> { node_index: AtomicNodeIndex::NONE, }); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(outer_starred), value: Box::new(inner.expr), range: target_range, @@ -4745,6 +4778,7 @@ impl<'src> Parser<'src> { .parse_conditional_expression_or_higher_impl(ExpressionContext::default()); let field_range = self.node_range(field_start); elts.push(Expr::Named(ast::ExprNamed { + is_let: false, target: Box::new(Expr::Name(target_name)), value: Box::new(inner.expr), range: field_range, @@ -5310,6 +5344,7 @@ impl<'src> Parser<'src> { self.add_unsupported_syntax_error(UnsupportedSyntaxErrorKind::Walrus, range); ast::ExprNamed { + is_let: false, target: Box::new(target), value: Box::new(value.expr), range, diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_alias.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_alias.snap index 1432690177..3a8984aead 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_alias.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_alias.snap @@ -46,6 +46,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -68,6 +69,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_function_signature.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_function_signature.snap index e2e64c697b..2cda0ca371 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_function_signature.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_function_signature.snap @@ -61,6 +61,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -83,6 +84,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], @@ -131,6 +133,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -153,6 +156,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_in_annotation.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_in_annotation.snap index f8c454c090..66a7f5f776 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_in_annotation.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_in_annotation.snap @@ -44,6 +44,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -66,6 +67,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_type.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_type.snap index d0baa43366..7d7ec56315 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_type.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_type.snap @@ -52,6 +52,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_value.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_value.snap index 9e2aa9ff39..a3b28e128f 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_value.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_mixed_value.snap @@ -68,6 +68,7 @@ ModModule { }, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_single_field.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_single_field.snap index b32e8dbd0f..9e6477ad4e 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_single_field.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_single_field.snap @@ -44,6 +44,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_trailing_comma.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_trailing_comma.snap index 6562bec38f..7c844ef518 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_trailing_comma.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_trailing_comma.snap @@ -44,6 +44,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -66,6 +67,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_complex_value.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_complex_value.snap index 42dfcff84f..5cffde2919 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_complex_value.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_complex_value.snap @@ -76,6 +76,7 @@ ModModule { ), }, ), + is_let: false, }, ), Named( @@ -111,6 +112,7 @@ ModModule { optional: false, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_construction.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_construction.snap index 3cf31ff3b9..4d51cf1783 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_construction.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_construction.snap @@ -59,6 +59,7 @@ ModModule { }, }, ), + is_let: false, }, ), Named( @@ -82,6 +83,7 @@ ModModule { ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_trailing_comma.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_trailing_comma.snap index c68ce08cd0..91b227750f 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_trailing_comma.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__anon_named_tuple_value_trailing_comma.snap @@ -59,6 +59,7 @@ ModModule { }, }, ), + is_let: false, }, ), Named( @@ -82,6 +83,7 @@ ModModule { ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_let_member.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_let_member.snap new file mode 100644 index 0000000000..7204ac1704 --- /dev/null +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_let_member.snap @@ -0,0 +1,128 @@ +--- +source: crates/ruff_python_parser/src/parser/tests.rs +expression: parsed.syntax() +--- +ModModule { + node_index: NodeIndex(None), + range: 0..61, + body: [ + AnnAssign( + StmtAnnAssign { + node_index: NodeIndex(None), + range: 0..60, + decorator_list: [], + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 0..1, + id: Name("a"), + ctx: Store, + }, + ), + annotation: ProtocolType( + ExprProtocolType { + node_index: NodeIndex(None), + range: 3..60, + members: [ + Named( + ExprNamed { + node_index: NodeIndex(None), + range: 12..22, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 16..17, + id: Name("a"), + ctx: Invalid, + }, + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 19..22, + id: Name("int"), + ctx: Load, + }, + ), + is_let: true, + }, + ), + Named( + ExprNamed { + node_index: NodeIndex(None), + range: 24..32, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 24..27, + id: Name("let"), + ctx: Invalid, + }, + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 29..32, + id: Name("str"), + ctx: Load, + }, + ), + is_let: false, + }, + ), + Named( + ExprNamed { + node_index: NodeIndex(None), + range: 34..48, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 38..43, + id: Name("match"), + ctx: Invalid, + }, + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 45..48, + id: Name("int"), + ctx: Load, + }, + ), + is_let: true, + }, + ), + Named( + ExprNamed { + node_index: NodeIndex(None), + range: 50..59, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..54, + id: Name("type"), + ctx: Invalid, + }, + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 56..59, + id: Name("str"), + ctx: Load, + }, + ), + is_let: false, + }, + ), + ], + }, + ), + value: None, + simple: true, + is_context: false, + }, + ), + ], +} diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_method_parameter_spec.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_method_parameter_spec.snap index c5e6aba995..3870776178 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_method_parameter_spec.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_method_parameter_spec.snap @@ -67,6 +67,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -96,6 +97,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -132,6 +134,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_multiline_trailing_semicolon.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_multiline_trailing_semicolon.snap index b38db51808..3df5509113 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_multiline_trailing_semicolon.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_multiline_trailing_semicolon.snap @@ -44,6 +44,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ProtocolMethod( diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_type.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_type.snap index 5584763ba3..102b98fb11 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_type.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__inline_protocol_type.snap @@ -44,6 +44,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), Named( @@ -66,6 +67,7 @@ ModModule { ctx: Load, }, ), + is_let: false, }, ), ProtocolMethod( diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__protocol_call_is_not_inline_protocol.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__protocol_call_is_not_inline_protocol.snap index 1b3ba3f4f6..89d37ae709 100644 --- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__protocol_call_is_not_inline_protocol.snap +++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__protocol_call_is_not_inline_protocol.snap @@ -145,6 +145,7 @@ ModModule { ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/src/parser/tests.rs b/crates/ruff_python_parser/src/parser/tests.rs index 0e7230a1bd..f9899eac1e 100644 --- a/crates/ruff_python_parser/src/parser/tests.rs +++ b/crates/ruff_python_parser/src/parser/tests.rs @@ -1736,6 +1736,15 @@ fn test_inline_protocol_type() { insta::assert_debug_snapshot!(parsed.syntax()); } +/// `let a: T` is a read-only member, a member may still be named `let`, and a soft keyword names +/// a member like any other name +#[test] +fn test_inline_protocol_let_member() { + let source = "a: protocol(let a: int; let: str; let match: int; type: str)\n"; + let parsed = parse_basedpython_module(source); + insta::assert_debug_snapshot!(parsed.syntax()); +} + #[test] fn test_inline_protocol_keyword_pack() { let source = "a: protocol(**Kwargs)\n"; diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@decorator_named_expression_py37.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@decorator_named_expression_py37.py.snap index f7c1f1d378..45e8423bcc 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@decorator_named_expression_py37.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@decorator_named_expression_py37.py.snap @@ -80,6 +80,7 @@ Module( ), }, ), + is_let: false, }, ), arguments: Arguments { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__named_expression_0.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__named_expression_0.py.snap index 824f3261f7..cd68ea77ca 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__named_expression_0.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__named_expression_0.py.snap @@ -42,6 +42,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap index 429b8d5fdf..330c705bc3 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap @@ -432,6 +432,7 @@ Module( ), }, ), + is_let: false, }, ), Name( diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__invalid_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__invalid_target.py.snap index 02d482c66d..7049ad0f95 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__invalid_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__invalid_target.py.snap @@ -48,6 +48,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -94,6 +95,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -130,6 +132,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -194,6 +197,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_1.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_1.py.snap index 6adc7cf738..cce7fe0512 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_1.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_1.py.snap @@ -34,6 +34,7 @@ Module( ctx: Invalid, }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_2.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_2.py.snap index badcc51107..a554a94e9b 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_2.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_2.py.snap @@ -34,6 +34,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_3.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_3.py.snap index 56494fe2b3..ae1f54b3bc 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_3.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_3.py.snap @@ -49,6 +49,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_4.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_4.py.snap index ee4effd12c..ecc7452c31 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_4.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__named__missing_expression_4.py.snap @@ -34,6 +34,7 @@ Module( ctx: Invalid, }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap index b5ff137f49..4cc2264432 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap @@ -40,6 +40,7 @@ Module( ), }, ), + is_let: false, }, ), Name( diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap index 4593ae60d8..2896e0103c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap @@ -633,6 +633,7 @@ Module( ), }, ), + is_let: false, }, ), Name( @@ -671,6 +672,7 @@ Module( ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap index f4ab2b2c11..0c477e4e05 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap @@ -425,6 +425,7 @@ Module( ), }, ), + is_let: false, }, ), Name( diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__subscript__invalid_slice_element.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__subscript__invalid_slice_element.py.snap index 0f5b61e743..c4f2a728ff 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__subscript__invalid_slice_element.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__subscript__invalid_slice_element.py.snap @@ -52,6 +52,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -285,6 +286,7 @@ Module( ), }, ), + is_let: false, }, ), ctx: Load, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_class.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_class.py.snap index 0b5fd6de2b..435f8478fc 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_class.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_class.py.snap @@ -77,6 +77,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ], @@ -389,6 +390,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function.py.snap index 2b70e3ffda..da58c72eb7 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function.py.snap @@ -281,6 +281,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -385,6 +386,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -1212,6 +1214,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -1310,6 +1313,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -1404,6 +1408,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -1496,6 +1501,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function_py314.py.snap index bf557defe8..c82f91e534 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function_py314.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_function_py314.py.snap @@ -53,6 +53,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -126,6 +127,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_py314.py.snap index a274f59751..5525349b13 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_py314.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_py314.py.snap @@ -44,6 +44,7 @@ Module( ), }, ), + is_let: false, }, ), value: None, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_type_alias.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_type_alias.py.snap index 54b057ca25..78d2011ed5 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_type_alias.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@invalid_annotation_type_alias.py.snap @@ -366,6 +366,7 @@ Module( ), }, ), + is_let: false, }, ), cases: [], diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice.py.snap index 8df7ffcb9c..b926e358a5 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice.py.snap @@ -52,6 +52,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice_parse_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice_parse_error.py.snap index f4307180e7..3c6d87e553 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice_parse_error.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@named_expr_slice_parse_error.py.snap @@ -52,6 +52,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__ty_1828.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__ty_1828.py.snap index 3c0eed43f4..11e5f88502 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__ty_1828.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@re_lexing__ty_1828.py.snap @@ -41,6 +41,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap index 82f19b5a7a..3c5f903c86 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap @@ -39,6 +39,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ @@ -122,6 +123,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ @@ -206,6 +208,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -308,6 +311,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ @@ -391,6 +395,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ @@ -480,6 +485,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -567,6 +573,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ @@ -696,6 +703,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ @@ -830,6 +838,7 @@ Module( ), }, ), + is_let: false, }, ), Named( @@ -853,6 +862,7 @@ Module( ), }, ), + is_let: 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 477401a8d4..de1776270b 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 @@ -169,6 +169,7 @@ Module( ), }, ), + is_let: 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 6cc5335145..4a8d54f105 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 @@ -76,6 +76,7 @@ Module( ), }, ), + is_let: false, }, ), op: Add, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap index 5a3e4715de..3d2c034c38 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap @@ -342,6 +342,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: Some( @@ -417,6 +418,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: Some( @@ -1623,6 +1625,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_index_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_index_py38.py.snap index e7053f1126..47d76a9266 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_index_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_index_py38.py.snap @@ -47,6 +47,7 @@ Module( ), }, ), + is_let: false, }, ), ctx: Load, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_comp_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_comp_py38.py.snap index 7733c80c00..325b7543ef 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_comp_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_comp_py38.py.snap @@ -38,6 +38,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), generators: [ diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_literal_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_literal_py38.py.snap index 3d999f8721..4e982e34e7 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_literal_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@unparenthesized_named_expr_set_literal_py38.py.snap @@ -40,6 +40,7 @@ Module( ), }, ), + is_let: false, }, ), NumberLiteral( @@ -104,6 +105,7 @@ Module( ), }, ), + is_let: false, }, ), NumberLiteral( @@ -168,6 +170,7 @@ Module( ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@walrus_py37.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@walrus_py37.py.snap index e712c56940..731aba0b8a 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@walrus_py37.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@walrus_py37.py.snap @@ -35,6 +35,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_invalid_test_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_invalid_test_expr.py.snap index 528d013881..54c7cc682e 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_invalid_test_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_invalid_test_expr.py.snap @@ -148,6 +148,7 @@ Module( ), }, ), + is_let: false, }, ), body: [], diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@with_items_parenthesized_missing_comma.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@with_items_parenthesized_missing_comma.py.snap index e76410e3d3..e88da6a674 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@with_items_parenthesized_missing_comma.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@with_items_parenthesized_missing_comma.py.snap @@ -328,6 +328,7 @@ Module( range: 156..159, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_py39.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_py39.py.snap index 4c27b1ac73..e47e7e216d 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_py39.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@decorator_expression_py39.py.snap @@ -178,6 +178,7 @@ Module( ), }, ), + is_let: false, }, ), arguments: Arguments { diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__arguments.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__arguments.py.snap index ca671c9b80..318a6a0b96 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__arguments.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__arguments.py.snap @@ -1223,6 +1223,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -1363,6 +1364,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -1419,6 +1421,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ 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 a1f155b08c..3182071584 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 @@ -538,6 +538,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -587,6 +588,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -611,6 +613,7 @@ Module( ), }, ), + is_let: 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 1087249e6a..4cb2644cd2 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 @@ -495,6 +495,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap index 7f4b3f51ae..50feaab00d 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap @@ -368,6 +368,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -406,6 +407,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -453,6 +455,7 @@ Module( ), }, ), + is_let: false, }, ), NumberLiteral( @@ -795,6 +798,7 @@ Module( ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__named.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__named.py.snap index 38d65cadd8..b94c54a4da 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__named.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__named.py.snap @@ -35,6 +35,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -78,6 +79,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -123,6 +125,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -180,6 +183,7 @@ Module( is_parameter_shape: false, }, ), + is_let: false, }, ), }, @@ -229,6 +233,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -294,6 +299,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -330,6 +336,7 @@ Module( ), }, ), + is_let: false, }, ), }, @@ -364,6 +371,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__parenthesized.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__parenthesized.py.snap index cc74a0396c..254841d6b2 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__parenthesized.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__parenthesized.py.snap @@ -232,6 +232,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set.py.snap index a832b7d61f..e0e3483c96 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set.py.snap @@ -346,6 +346,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -392,6 +393,7 @@ Module( ), }, ), + is_let: false, }, ), NumberLiteral( @@ -447,6 +449,7 @@ Module( ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__slice.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__slice.py.snap index 852d057ffe..4be9286930 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__slice.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__slice.py.snap @@ -591,6 +591,7 @@ Module( ), }, ), + is_let: false, }, ), ctx: Load, @@ -642,6 +643,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -698,6 +700,7 @@ Module( ), }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__subscript.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__subscript.py.snap index 728f43bf8a..69d368ff3a 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__subscript.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__subscript.py.snap @@ -475,6 +475,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ctx: Load, @@ -693,6 +694,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -749,6 +751,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -938,6 +941,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ctx: Load, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__tuple.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__tuple.py.snap index acfadddc40..a18a767702 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__tuple.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__tuple.py.snap @@ -726,6 +726,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -777,6 +778,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -828,6 +830,7 @@ Module( ), }, ), + is_let: false, }, ), Name( @@ -887,6 +890,7 @@ Module( ), }, ), + is_let: false, }, ), Name( diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__unary_op.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__unary_op.py.snap index 6975b727d7..d054b44d67 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__unary_op.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__unary_op.py.snap @@ -535,6 +535,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap index 5c7a19142a..e6784da801 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap @@ -442,6 +442,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap index fc81e1c3da..e992b4e690 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap @@ -366,6 +366,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_subject_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_subject_expr.py.snap index f8f8c6aec9..22d597ff67 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_subject_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_subject_expr.py.snap @@ -35,6 +35,7 @@ Module( ), }, ), + is_let: false, }, ), cases: [ @@ -93,6 +94,7 @@ Module( ), }, ), + is_let: false, }, ), cases: [ diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_valid_guard_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_valid_guard_expr.py.snap index 37ea70e0b7..e4ce98f7c8 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_valid_guard_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_stmt_valid_guard_expr.py.snap @@ -62,6 +62,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_rebound_comprehension_variable.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_rebound_comprehension_variable.py.snap index 66ccf1e03a..036672711d 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_rebound_comprehension_variable.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@non_rebound_comprehension_variable.py.snap @@ -39,6 +39,7 @@ Module( ), }, ), + is_let: false, }, ), generators: [ diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@other__decorator.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@other__decorator.py.snap index bdcfd544d0..8ef9f44bfe 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@other__decorator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@other__decorator.py.snap @@ -457,6 +457,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_index_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_index_py38.py.snap index 96d6bd74ed..bc5888f3f3 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_index_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_index_py38.py.snap @@ -47,6 +47,7 @@ Module( ), }, ), + is_let: false, }, ), ctx: Load, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_py38.py.snap index f8eb7dd227..8640150d18 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_named_expr_py38.py.snap @@ -40,6 +40,7 @@ Module( ), }, ), + is_let: false, }, ), NumberLiteral( @@ -93,6 +94,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), generators: [ 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 1a3feff163..b9da754437 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 @@ -678,6 +678,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, @@ -735,6 +736,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -854,6 +856,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, @@ -943,6 +946,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, @@ -1256,6 +1260,7 @@ Module( ), }, ), + is_let: false, }, ), debug_text: None, @@ -1940,6 +1945,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, @@ -1992,6 +1998,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: Some( @@ -2053,6 +2060,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, @@ -2105,6 +2113,7 @@ Module( ), }, ), + is_let: false, }, ), optional_vars: None, @@ -3348,6 +3357,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -3423,6 +3433,7 @@ Module( ), }, ), + is_let: false, }, ), ], @@ -3489,6 +3500,7 @@ Module( ), }, ), + is_let: false, }, ), Name( @@ -3571,6 +3583,7 @@ Module( ), }, ), + is_let: false, }, ), Name( @@ -4480,6 +4493,7 @@ Module( ), }, ), + is_let: false, }, ), Starred( @@ -4561,6 +4575,7 @@ Module( ), }, ), + is_let: false, }, ), Starred( diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap index 1cf47f0ccd..fc4ca7c1dc 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap @@ -460,6 +460,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), body: [ @@ -502,6 +503,7 @@ Module( ctx: Load, }, ), + is_let: 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 1f38fe8c7d..3d6a0d5a6d 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 @@ -2216,6 +2216,7 @@ Module( is_type_decoration: false, }, ), + is_let: false, }, ), ), @@ -4283,6 +4284,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ], @@ -7796,6 +7798,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), cases: [ @@ -8015,6 +8018,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ), @@ -8892,6 +8896,7 @@ Module( ), }, ), + is_let: false, }, ), body: [ diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap index b0af82ddd0..a8992cd833 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap @@ -111,6 +111,7 @@ Module( ), }, ), + is_let: 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 a2a5b67daf..0e8c83225a 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 @@ -3113,6 +3113,7 @@ Module( ), }, ), + is_let: false, }, ), body: [ 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 577ba2b3bc..a82f769d26 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 @@ -296,6 +296,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), body: [ @@ -345,6 +346,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), Name( diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_index_py39.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_index_py39.py.snap index a9ef41d87b..e6e76910f6 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_index_py39.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_index_py39.py.snap @@ -47,6 +47,7 @@ Module( ), }, ), + is_let: false, }, ), ctx: Load, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_py39.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_py39.py.snap index 41e559775a..503427a951 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_py39.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@unparenthesized_named_expr_py39.py.snap @@ -40,6 +40,7 @@ Module( ), }, ), + is_let: false, }, ), NumberLiteral( @@ -93,6 +94,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), generators: [ diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_class.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_class.py.snap index 47e5a9741a..567470ce28 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_class.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_class.py.snap @@ -46,6 +46,7 @@ Module( ctx: Load, }, ), + is_let: false, }, ), ], diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_function_py313.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_function_py313.py.snap index b06111de55..d20f7180e6 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_function_py313.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_function_py313.py.snap @@ -53,6 +53,7 @@ Module( ), }, ), + is_let: false, }, ), ), @@ -126,6 +127,7 @@ Module( ), }, ), + is_let: false, }, ), ), diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_py313.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_py313.py.snap index b5481588be..d6e9868c65 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_py313.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@valid_annotation_py313.py.snap @@ -44,6 +44,7 @@ Module( ), }, ), + is_let: false, }, ), value: None, diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@walrus_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@walrus_py38.py.snap index 0dba7ff0cd..c4904911c5 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@walrus_py38.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@walrus_py38.py.snap @@ -35,6 +35,7 @@ Module( ), }, ), + is_let: false, }, ), }, diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index 3fe31e917d..adeeedf665 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -397,6 +397,9 @@ const PRIVATE_TYPE_ALIAS: &[&str] = &["private"]; /// The keyword introducing an inline protocol type, written ahead of its members. const INLINE_PROTOCOL: &[&str] = &["protocol"]; +/// the keyword of a read-only inline protocol member, `protocol(let a: int)` +const INLINE_PROTOCOL_LET: &[&str] = &["let"]; + /// basedpython's postfix `await`, written after the awaited expression. const POSTFIX_AWAIT: &[&str] = &["await"]; @@ -2140,6 +2143,15 @@ impl SourceOrderVisitor<'_> for SemanticTokenVisitor<'_> { // a data member declares a member of the protocol, not a // parameter member => { + if let Expr::Named(named) = member + && named.is_let + { + self.add_consumed_keywords( + named.start(), + named.target.start(), + INLINE_PROTOCOL_LET, + ); + } self.visit_parameter_field(member, SemanticTokenType::Variable); } } @@ -7513,6 +7525,24 @@ def both[reified in out T]() -> None: ... "#); } + #[test] + fn semantic_tokens_inline_protocol_let_member() { + let test = SemanticTokenTest::new_by("def f(x: protocol(let a: int; b: str)): ...\n"); + + let tokens = test.highlight_file(); + + assert_snapshot!(test.to_snapshot(&tokens), @r#" + "f" @ 4..5: Function [definition] + "x" @ 6..7: Parameter [definition] + "protocol" @ 9..17: Keyword + "let" @ 18..21: Keyword + "a" @ 22..23: Variable [definition] + "int" @ 25..28: Class + "b" @ 30..31: Variable [definition] + "str" @ 33..36: Class + "#); + } + #[test] fn semantic_tokens_postfix_await() { // `x.await` trails the keyword rather than introducing it diff --git a/crates/ty_python_core/src/re_exports.rs b/crates/ty_python_core/src/re_exports.rs index 57ff6b3715..6a3cce43da 100644 --- a/crates/ty_python_core/src/re_exports.rs +++ b/crates/ty_python_core/src/re_exports.rs @@ -419,6 +419,7 @@ impl<'db> Visitor<'db> for WalrusFinder<'_, 'db> { ast::Expr::Named(ast::ExprNamed { target, value: _, + is_let: _, range: _, node_index: _, }) => { diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_narrowing.md b/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_narrowing.md index 15dc0ea4f7..5c232ec463 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_narrowing.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_narrowing.md @@ -460,7 +460,7 @@ def checked(a: A): return a def f(x: A): - reveal_type(checked) # revealed: def checked(a: A) -> A & protocol(b: int) + reveal_type(checked) # revealed: def checked(a: A) -> A & protocol(let b: int) reveal_type(checked(x).b) # revealed: int ``` @@ -480,7 +480,7 @@ def checked(o: Outer): return o def f(x: Outer): - reveal_type(checked) # revealed: def checked(o: Outer) -> Outer & protocol(inner: protocol(b: str)) + reveal_type(checked) # revealed: def checked(o: Outer) -> Outer & protocol(let inner: protocol(let b: str)) reveal_type(checked(x).inner.b) # revealed: str ``` @@ -534,7 +534,7 @@ def checked(o: Outer): return o.b def f(x: Outer): - reveal_type(checked) # revealed: def checked(o: Outer) -> Inner & protocol(c: int) + reveal_type(checked) # revealed: def checked(o: Outer) -> Inner & protocol(let c: int) ``` ## a type is handed back by a `type def`, not a value @@ -569,7 +569,7 @@ def checked(a: A | None): return a def f(x: A | None): - reveal_type(checked) # revealed: def checked(a: A | None) -> A & protocol(b: int) + reveal_type(checked) # revealed: def checked(a: A | None) -> A & protocol(let b: int) ``` ## a body that returns nothing asserts what it establishes diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_inline_protocol.md b/crates/ty_python_semantic/resources/mdtest/basedpython_inline_protocol.md index b4bffb7b70..153c6d93c7 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_inline_protocol.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_inline_protocol.md @@ -19,6 +19,24 @@ def f(x: protocol(a: int; b: str)) -> None: reveal_type(x.b) # revealed: str ``` +### Read-only data members + +`let a: T` declares a member that only has to be readable, as a getter-only property does in a +protocol class body. It reads back as `T`, and it cannot be assigned through the protocol + +```by +def f(x: protocol(let a: int)) -> None: + reveal_type(x.a) # revealed: int + x.a = 1 # error: [invalid-assignment] +``` + +A member that is itself named `let` is still written `let: T` + +```by +def f(x: protocol(let: int)) -> None: + reveal_type(x.let) # revealed: int +``` + ### Method members A method member's first parameter is the receiver — a parameter *name*, not a type — so it is bound @@ -110,6 +128,33 @@ def g(y: protocol(a: int)) -> None: f(y) ``` +### A read-only member accepts a narrower attribute + +A mutable member is invariant: the argument's attribute has to be exactly the declared type, since a +write through the protocol could store any value of that type. A `let` member is only read, so an +attribute of any narrower type satisfies it, and so does a property + +```by +class Narrower: + a: bool + +class Property: + @property + def a(self) -> int: + return 1 + +def mutable(x: protocol(a: int)) -> None: ... +def read_only(x: protocol(let a: int)) -> None: ... + +read_only(Narrower()) +read_only(Property()) + +# error: [invalid-argument-type] "Argument to function `mutable` is incorrect" +mutable(Narrower()) +# error: [invalid-argument-type] "Argument to function `mutable` is incorrect" +mutable(Property()) +``` + ### A protocol with fewer members accepts one with more ```by @@ -331,8 +376,8 @@ def f(x: protocol(**int)) -> None: ... A synthesized protocol reads back as the members it declares. ```by -def f(x: protocol(a: int; def m(self) -> str)) -> None: - reveal_type(x) # revealed: protocol(a: int; def m(self) -> str) +def f(x: protocol(a: int; let b: str; def m(self) -> str)) -> None: + reveal_type(x) # revealed: protocol(a: int; let b: str; def m(self) -> str) ``` ## `protocol` is a soft keyword diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md b/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md index b116af4422..40bb043ca6 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md @@ -1342,7 +1342,7 @@ def f(x, y: object): if hasattr(y, "a"): x.b(y) -# revealed: def f(x: some protocol(def b(self, protocol(a: object), /) -> Unknown), y: object) +# revealed: def f(x: some protocol(def b(self, protocol(let a: object), /) -> Unknown), y: object) reveal_type(f) ``` diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 9393444065..b8b33280cf 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -3025,6 +3025,11 @@ impl<'db> FmtDetailed<'db> for DisplayInlineProtocol<'_, 'db> { ty.display_with(self.db, env, self.settings.clone()) .fmt_detailed(f)?; } + Some(InlineProtocolMemberForm::ReadOnlyAttribute(ty)) => { + write!(f, "let {}: ", member.name())?; + ty.display_with(self.db, env, self.settings.clone()) + .fmt_detailed(f)?; + } // `new` rejected an interface with such a member None => return Err(fmt::Error), } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 57b7f02306..c6ec47017f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -8724,11 +8724,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; members.push((method.name.id.clone(), member)); } - // `a: int` — a data member, mutable as in a protocol class body + // `a: int` — a data member, mutable as in a protocol class body, and + // `let a: int` — one that only has to be readable ast::Expr::Named(named) => { let ty = self.infer_type_expression(&named.value); if let Some(name) = named.target.as_name_expr() { - members.push((name.id.clone(), InlineProtocolMember::Attribute(ty))); + let member = if named.is_let { + InlineProtocolMember::ReadOnlyAttribute(ty) + } else { + InlineProtocolMember::Attribute(ty) + }; + members.push((name.id.clone(), member)); } } other => { @@ -10435,6 +10441,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { node_index: _, target, value, + is_let: _, } = named; let add = self.add_binding(named.target.as_ref().into(), definition); diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 3742d5552b..62e10e1527 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -559,6 +559,8 @@ pub(super) enum InlineProtocolMemberForm<'db> { Method(Type<'db>), /// `name: T` Attribute(Type<'db>), + /// `let name: T` + ReadOnlyAttribute(Type<'db>), } impl get_size2::GetSize for ProtocolInterface<'_> {} @@ -2223,7 +2225,12 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { Some(InlineProtocolMemberForm::Attribute(member.ty())) } ProtocolMemberKind::Property { - read: Some(read), .. + read: Some(read), + write: None, + } => Some(InlineProtocolMemberForm::ReadOnlyAttribute(read.ty())), + ProtocolMemberKind::Property { + read: Some(read), + write: Some(_), } => Some(InlineProtocolMemberForm::Attribute(read.ty())), // a `classmethod` / `staticmethod` member has no inline spelling ProtocolMemberKind::Method( diff --git a/docs/basedpython/features/inline-protocol.md b/docs/basedpython/features/inline-protocol.md index b91005eb06..fe8de06b47 100644 --- a/docs/basedpython/features/inline-protocol.md +++ b/docs/basedpython/features/inline-protocol.md @@ -9,7 +9,21 @@ def render(x: protocol(name: str; def greet(self, who: str) -> str)) -> str: ``` `name: T` declares a data member and `def name(...) -> T` declares a method -member. a method member's first parameter is the receiver, so it binds away on +member. `let name: T` declares a read-only data member, which only has to be +readable — an attribute of any narrower type satisfies it, and so does a +property, while a plain `name: T` asks for an attribute of exactly `T` that can +be assigned: + +```by +class Point: + x: bool + +def show(p: protocol(let x: int)) -> None: ... + +show(Point()) # ok — a `bool` attribute can be read as an `int` +``` + +a method member's first parameter is the receiver, so it binds away on access — that is what distinguishes it from a data member whose type happens to be a callable: From e2bacf786e3417a0e4896052a094b6a7a9409b17 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:16:20 +1000 Subject: [PATCH 4/8] read an inferred protocol attribute as object when the body only discards or tests its value --- .../mdtest/basedpython_sound_types.md | 72 +++++++++++ .../src/types/inferred_signature.rs | 112 ++++++++++++------ 2 files changed, 145 insertions(+), 39 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md b/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md index 40bb043ca6..1790ac0172 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_sound_types.md @@ -417,6 +417,78 @@ def f(x): reveal_type(x.name) # revealed: Unknown ``` +### an attribute whose value is only discarded reads back as `object` + +the exception is an attribute the body reads only to throw the value away or to test it. nothing the +function produces can carry that value's type anywhere, so `object` claims no more than the body +asked for: that the attribute can be read + +```by +def discarded(x): + _ = x.name + +def statement(x): + x.name + +def tested(x): + if x.name: + pass + +reveal_type(discarded) # revealed: def discarded(x: some protocol(let name: object)) +reveal_type(statement) # revealed: def statement(x: some protocol(let name: object)) +reveal_type(tested) # revealed: def tested(x: some protocol(let name: object)) +``` + +a value the body hands on — returned, or passed as an argument — can reach what the function +produces, so it stays gradual + +```by +def returned(x): + return x.name + +def passed(x): + print(x.name) + +reveal_type(returned) # revealed: def returned(x: some protocol(let name: Unknown)) -> Unknown +reveal_type(passed) # revealed: def passed(x: some protocol(let name: Unknown)) +``` + +a local is a place the value is handed to as well when a nested function or a lambda reads it, or +when it is a global: what happens to it there is out of the body's sight + +```by +def closed_over(x): + y = x.name + def inner(): + return y + return inner + +def in_a_lambda(x): + y = x.name + return lambda: y + +stored: object = None + +def stored_globally(x): + global stored + stored = x.name + +# revealed: def closed_over(x: some protocol(let name: Unknown)) -> def inner() -> Unknown +reveal_type(closed_over) +reveal_type(in_a_lambda) # revealed: def in_a_lambda(x: some protocol(let name: Unknown)) -> (() -> Unknown) +reveal_type(stored_globally) # revealed: def stored_globally(x: some protocol(let name: Unknown)) +``` + +a method's result stays gradual even when the call is a statement of its own. an `object` result +there is one `unused-return-value` would report, about a value the source never said anything about + +```by +def closed(x): + x.close() + +reveal_type(closed) # revealed: def closed(x: some protocol(def close(self, /) -> Unknown)) +``` + ### a requirement another requirement already meets is left out `int` has the `__mul__` the body needs, so intersecting the two would only cost the body the `int` diff --git a/crates/ty_python_semantic/src/types/inferred_signature.rs b/crates/ty_python_semantic/src/types/inferred_signature.rs index 8c5f483864..5967ebffe0 100644 --- a/crates/ty_python_semantic/src/types/inferred_signature.rs +++ b/crates/ty_python_semantic/src/types/inferred_signature.rs @@ -527,11 +527,17 @@ pub(crate) fn body_parameter_constraints<'db>( .map(|returns| crate::types::definition_expression_type(db, function, returns)); // a name bound more than once cannot stand for one value: which of them a later use is - // about is not a question this can answer + // about is not a question this can answer. a `global` or `nonlocal` name is bound in + // another scope, whose uses of it this walk does not see let place_table = index.place_table(body_scope.file_scope_id(db)); let single_bindings: FxHashSet = place_table .symbols() - .filter(|symbol| symbol.is_bound() && !symbol.is_reassigned()) + .filter(|symbol| { + symbol.is_bound() + && !symbol.is_reassigned() + && !symbol.is_global() + && !symbol.is_nonlocal() + }) .map(|symbol| symbol.name().clone()) .collect(); @@ -566,6 +572,13 @@ pub(crate) fn body_parameter_constraints<'db>( collector.visit_body(&node.body); let mut uses = collector.uses; + // a local a nested scope reads is handed on to that scope, and what the nested scope does + // with it — return it, store it — is out of this walk's sight + for (local, path) in &collector.locals { + if collector.captured.contains(local) { + uses.entry(path.clone()).or_default().handed_on = true; + } + } apply_asserted_local_types( db, env, @@ -873,6 +886,8 @@ struct PathUses<'db> { value: Vec>, /// whether the body did something with this value that no requirement can state unstatable: bool, + /// whether the body handed this value on rather than discarding it (see [`Sink::Anything`]) + handed_on: bool, } /// What one member of the value at a path was used for. @@ -881,23 +896,16 @@ struct MemberUses<'db> { /// the parameters of every call the body made through this member. a member nothing called /// has none, and is asked for as a plain attribute calls: Vec>, - /// how the body got to the member, which decides what its value reads back as when nothing - /// in the body says what that value is + /// how the body got to the member, which decides what becomes of the requirement when + /// something the program states rules it out reach: MemberReach, } /// How a body reached a member: by writing its name, or through the syntax the member is the /// meaning of. /// -/// This decides only what the member's value reads back as when nothing else says. A member the -/// body *named* reads back as `object`: the requirement is that it exist, and `object` is what a -/// value nothing was required of has to be. -/// -/// A member reached through syntax reads back as `Unknown` instead. Recording a requirement is -/// about what the *call site* has to supply, and it should not change what the body itself reads. -/// `x[k]`, `x - 1` and the element of `for _ in x` all read as `Unknown` before any of this, and -/// answering `object` there hands a body that used to check a run of errors about a value the -/// analysis never learned anything about. +/// a requirement reached through syntax gives way to what the program states, and one the body +/// named stands — see [`WhenContradicted`]. #[derive(Copy, Clone, Default, PartialEq, Eq)] enum MemberReach { /// `x.foo`, `x.foo()` @@ -1009,6 +1017,11 @@ fn path_bounds<'db>( uses: FxHashMap, PathUses<'db>>, unstatable: &mut Vec>, ) -> Vec<(Definition<'db>, Requirement<'db>)> { + let handed_on: FxHashSet> = uses + .iter() + .filter(|(_, path_uses)| path_uses.handed_on) + .map(|(path, _)| path.clone()) + .collect(); let mut uses: Vec<_> = uses.into_iter().collect(); uses.sort_by_key(|(path, _)| std::cmp::Reverse(path.members.len())); @@ -1030,16 +1043,26 @@ fn path_bounds<'db>( let mut through_syntax = Vec::new(); for (name, uses) in path_uses.members { // a member the body never constrained is one nothing is known about, and the - // way to say that is a gradual type. `object` would be a stronger claim than - // the source made — it does not describe the value, it forbids every use of + // way to say that is usually a gradual type. `object` would be a stronger claim + // than the source made — it does not describe the value, it forbids every use of // it, and that travels: the member's type becomes the function's own return // type, so an unannotated helper's result reached callers unusable. reading // `yaml.safe_load(fp)` as `object` made `config["plugins"]` an error, and no // annotation could take it back — `object` is not assignable to `dict` - let value = resolved - .get(&path.member(&name)) - .copied() - .unwrap_or_else(Type::unknown); + // + // an attribute whose value the body only ever discarded or tested is the + // exception: nothing the function produces can carry its type anywhere, so + // `object` is exactly what the body asked of it — `_ = x.foo` asks only that + // `foo` be readable. a method's result stays gradual even then, since a call + // statement whose result is not gradual is one `unused-return-value` reports + let member_path = path.member(&name); + let value = resolved.get(&member_path).copied().unwrap_or_else(|| { + if uses.calls.is_empty() && !handed_on.contains(&member_path) { + Type::object() + } else { + Type::unknown() + } + }); let member = if uses.calls.is_empty() { InlineProtocolMember::ReadOnlyAttribute(value) } else { @@ -1184,7 +1207,8 @@ fn record_captured_names(stmt: &Stmt, into: &mut FxHashSet) { } fn record_captured_names_in_expr(expr: &Expr, into: &mut FxHashSet) { - walk_expr(&mut CapturedNames(into), expr); + // visited rather than walked, so a body that is itself a name, `lambda: y`, is recorded + CapturedNames(into).visit_expr(expr); } /// What the place a value is being read into says the value has to be. @@ -1196,10 +1220,15 @@ fn record_captured_names_in_expr(expr: &Expr, into: &mut FxHashSet) { /// costs a bound rather than inventing a requirement that does not hold. #[derive(Debug, Clone, Copy)] enum Sink<'db> { - /// the place takes whatever it is handed — an expression statement's value, a `bool` test, an - /// argument to a parameter typed `object` — so nothing about it has to be recorded for the - /// body to go on checking + /// the place takes whatever it is handed — an argument to a parameter typed `object`, the + /// value of an inferred return — so nothing about it has to be recorded for the body to go on + /// checking. the value is still handed on, so its type can reach something the function + /// produces Anything, + /// the place takes whatever it is handed and keeps none of it — an expression statement's + /// value, a `bool` test, a local that carries the value's uses itself. nothing the function + /// produces can depend on the value's type + Discarded, /// the place says what it holds, and says it in something a call site could be asked for Required(Type<'db>), } @@ -1467,11 +1496,9 @@ where self.reads_into(expr, Sink::Anything); } - /// Say that each of `exprs` sits in a position that asks nothing. - fn all_accounted_for<'e>(&mut self, exprs: impl IntoIterator) { - for expr in exprs { - self.accounted_for(expr); - } + /// say that `expr`'s position asks nothing of the value written there, and keeps none of it + fn discarded(&mut self, expr: &Expr) { + self.reads_into(expr, Sink::Discarded); } /// Whether `call` still binds when the argument written at `range` is `object`. @@ -1597,8 +1624,10 @@ where parts: impl IntoIterator, sink: Option>, ) { - if matches!(sink, Some(Sink::Anything)) { - self.all_accounted_for(parts); + if let Some(sink @ (Sink::Anything | Sink::Discarded)) = sink { + for part in parts { + self.reads_into(part, sink); + } } } @@ -1902,7 +1931,7 @@ where // the value of an expression statement is thrown away, so the statement asks nothing // of it Stmt::Expr(expr) => { - self.accounted_for(&expr.value); + self.discarded(&expr.value); walk_stmt(self, stmt); } @@ -1915,7 +1944,7 @@ where && self.bind_local(target, path) { // the name carries every use below it, so the assignment itself asks nothing - self.accounted_for(&assign.value); + self.discarded(&assign.value); } for target in &assign.targets { match target { @@ -2029,24 +2058,24 @@ where // a test is read for its truth, which every object answers Stmt::If(if_stmt) => { - self.accounted_for(&if_stmt.test); + self.discarded(&if_stmt.test); for clause in &if_stmt.elif_else_clauses { if let Some(test) = &clause.test { - self.accounted_for(test); + self.discarded(test); } } walk_stmt(self, stmt); } Stmt::While(while_stmt) => { - self.accounted_for(&while_stmt.test); + self.discarded(&while_stmt.test); walk_stmt(self, stmt); } Stmt::Assert(assert) => { - self.accounted_for(&assert.test); + self.discarded(&assert.test); if let Some(message) = assert.msg.as_deref() { - self.accounted_for(message); + self.discarded(message); } walk_stmt(self, stmt); } @@ -2074,6 +2103,11 @@ where // a sink belongs to the expression it was set for; whatever is nested inside it is // read into somewhere else, or nowhere let sink = self.sinks.remove(&expr.range()); + if matches!(sink, Some(Sink::Anything)) + && let Some(path) = self.path(expr) + { + self.uses.entry(path).or_default().handed_on = true; + } match sink { Some(Sink::Required(required)) => { // a name that stands for a tracked value carries the requirement itself. a value @@ -2089,7 +2123,7 @@ where self.mark_narrowed(expr); } } - Some(Sink::Anything) => {} + Some(Sink::Anything | Sink::Discarded) => {} // nothing above accounted for this expression, so whatever tracked value it reads was // used in a way this analysis cannot state None => self.cannot_state(expr), @@ -2297,7 +2331,7 @@ where } Expr::If(ternary) => { - self.accounted_for(&ternary.test); + self.discarded(&ternary.test); self.hands_on(&ternary.body, sink); self.hands_on(&ternary.orelse, sink); } From 23b1656a23bb73af4f43a9d412d391de3769fe9c Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:16:31 +1000 Subject: [PATCH 5/8] add an inferredParameterTypes inlay hint showing the some bound an unannotated parameter takes from its body --- crates/ty_ide/src/inlay_hints.rs | 111 +++++++++++++++++- ..._basedpython_inferred_parameter_types.snap | 21 ++++ .../src/types/ide_support.rs | 29 +++++ crates/ty_server/src/session/options.rs | 2 + .../e2e__commands__debug_command.snap | 1 + docs/basedpython/features/editor.md | 1 + 6 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_parameter_types.snap diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index ebd6bf52c5..1930f7b3e2 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -27,11 +27,12 @@ use ty_python_semantic::types::context_params::implicit_context_arguments; use ty_python_semantic::types::ide_support::{ InferredInvalidations, InferredStateReads, InlayHintCallArgumentDetails, StateRead, WriteSite, hintable_parameter_type, implicit_enum_member_value, inferred_derived_dependencies, - inferred_invalidations, inferred_override, inferred_property_type, inferred_raises, - inferred_return_annotation, inferred_state_reads, inferred_type_param_variance, - inherited_parameter_annotation, inherited_parameter_default, inlay_hint_call_argument_details, - is_composable_function, is_reveal_type_function, is_union_special_form, numeric_promotion, - parameter_stability, trailing_lambda_implicit_parameters, type_parameter_names, + inferred_invalidations, inferred_override, inferred_parameter_bound, inferred_property_type, + inferred_raises, inferred_return_annotation, inferred_state_reads, + inferred_type_param_variance, inherited_parameter_annotation, inherited_parameter_default, + inlay_hint_call_argument_details, is_composable_function, is_reveal_type_function, + is_union_special_form, numeric_promotion, parameter_stability, + trailing_lambda_implicit_parameters, type_parameter_names, }; use ty_python_semantic::types::{DisplaySettings, Type, TypeDetail, TypeDisplayDetails}; use ty_python_semantic::{HasType, SemanticModel, with_display_for_file}; @@ -489,6 +490,26 @@ impl InlayHint { } } + /// basedpython: the bound recovered for an unannotated parameter, as the + /// `some` annotation that would declare it + fn inferred_parameter_bound( + db: &dyn Db, + env: &ProgramEnvironment<'_>, + position: TextSize, + bound: Type, + ) -> Self { + Self { + position, + kind: InlayHintKind::InferredParameterType, + label: InlayHintLabel { + parts: vec![format!(": some {}", bound.display(db, env)).into()], + }, + padding_left: false, + padding_right: false, + text_edits: vec![], + } + } + /// the type of a place the source leaves unannotated — a parameter, or a /// basedpython property declaration — shown where the annotation would be /// written @@ -535,6 +556,8 @@ pub enum InlayHintKind { PropertyType, /// basedpython: the return type of a `def` that leaves its annotation out InferredReturnType, + /// basedpython: the bound an unannotated parameter of a `def` takes from its body + InferredParameterType, CallArgumentName, /// basedpython: a function's inferred exception set Raises, @@ -583,6 +606,7 @@ impl InlayHintKind { | InlayHintKind::InheritedParameterType | InlayHintKind::PropertyType | InlayHintKind::InferredReturnType + | InlayHintKind::InferredParameterType // basedpython: an inferred exception set is a type, like a return type | InlayHintKind::Raises | InlayHintKind::Variance @@ -659,6 +683,7 @@ hint_switches!(InlayHintKind, enabled, set_enabled, { InheritedParameterType => inherited_parameter_types = "inheritedParameterTypes", PropertyType => property_types = "propertyTypes", InferredReturnType => inferred_return_types = "inferredReturnTypes", + InferredParameterType => inferred_parameter_types = "inferredParameterTypes", CallArgumentName => call_argument_names = "callArgumentNames", Raises => inferred_raises = "inferredRaises", Variance => inferred_variance = "inferredVariance", @@ -1024,6 +1049,15 @@ pub struct InlayHintSettings { /// ``` pub inferred_return_types: bool, + /// basedpython: whether to show the bound recovered for an unannotated + /// parameter of a `def` from what its body does with it + /// + /// ```by + /// def f(a": some protocol(let foo: object)"): + /// _ = a.foo + /// ``` + pub inferred_parameter_types: bool, + /// basedpython: whether to show the type a property declaration leaves to its /// accessors. /// @@ -1097,6 +1131,7 @@ impl InlayHintSettings { inherited_parameter_types: false, inherited_parameter_defaults: false, inferred_return_types: false, + inferred_parameter_types: false, property_types: false, implicit_arguments: false, enum_values: false, @@ -1127,6 +1162,7 @@ impl InlayHintSettings { inherited_parameter_types, inherited_parameter_defaults, inferred_return_types, + inferred_parameter_types, property_types, implicit_arguments, enum_values, @@ -1154,6 +1190,7 @@ impl InlayHintSettings { || inherited_parameter_types || inherited_parameter_defaults || inferred_return_types + || inferred_parameter_types || property_types || implicit_arguments || enum_values @@ -1185,6 +1222,7 @@ impl Default for InlayHintSettings { inherited_parameter_types: true, inherited_parameter_defaults: true, inferred_return_types: true, + inferred_parameter_types: true, property_types: true, implicit_arguments: true, enum_values: true, @@ -2091,6 +2129,32 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> { )); } + /// basedpython: hint the bound an unannotated parameter of a `def` takes from + /// what its body does with it, as the `some` annotation that would declare it + fn add_inferred_parameter_type(&mut self, parameter: &ast::Parameter) { + let env = &self.model.program_environment(); + // a `.py` file has no `some` to write the bound with + if !self.settings.enabled(InlayHintKind::InferredParameterType) + || !self.is_basedpython() + || self.in_lambda + || parameter.annotation.is_some() + || parameter.range().is_empty() + { + return; + } + + let Some(bound) = inferred_parameter_bound(&self.model, parameter) else { + return; + }; + + self.push(InlayHint::inferred_parameter_bound( + self.db, + env, + parameter.name.range().end(), + bound, + )); + } + /// basedpython: hint the default a parameter takes from the method its `def` /// overrides, where the parameter's own default would be written. fn add_inherited_parameter_default(&mut self, parameter: &ast::ParameterWithDefault) { @@ -2389,6 +2453,7 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> { self.add_implicit_self(parameter); self.add_lambda_parameter_type(parameter); self.add_inherited_parameter_type(parameter); + self.add_inferred_parameter_type(parameter); } source_order::walk_parameter(self, parameter); @@ -3232,6 +3297,9 @@ Source with applied edits: class Child(Base): def f(self, a) -> None: ... + def reads(a): + _ = a.foo + reveal_type(x) def apply(fn: (int) -> None) -> None: @@ -12800,6 +12868,39 @@ def each(fn: (int) -> None) -> None: })); } + /// an unannotated parameter is bounded by what its body does with it, and + /// the bound is shown as the `some` annotation that would declare it. a + /// parameter the body asks nothing of, an annotated one and a method's + /// receiver get no hint + #[test] + fn basedpython_inferred_parameter_types() { + let mut test = basedpython_inlay_hint_test( + " + def read(a): + _ = a.foo + + def called(a, b=1): + x: int = a.size() + return b + + def untouched(a): + pass + + def annotated(a: int): + _ = a.real + + class C: + def m(self, a): + _ = a.foo + ", + ); + + assert_snapshot!(test.inlay_hints_with_settings(&InlayHintSettings { + inferred_parameter_types: true, + ..InlayHintSettings::none() + })); + } + /// A written return annotation is the source's own answer, and an /// `init(...)` is given its `-> None` by the parser, so neither is hinted. #[test] diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_parameter_types.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_parameter_types.snap new file mode 100644 index 0000000000..b491129e7c --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_parameter_types.snap @@ -0,0 +1,21 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: "test.inlay_hints_with_settings(&InlayHintSettings\n{ inferred_parameter_types: true, ..InlayHintSettings::none() })" +--- + +def read(a[: some protocol(let foo: object)]): + _ = a.foo + +def called(a[: some protocol(def size(self, /) -> int)], b[: some int]=1): + x: int = a.size() + return b + +def untouched(a): + pass + +def annotated(a: int): + _ = a.real + +class C: + def m(self, a[: some protocol(let foo: object)]): + _ = a.foo diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 936e09032b..a282cd21bb 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -3417,6 +3417,35 @@ pub fn inherited_parameter_annotation<'db>( (!ty.is_unknown() && !ty.is_inferred_parameter_hole(db)).then_some(ty) } +/// basedpython: the bound an unannotated parameter of a `def` takes from what its body does with +/// it — the `some` hole an inferred signature opens for it +/// +/// `None` when the parameter's type comes from anywhere else (an annotation, the method it +/// overrides, an implicit receiver), and when its body asked nothing of it, since a hole with a +/// gradual bound says nothing a reader could use +pub fn inferred_parameter_bound<'db>( + model: &SemanticModel<'db>, + parameter: &ast::Parameter, +) -> Option> { + let db = model.db(); + let index = semantic_index(db, db.program_file(model.file())); + let definition = index.try_definition(parameter)?; + let function = nearest_enclosing_function(db, index, definition.scope(db))?; + + let signature = function.last_definition_raw_signature(db, ReturnCallableTypeVarScope::Public); + let Type::TypeVar(hole) = + declared_parameter(model, definition, signature, parameter)?.annotated_type() + else { + return None; + }; + if !Type::TypeVar(hole).is_inferred_parameter_hole(db) { + return None; + } + hole.typevar(db) + .upper_bound(db, &model.program_environment()) + .filter(|bound| !bound.is_dynamic()) +} + /// basedpython: the default a parameter takes from the method its `def` overrides, written the /// way a signature writes it. /// diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index 11448811cd..cdc66b68e1 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -422,6 +422,7 @@ pub struct InlayHintOptions { inherited_parameter_types: Option, inherited_parameter_defaults: Option, inferred_return_types: Option, + inferred_parameter_types: Option, property_types: Option, implicit_arguments: Option, enum_values: Option, @@ -452,6 +453,7 @@ impl InlayHintOptions { inherited_parameter_types: self.inherited_parameter_types.unwrap_or(true), inherited_parameter_defaults: self.inherited_parameter_defaults.unwrap_or(true), inferred_return_types: self.inferred_return_types.unwrap_or(true), + inferred_parameter_types: self.inferred_parameter_types.unwrap_or(true), property_types: self.property_types.unwrap_or(true), implicit_arguments: self.implicit_arguments.unwrap_or(true), enum_values: self.enum_values.unwrap_or(true), 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 0f428473f7..6c868baaa9 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 @@ -37,6 +37,7 @@ Settings: WorkspaceSettings { inherited_parameter_types: true, inherited_parameter_defaults: true, inferred_return_types: true, + inferred_parameter_types: true, property_types: true, implicit_arguments: true, enum_values: true, diff --git a/docs/basedpython/features/editor.md b/docs/basedpython/features/editor.md index e676cff7ac..2012b12b0f 100644 --- a/docs/basedpython/features/editor.md +++ b/docs/basedpython/features/editor.md @@ -223,6 +223,7 @@ on | `inheritedParameterTypes` | the type a parameter takes from the method it overrides | | `inheritedParameterDefaults` | the [default](inherited-defaults.md) it takes from that method | | `inferredReturnTypes` | the return type of a `def` that leaves it out | +| `inferredParameterTypes` | the `some` bound an unannotated parameter takes from its body | | `propertyTypes` | the type a [property](properties.md) leaves to its accessors | | `implicitArguments` | the [context arguments](context-parameters.md) a call fills | | `enumValues` | the value an [enum](enums.md) member takes implicitly | From bb75ae1dbe904a8bfdee16821076bcf74ee3c5af Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:50:12 +1000 Subject: [PATCH 6/8] infer a generic base's type arguments from the members a basedpython class overrides, and write them when transpiling --- .../src/transforms/ast_driver.rs | 27 +- .../by_transforms/src/transforms/generics.rs | 2 +- .../src/transforms/inferred_annotation.rs | 21 + .../src/transforms/inferred_base_arguments.rs | 375 +++++++++++++ crates/by_transforms/src/transforms/mod.rs | 1 + crates/by_transforms/src/type_info.rs | 108 +++- crates/ty_ide/src/inlay_hints.rs | 119 ++++- ...edpython_inferred_base_type_arguments.snap | 16 + .../basedpython_inferred_base_arguments.md | 305 +++++++++++ crates/ty_python_semantic/src/types/class.rs | 23 +- .../src/types/class/inferred_base.rs | 491 ++++++++++++++++++ .../src/types/class/static_literal.rs | 15 + .../ty_python_semantic/src/types/display.rs | 70 ++- .../ty_python_semantic/src/types/generics.rs | 12 + .../src/types/ide_support.rs | 71 ++- .../builder/post_inference/static_class.rs | 10 +- crates/ty_server/src/session/options.rs | 2 + .../e2e__commands__debug_command.snap | 1 + docs/basedpython/features/editor.md | 1 + docs/basedpython/features/index.md | 1 + .../features/inferred-base-arguments.md | 98 ++++ zensical.toml | 1 + 22 files changed, 1731 insertions(+), 39 deletions(-) create mode 100644 crates/by_transforms/src/transforms/inferred_base_arguments.rs create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_base_type_arguments.snap create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_inferred_base_arguments.md create mode 100644 crates/ty_python_semantic/src/types/class/inferred_base.rs create mode 100644 docs/basedpython/features/inferred-base-arguments.md diff --git a/crates/by_transforms/src/transforms/ast_driver.rs b/crates/by_transforms/src/transforms/ast_driver.rs index 880588644f..535a9a9fad 100644 --- a/crates/by_transforms/src/transforms/ast_driver.rs +++ b/crates/by_transforms/src/transforms/ast_driver.rs @@ -41,15 +41,15 @@ use super::{ decl_site_variance, decorated_binding, decorator_keyword, dedent_string, destructure, django_lookup, dynamic_keyword, empty_declarations, export_import, extension, flexible_keyword, float_const, force_unwrap, frameworks, generic_call, generics, grapheme_string, identity_swap, - if_let, implicit_receiver, implicit_typing, inferred_annotation, init_method, just_float, - kw_subscript, literal_string, literal_types, local_once, main_function, match_type, modifiers, - module_api, mutable_defaults, none_chain, optional_type, overload, parametric_is, - postfix_await, propagate, properties, protocol_type, raises_clause, reified_class, - reified_generic, repeated_underscore, return_value_use, runtime_union, sentinel, some_ctor, - soundness, statement_expression, static_resource, string_tag, super_keyword, symbolic_type_op, - template_expression, template_type, top_star, trailing_lambda, tuple_index, type_fn, type_is, - type_reification, typed_dict_literal, typed_lambda, typeof_keyword, unique_loop_bindings, - unpack, use_site_variance, visibility_rename, + if_let, implicit_receiver, implicit_typing, inferred_annotation, inferred_base_arguments, + init_method, just_float, kw_subscript, literal_string, literal_types, local_once, + main_function, match_type, modifiers, module_api, mutable_defaults, none_chain, optional_type, + overload, parametric_is, postfix_await, propagate, properties, protocol_type, raises_clause, + reified_class, reified_generic, repeated_underscore, return_value_use, runtime_union, sentinel, + some_ctor, soundness, statement_expression, static_resource, string_tag, super_keyword, + symbolic_type_op, template_expression, template_type, top_star, trailing_lambda, tuple_index, + type_fn, type_is, type_reification, typed_dict_literal, typed_lambda, typeof_keyword, + unique_loop_bindings, unpack, use_site_variance, visibility_rename, }; use crate::Config; use crate::source_map::Replacement; @@ -1415,6 +1415,11 @@ pub(crate) fn run_against_source<'a>( let implicit_typing_pass = implicit_typing::ImplicitTypingPass::new(); let inferred_annotation_pass = inferred_annotation::InferredAnnotationPass::new(written, config.min_version); + let inferred_base_arguments_pass = inferred_base_arguments::InferredBaseArgumentsPass::new( + source_ref, + written, + config.clone(), + ); let template_type_pass = template_type::TemplateTypePass { written }; let tuple_types_pass = annotation::TupleLiteralTypePass::new(source_ref, written, config.clone()); @@ -1675,6 +1680,10 @@ pub(crate) fn run_against_source<'a>( // (`class A: a = 1` → `a: int = 1`); a zero-width insertion at the // target name, disjoint from the value-position lowerings above &inferred_annotation_pass, + // write the type arguments a generic base takes from the class body + // (`class B(A)` → `class B(A[int])`); a zero-width insertion after the + // base expression + &inferred_base_arguments_pass, // an f-string in a type position replaces its whole span with the // python spelling of the type ty resolved it to. it runs before the // type-position passes below so its wide edit claims the holes, whose diff --git a/crates/by_transforms/src/transforms/generics.rs b/crates/by_transforms/src/transforms/generics.rs index 6c49d167d6..594375bc84 100644 --- a/crates/by_transforms/src/transforms/generics.rs +++ b/crates/by_transforms/src/transforms/generics.rs @@ -1600,7 +1600,7 @@ impl PolyfilledTypeParams { /// what each parameter of `type_params` is renamed to, or `None` when the list is kept as /// native syntax - fn renames( + pub(crate) fn renames( &self, type_params: &ruff_python_ast::TypeParams, ) -> Option> { diff --git a/crates/by_transforms/src/transforms/inferred_annotation.rs b/crates/by_transforms/src/transforms/inferred_annotation.rs index 5fc3e0c64b..4d9e2e226e 100644 --- a/crates/by_transforms/src/transforms/inferred_annotation.rs +++ b/crates/by_transforms/src/transforms/inferred_annotation.rs @@ -185,6 +185,27 @@ mod tests { ); } + /// a class nested in another class of the same module is reached through the outer one, + /// which is still being made where a class-body annotation is evaluated, so the annotation + /// is a string rather than an import of the module it is written in + #[test] + fn a_nested_class_is_reached_through_its_outer_class() { + check( + indoc! {" + class D: + class Inner: ... + + x = Inner() + "}, + indoc! {r#" + class D: + class Inner: ... + + x: "D.Inner" = Inner() + "#}, + ); + } + /// `var` and a modifier chain declare no type, so the member is the bare assignment /// it lowers to and gets the same annotation #[test] diff --git a/crates/by_transforms/src/transforms/inferred_base_arguments.rs b/crates/by_transforms/src/transforms/inferred_base_arguments.rs new file mode 100644 index 0000000000..94fb8e41dd --- /dev/null +++ b/crates/by_transforms/src/transforms/inferred_base_arguments.rs @@ -0,0 +1,375 @@ +//! type-aware pass: write the type arguments a generic base takes from the class body +//! overriding its members +//! +//! ```by +//! class A[T]: +//! x: T +//! +//! class B(A): +//! x = 1 +//! ``` +//! +//! lowers `class B(A)` to `class B(A[int])`. the type checker solves the arguments from the +//! members `B` overrides; python has no such rule, so the lowering writes them where a python +//! checker reads them +//! +//! a base is evaluated as the class is made, so an argument that would not resolve there — one +//! naming a module the source never imports as it runs, the class being made, or a class the +//! module binds further down — is written as a string, which a generic class's subscript accepts +//! as a forward reference + +use std::collections::{BTreeSet, HashMap}; + +use ruff_python_ast::visitor::{Visitor, walk_stmt}; +use ruff_python_ast::{Stmt, StmtClassDef}; +use ruff_text_size::TextRange; + +use super::generics::PolyfilledTypeParams; +use super::source_util::rename_identifiers; +use crate::Config; +use crate::transforms::ast_driver::{PassContext, TypeAwarePass}; +use crate::transforms::repeated_underscore::WrittenNames; +use crate::type_info::{SpelledBaseArgument, TypeInfo}; + +pub(crate) struct InferredBaseArgumentsPass<'src> { + source: &'src str, + written: WrittenNames<'src>, + config: Config, +} + +impl<'src> InferredBaseArgumentsPass<'src> { + pub(crate) fn new(source: &'src str, written: WrittenNames<'src>, config: Config) -> Self { + Self { + source, + written, + config, + } + } +} + +impl TypeAwarePass for InferredBaseArgumentsPass<'_> { + fn run(&self, stmts: &[Stmt], types: &dyn TypeInfo, ctx: &mut PassContext) { + let mut state = State { + types, + source: self.source, + written: self.written, + min_version: self.config.min_version, + polyfilled: PolyfilledTypeParams::decide( + self.source, + self.written, + stmts, + &self.config, + ), + edits: Vec::new(), + errors: Vec::new(), + modules: BTreeSet::new(), + imports: BTreeSet::new(), + }; + for stmt in stmts { + state.visit_stmt(stmt); + } + ctx.text_edits.extend(state.edits); + ctx.errors.extend(state.errors); + ctx.type_only_imports.extend(state.modules); + ctx.required_imports.extend(state.imports); + } +} + +struct State<'a> { + types: &'a dyn TypeInfo, + source: &'a str, + written: WrittenNames<'a>, + min_version: ruff_python_ast::PythonVersion, + /// the type parameters the generics polyfill moves to module-level `TypeVar`s, under names + /// of its own + polyfilled: PolyfilledTypeParams, + edits: Vec<(TextRange, String)>, + errors: Vec, + /// the modules an argument qualifies a class with that the source never imported under their + /// own names + modules: BTreeSet, + /// the imports of the `typing` names an argument reads, and of `Unpack` + imports: BTreeSet, +} + +impl State<'_> { + fn process_class(&mut self, class: &StmtClassDef) { + // a class's own type parameters, which an argument may name, are renamed by the + // polyfill when it moves them to the module + let renames: HashMap = class + .type_params + .as_deref() + .and_then(|type_params| self.polyfilled.renames(type_params)) + .unwrap_or_default(); + let renames: HashMap<&str, &str> = renames + .iter() + .map(|(from, to)| (from.as_str(), to.as_str())) + .collect(); + + for (base, arguments) in self + .types + .inferred_base_type_arguments(class, self.min_version) + { + // the checker inherits these arguments; leaving them out would give a python + // checker a different base + let Some(arguments) = arguments else { + self.errors.push(format!( + "the type arguments `{}` inherits `{}` at cannot be written where its base is", + class.name, &self.source[base], + )); + continue; + }; + let spelled: Vec = arguments + .iter() + .map(|argument| match argument { + SpelledBaseArgument::Type(ty) => ty.annotation_in(self.written, true), + SpelledBaseArgument::Unpacked(ty) => { + self.imports + .insert(self.written.import_from("typing", &["Unpack"])); + format!( + "{}[{}]", + self.written.imported("typing", "Unpack"), + ty.annotation_in(self.written, true) + ) + } + }) + .map(|text| rename_identifiers(&text, &renames)) + .collect(); + self.edits.push(( + TextRange::empty(base.end()), + format!("[{}]", spelled.join(", ")), + )); + for argument in &arguments { + let (SpelledBaseArgument::Type(ty) | SpelledBaseArgument::Unpacked(ty)) = argument; + self.modules.extend(ty.modules.iter().cloned()); + self.imports.extend(ty.typing_imports_in(self.written)); + } + } + } +} + +impl<'ast> Visitor<'ast> for State<'_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + if let Stmt::ClassDef(class) = stmt { + self.process_class(class); + } + walk_stmt(self, stmt); + } +} + +#[cfg(test)] +mod tests { + use crate::{Config, transpile}; + use indoc::indoc; + use ruff_python_ast::PythonVersion; + + /// on 3.12, where the generic classes keep their own type parameter lists + fn check(input: &str, expected: &str) { + let config = Config { + min_version: PythonVersion::PY312, + ..Config::test_default() + }; + assert_eq!( + transpile(input, &config).unwrap(), + crate::python_passthrough::lazify_expected(expected) + ); + } + + #[test] + fn an_attribute_override_writes_the_argument() { + check( + indoc! {" + class A[T]: + x: T + + class B(A): + x = 1 + "}, + indoc! {" + class A[T]: + x: T + + class B(A[int]): + x: int = 1 + "}, + ); + } + + #[test] + fn a_written_argument_is_left_alone() { + check( + indoc! {" + class A[T]: + x: T + + class B(A[object]): + x = 1 + "}, + indoc! {" + class A[T]: + x: T + + class B(A[object]): + x: int = 1 + "}, + ); + } + + #[test] + fn several_arguments_are_written_in_declaration_order() { + check( + indoc! {" + class C[K, V]: + def get(self, key: K) -> V: + raise NotImplementedError + + class D(C): + override def get(self, key: int) -> str: + return '' + "}, + indoc! {" + from typing import override + class C[K, V]: + def get(self, key: K) -> V: + raise NotImplementedError + + class D(C[int, str]): + @override + def get(self, key: int) -> str: + return '' + "}, + ); + } + + /// a `*Ts` of a fixed number of types is written as that many arguments, and one of no + /// fixed number unpacked + #[test] + fn a_variadic_argument_is_written_by_its_length() { + check( + indoc! {" + class F[*Ts]: + def f(self) -> tuple[*Ts]: + raise NotImplementedError + + class Two(F): + override def f(self) -> tuple[int, str]: + return (1, '') + + class Many(F): + override def f(self) -> tuple[int, ...]: + return (1,) + "}, + indoc! {" + from typing import Unpack, override + class F[*Ts]: + def f(self) -> tuple[*Ts]: + raise NotImplementedError + + class Two(F[int, str]): + @override + def f(self) -> tuple[int, str]: + return (1, '') + + class Many(F[Unpack[tuple[int, ...]]]): + @override + def f(self) -> tuple[int, ...]: + return (1,) + "}, + ); + } + + /// a base is evaluated before the class exists and before anything the module binds further + /// down, so an argument naming either is a forward reference + #[test] + fn an_argument_not_yet_bound_is_a_string() { + check( + indoc! {" + class A[T]: + x: T + + class Early: ... + + class D(A): + class Inner: ... + x: Inner + + class E(A): + x: Later + + class F(A): + x: Early + + class Later: ... + "}, + indoc! {r#" + class A[T]: + x: T + + class Early: ... + + class D(A["D.Inner"]): + class Inner: ... + x: Inner + + class E(A["Later"]): + x: "Later" + + class F(A[Early]): + x: Early + + class Later: ... + "#}, + ); + } + + /// a generic subclass names its own type parameter, under the name the polyfill declares it + /// by where the type parameter list is lowered + #[test] + fn a_generic_subclass_names_its_own_type_parameter() { + let source = indoc! {" + class A[T]: + x: T + + class H[U](A): + x: U + "}; + check( + source, + indoc! {" + class A[T]: + x: T + + class H[U](A[U]): + x: U + "}, + ); + let lowered = transpile(source, &Config::test_default()).unwrap(); + assert!( + lowered.contains("class H(A[_U], Generic[_U]):"), + "{lowered}" + ); + } + + /// the checker inherits the arguments, so a base whose arguments python cannot write where + /// the base is — a class local to a function — is an error rather than a different base + #[test] + fn an_argument_python_cannot_write_is_an_error() { + let error = transpile( + indoc! {" + class A[T]: + x: T + + def make(): + class Local: ... + + class B(A): + x: Local + + return B + "}, + &Config::test_default(), + ) + .unwrap_err(); + assert!(error.contains("cannot be written"), "{error}"); + } +} diff --git a/crates/by_transforms/src/transforms/mod.rs b/crates/by_transforms/src/transforms/mod.rs index f72039b3fb..beaaabc8f6 100644 --- a/crates/by_transforms/src/transforms/mod.rs +++ b/crates/by_transforms/src/transforms/mod.rs @@ -39,6 +39,7 @@ pub(crate) mod if_let; pub(crate) mod implicit_receiver; pub(crate) mod implicit_typing; pub(crate) mod inferred_annotation; +pub(crate) mod inferred_base_arguments; pub(crate) mod init_method; pub(crate) mod intersection; pub(crate) mod just_float; diff --git a/crates/by_transforms/src/type_info.rs b/crates/by_transforms/src/type_info.rs index 8039be8611..ac0c0227cd 100644 --- a/crates/by_transforms/src/type_info.rs +++ b/crates/by_transforms/src/type_info.rs @@ -2,17 +2,19 @@ use crate::transforms::repeated_underscore::WrittenNames; use crate::transforms::trailing_lambda::RECEIVER_PARAMETER; +use ruff_db::parsed::parsed_module; use ruff_python_ast::helpers::is_dotted_name; use ruff_python_ast::{ Expr, ExprCall, ExprName, ExprRef, Parameter, ParameterWithDefault, Stmt, StmtClassDef, StmtFunctionDef, }; use ruff_python_parser::parse_expression; -use ruff_text_size::TextRange; +use ruff_text_size::{Ranged, TextRange}; use ty_python_core::scope::ScopeKind; -use ty_python_core::{global_scope, place_table, semantic_index}; +use ty_python_core::{global_scope, place_table, semantic_index, use_def_map}; use ty_python_semantic::types::call_type_forms::CallTypeForm; use ty_python_semantic::types::exceptions::RaisesRuntimeTarget; +use ty_python_semantic::types::ide_support::BaseTypeArgument; use ty_python_semantic::types::repeated_underscore::{UnderscoreLowering, UnderscoreRefusal}; use ty_python_semantic::types::{ DisplaySettings, DynamicType, KnownClass, KnownInstanceType, Type, UnpackedKwargs, character, @@ -53,6 +55,15 @@ pub(crate) enum UnpackedKwargsLowering { Protocol(Vec<(String, String)>), } +/// basedpython: one inferred argument of a base's subscript, spelled for the emitted file +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SpelledBaseArgument { + /// an ordinary type argument + Type(SynthesizedType), + /// the argument of a `*Ts` that is no fixed number of types, written unpacked + Unpacked(SynthesizedType), +} + /// A type spelled as source for the *emitted* file, with the imports that spelling needs. /// Produced by [`spell_for_python`], which never returns one whose text names something the /// output cannot resolve @@ -64,6 +75,10 @@ pub(crate) struct SynthesizedType { pub(crate) modules: Vec, /// `typing` names the file does not already bind pub(crate) typing_names: Vec<&'static str>, + /// whether the text names something not yet bound where it is written: a class the module + /// binds further down, or the class being defined, or one nested in it. evaluated there it + /// raises, so it is written as a string + pub(crate) forward: bool, } impl SynthesizedType { @@ -119,7 +134,7 @@ impl SynthesizedType { /// [`PassContext::annotations_evaluated`]: crate::transforms::ast_driver::PassContext::annotations_evaluated pub(crate) fn annotation_in(&self, written: WrittenNames, evaluated: bool) -> String { let text = self.text_in(written); - if evaluated && !self.modules.is_empty() { + if evaluated && (!self.modules.is_empty() || self.forward) { crate::transforms::source_util::string_repr(&text) } else { text @@ -753,6 +768,16 @@ pub(crate) trait TypeInfo { /// member). the inferred-annotation transform must leave these alone fn class_body_annotation_is_semantic(&self, class_def: &StmtClassDef) -> bool; + /// basedpython: the type arguments each generic base `class_def` writes without them is + /// inherited at, solved from the members the class body overrides — `class B(A): x = 1` + /// inherits `A[int]`. each entry is the base expression's range and one spelling per + /// argument, or `None` when python cannot spell one of them where the base is written + fn inferred_base_type_arguments( + &self, + class_def: &StmtClassDef, + min_version: ruff_python_ast::PythonVersion, + ) -> Vec<(TextRange, Option>)>; + /// basedpython: the name of the backing function `member` of the extension `class_def` /// declares lowers to, the name every call of it is rewritten to call. `None` when /// `class_def` is not an extension @@ -1573,6 +1598,40 @@ impl TypeInfo for SemanticModel<'_> { }) } + fn inferred_base_type_arguments( + &self, + class_def: &StmtClassDef, + min_version: ruff_python_ast::PythonVersion, + ) -> Vec<(TextRange, Option>)> { + let db = self.db(); + let env = self.program_environment(); + ty_python_semantic::types::ide_support::inferred_base_type_arguments(self, class_def) + .into_iter() + .map(|(range, arguments)| { + // a base is evaluated where it is written, so that is where its arguments have + // to resolve + let base = class_def.bases().iter().find(|base| base.range() == range); + let spelled = arguments + .into_iter() + .map(|argument| { + let (ty, unpacked) = match argument { + BaseTypeArgument::Type(ty) => (ty, false), + BaseTypeArgument::Unpacked(ty) => (ty, true), + }; + let spelled = + spell_for_python(db, &env, self, ty, min_version, false, base)?; + Some(if unpacked { + SpelledBaseArgument::Unpacked(spelled) + } else { + SpelledBaseArgument::Type(spelled) + }) + }) + .collect::>>(); + (range, spelled) + }) + .collect() + } + fn extension_backing_function(&self, class_def: &StmtClassDef, member: &str) -> Option { let class = class_def.inferred_type(self)?.as_class_literal()?; ty_python_semantic::extension_backing_function(self.db(), class, member) @@ -1680,10 +1739,19 @@ fn spell_for_python<'db>( .filter_map(|name| typing_name(name, literal)) .collect(); + let forward = spelling.reads_nested_class + || written_at.is_some_and(|anchor| { + read.iter().any(|name| { + !modules.contains(name) + && !typing_names.contains(&name.as_str()) + && reads_module_name_before_bound(model, name, anchor) + }) + }); let spelled = SynthesizedType { text, modules, typing_names, + forward, }; resolvable_in_output( model, @@ -1696,6 +1764,40 @@ fn spell_for_python<'db>( .then_some(spelled) } +/// whether `name`, read at `anchor`, means a module-level name the module has not bound yet +/// when python evaluates `anchor`: one whose every binding is a statement that ends after it, +/// such as the class whose header `anchor` is part of. a name an enclosing scope binds — the +/// type parameter of a generic class, read in its header — is not the module's +fn reads_module_name_before_bound(model: &SemanticModel<'_>, name: &str, anchor: &Expr) -> bool { + let db = model.db(); + let file = model.program_file(); + let index = semantic_index(db, file); + let Some(scope_id) = index.try_expression_scope_id(anchor) else { + return false; + }; + let global = global_scope(db, file); + let bound_in_enclosing = index + .ancestor_scopes(scope_id) + .map(|(ancestor, _)| ancestor.to_scope_id(db, file)) + .filter(|scope| *scope != global) + .any(|scope| { + place_table(db, scope) + .symbol_by_name(name) + .is_some_and(ty_python_core::symbol::Symbol::is_bound) + }); + if bound_in_enclosing { + return false; + } + let Some(symbol) = place_table(db, global).symbol_id(name) else { + return false; + }; + let module = parsed_module(db, file.python_file(db)).load(db); + !use_def_map(db, global) + .reachable_symbol_bindings(symbol) + .filter_map(|binding| binding.binding.definition()) + .any(|definition| definition.full_range(db, &module).range().end() <= anchor.start()) +} + /// the `typing` name `name` is, when ty's display writes it for a special form rather than /// for a class: one basedpython binds implicitly, or, when `literal`, `Literal` fn typing_name(name: &str, literal: bool) -> Option<&'static str> { diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 1930f7b3e2..7ba5689607 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -25,14 +25,14 @@ use ty_python_semantic::reified::{ }; use ty_python_semantic::types::context_params::implicit_context_arguments; use ty_python_semantic::types::ide_support::{ - InferredInvalidations, InferredStateReads, InlayHintCallArgumentDetails, StateRead, WriteSite, - hintable_parameter_type, implicit_enum_member_value, inferred_derived_dependencies, - inferred_invalidations, inferred_override, inferred_parameter_bound, inferred_property_type, - inferred_raises, inferred_return_annotation, inferred_state_reads, - inferred_type_param_variance, inherited_parameter_annotation, inherited_parameter_default, - inlay_hint_call_argument_details, is_composable_function, is_reveal_type_function, - is_union_special_form, numeric_promotion, parameter_stability, - trailing_lambda_implicit_parameters, type_parameter_names, + BaseTypeArgument, InferredInvalidations, InferredStateReads, InlayHintCallArgumentDetails, + StateRead, WriteSite, hintable_parameter_type, implicit_enum_member_value, + inferred_base_type_arguments, inferred_derived_dependencies, inferred_invalidations, + inferred_override, inferred_parameter_bound, inferred_property_type, inferred_raises, + inferred_return_annotation, inferred_state_reads, inferred_type_param_variance, + inherited_parameter_annotation, inherited_parameter_default, inlay_hint_call_argument_details, + is_composable_function, is_reveal_type_function, is_union_special_form, numeric_promotion, + parameter_stability, trailing_lambda_implicit_parameters, type_parameter_names, }; use ty_python_semantic::types::{DisplaySettings, Type, TypeDetail, TypeDisplayDetails}; use ty_python_semantic::{HasType, SemanticModel, with_display_for_file}; @@ -490,6 +490,34 @@ impl InlayHint { } } + /// basedpython: the type arguments a generic base takes from the class + /// body, where they would be written + fn inferred_base_type_arguments( + db: &dyn Db, + env: &ProgramEnvironment<'_>, + position: TextSize, + arguments: &[BaseTypeArgument], + ) -> Self { + let arguments = arguments + .iter() + .map(|argument| match argument { + BaseTypeArgument::Type(ty) => ty.display(db, env).to_string(), + BaseTypeArgument::Unpacked(ty) => format!("*{}", ty.display(db, env)), + }) + .collect::>() + .join(", "); + Self { + position, + kind: InlayHintKind::InferredBaseTypeArguments, + label: InlayHintLabel { + parts: vec![format!("[{arguments}]").into()], + }, + padding_left: false, + padding_right: false, + text_edits: vec![], + } + } + /// basedpython: the bound recovered for an unannotated parameter, as the /// `some` annotation that would declare it fn inferred_parameter_bound( @@ -558,6 +586,8 @@ pub enum InlayHintKind { InferredReturnType, /// basedpython: the bound an unannotated parameter of a `def` takes from its body InferredParameterType, + /// basedpython: the type arguments a generic base takes from the class body + InferredBaseTypeArguments, CallArgumentName, /// basedpython: a function's inferred exception set Raises, @@ -607,6 +637,7 @@ impl InlayHintKind { | InlayHintKind::PropertyType | InlayHintKind::InferredReturnType | InlayHintKind::InferredParameterType + | InlayHintKind::InferredBaseTypeArguments // basedpython: an inferred exception set is a type, like a return type | InlayHintKind::Raises | InlayHintKind::Variance @@ -684,6 +715,7 @@ hint_switches!(InlayHintKind, enabled, set_enabled, { PropertyType => property_types = "propertyTypes", InferredReturnType => inferred_return_types = "inferredReturnTypes", InferredParameterType => inferred_parameter_types = "inferredParameterTypes", + InferredBaseTypeArguments => inferred_base_type_arguments = "inferredBaseTypeArguments", CallArgumentName => call_argument_names = "callArgumentNames", Raises => inferred_raises = "inferredRaises", Variance => inferred_variance = "inferredVariance", @@ -1058,6 +1090,15 @@ pub struct InlayHintSettings { /// ``` pub inferred_parameter_types: bool, + /// basedpython: whether to show the type arguments a generic base written + /// without them takes from the members the class body overrides + /// + /// ```by + /// class B(A"[int]"): + /// x = 1 + /// ``` + pub inferred_base_type_arguments: bool, + /// basedpython: whether to show the type a property declaration leaves to its /// accessors. /// @@ -1132,6 +1173,7 @@ impl InlayHintSettings { inherited_parameter_defaults: false, inferred_return_types: false, inferred_parameter_types: false, + inferred_base_type_arguments: false, property_types: false, implicit_arguments: false, enum_values: false, @@ -1163,6 +1205,7 @@ impl InlayHintSettings { inherited_parameter_defaults, inferred_return_types, inferred_parameter_types, + inferred_base_type_arguments, property_types, implicit_arguments, enum_values, @@ -1191,6 +1234,7 @@ impl InlayHintSettings { || inherited_parameter_defaults || inferred_return_types || inferred_parameter_types + || inferred_base_type_arguments || property_types || implicit_arguments || enum_values @@ -1223,6 +1267,7 @@ impl Default for InlayHintSettings { inherited_parameter_defaults: true, inferred_return_types: true, inferred_parameter_types: true, + inferred_base_type_arguments: true, property_types: true, implicit_arguments: true, enum_values: true, @@ -2129,6 +2174,29 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> { )); } + /// basedpython: hint the type arguments each generic base a class writes + /// without them takes from the members its body overrides, after the base + fn add_inferred_base_type_arguments(&mut self, class: &ast::StmtClassDef) { + let env = &self.model.program_environment(); + if !self + .settings + .enabled(InlayHintKind::InferredBaseTypeArguments) + { + return; + } + for (base, arguments) in inferred_base_type_arguments(&self.model, class) { + if self.range.intersect(base).is_none() { + continue; + } + self.push(InlayHint::inferred_base_type_arguments( + self.db, + env, + base.end(), + &arguments, + )); + } + } + /// basedpython: hint the bound an unannotated parameter of a `def` takes from /// what its body does with it, as the `some` annotation that would declare it fn add_inferred_parameter_type(&mut self, parameter: &ast::Parameter) { @@ -2400,6 +2468,7 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> { class.inferred_type(model) }); self.add_inferred_class_reification(class); + self.add_inferred_base_type_arguments(class); // a `case` variant is a member declaration with nowhere to write // a value, so it goes after the name — `case Red 1, Green 2` @@ -3300,6 +3369,12 @@ Source with applied edits: def reads(a): _ = a.foo + class Held[T]: + x: T + + class HeldInt(Held): + x = 1 + reveal_type(x) def apply(fn: (int) -> None) -> None: @@ -12868,6 +12943,34 @@ def each(fn: (int) -> None) -> None: })); } + /// a generic base written without type arguments takes them from the + /// members the class body overrides, and they are shown where they would be + /// written. a base written with its arguments, and one the body does not + /// solve, get no hint + #[test] + fn basedpython_inferred_base_type_arguments() { + let mut test = basedpython_inlay_hint_test( + " + class A[T]: + x: T + + class B(A): + x = 1 + + class C(A[object]): + x = 1 + + class D(A): + y = 1 + ", + ); + + assert_snapshot!(test.inlay_hints_with_settings(&InlayHintSettings { + inferred_base_type_arguments: true, + ..InlayHintSettings::none() + })); + } + /// an unannotated parameter is bounded by what its body does with it, and /// the bound is shown as the `some` annotation that would declare it. a /// parameter the body asks nothing of, an annotated one and a method's diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_base_type_arguments.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_base_type_arguments.snap new file mode 100644 index 0000000000..5a8e920b78 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_base_type_arguments.snap @@ -0,0 +1,16 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: "test.inlay_hints_with_settings(&InlayHintSettings\n{ inferred_base_type_arguments: true, ..InlayHintSettings::none() })" +--- + +class A[T]: + x: T + +class B(A[[int]]): + x = 1 + +class C(A[object]): + x = 1 + +class D(A): + y = 1 diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_base_arguments.md b/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_base_arguments.md new file mode 100644 index 0000000000..3e4fdf1ced --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_inferred_base_arguments.md @@ -0,0 +1,305 @@ +# basedpython: type arguments a base takes from its overrides + +a generic base written without type arguments is inherited at the ones the class body's overrides +need. every member the class writes overrides the one the base declares, so it has to fit the base's +declaration, and the type arguments are solved from those overrides the way a call solves a generic +function's type parameters from its arguments + +## an attribute override + +`x = 1` declares `x: int`, so it overrides `x: T` only at `A[int]` + +```by +class A[T]: + x: T + + def get(self) -> T: + return self.x + +class B(A): + x = 1 + +reveal_type(B().get()) # revealed: int +reveal_type(B().x) # revealed: int +``` + +## an annotated attribute override + +an annotation says the same thing as a value does + +```by +class A[T]: + x: T + +class B(A): + x: list[str] = [] + +def takes(a: A[list[str]]): ... + +takes(B()) +``` + +## a method override + +a method is read from what it writes itself: its annotated parameters and its annotated return type + +```by +class Store[K, V]: + def get(self, key: K) -> V: + raise NotImplementedError + +class Names(Store): + override def get(self, key: int) -> str: + return "" + +reveal_type(Names().get(1)) # revealed: str + +def takes(store: Store[int, str]): ... + +takes(Names()) +``` + +## several overrides solve together + +```by +class Pair[L, R]: + left: L + + def right(self) -> R: + raise NotImplementedError + +class P(Pair): + left = "a" + + override def right(self) -> int: + return 1 + +def takes(pair: Pair[str, int]): ... + +takes(P()) +``` + +## a type parameter no override names + +nothing in the body says what `T` is, so the base needs its type argument written + +```by +class A[T]: + x: T + +class B(A): # error: [missing-type-argument] + y = 1 +``` + +## what an override leaves out says nothing + +a parameter an override leaves unannotated takes its type from the method it overrides, and a return +type it leaves out is read off its body, which is typed in terms of the very base being solved. +neither says anything about the base, so here nothing names `T` + +```by +class A[T]: + def f(self, x: T) -> T: + raise NotImplementedError + +class B(A): # error: [missing-type-argument] + override def f(self, x): + return x +``` + +## a type parameter with a default + +a type parameter with a default does not need an override to name it + +```by +class A[T, U = str]: + x: T + y: U + +class B(A): + x = 1 + +def takes(a: A[int, str]): ... + +takes(B()) +``` + +## an override solves a type parameter with a default too + +an override that names a type parameter decides it whether or not the parameter has a default, the +same way a call's arguments decide a type parameter that has one + +```by +class C[U = str]: + y: U + +class D(C): + y = 2 + +def takes(c: C[int]): ... + +takes(D()) +``` + +## overrides that disagree + +an attribute that can be assigned through the base is invariant: a subclass that narrowed it would +let a write through the base store a value the subclass rules out. so `x = 1` asks for exactly +`A[int]`, and a `get` returning `str` rules that out. no specialization fits both, so the base needs +its type argument written + +```by +class A[T]: + x: T + + def get(self) -> T: + return self.x + +class B(A): # error: [missing-type-argument] + x = 1 + + override def get(self) -> str: + return "" +``` + +## a solution the class header cannot name + +the transpiled class writes the arguments into its header, which sees the module and the class's own +type parameters and nothing else. an override typed by a method's `Self`, by a method's own type +parameter or by an anonymous `some` parameter solves the base to something the header cannot name, +so the base needs its type arguments written + +```by +from typing import Self + +class A[T]: + def f(self) -> T: + raise NotImplementedError + +class SelfTyped(A): # error: [missing-type-argument] + override def f(self) -> Self: + return self + +class B[K, V]: + def get(self, key: K) -> V: + raise NotImplementedError + +class HoleTyped(B): # error: [missing-type-argument] + override def get(self, key: some int) -> str: + return "" +``` + +## a generic subclass passes its own type parameter on + +the class's own type parameters are in scope in its header, so an override typed by one of them +solves the base to it + +```by +class A[T]: + x: T + + def get(self) -> T: + return self.x + +class H[U](A): + x: U + +def check(h: H[int]): + reveal_type(h.get()) # revealed: int +``` + +## an override that returns the class itself + +`IntNode`'s override returns an `IntNode`, which is a `Node[T]` only once `IntNode`'s base is known, +and that base is what is being solved. it is not solved from itself, so the base needs its type +argument written + +```by +class Node[T]: + def child(self) -> Node[T]: + raise NotImplementedError + +class IntNode(Node): # error: [missing-type-argument] + override def child(self) -> IntNode: + return self +``` + +## a member under a visibility keyword + +a `private` or `protected` member is emitted under a name of its own, so it does not override the +base's member of the same name and says nothing about the base + +```by +class A[T]: + x: T + +class B(A): # error: [missing-type-argument] + private x: int = 1 # error: [invalid-override-visibility] +``` + +## a variadic type parameter + +a `*Ts` is solved to a run of types, which the header writes as that many arguments + +```by +class Fixed[*Ts]: + def f(self) -> tuple[*Ts]: + raise NotImplementedError + +class Two(Fixed): + override def f(self) -> tuple[int, str]: + return (1, "") + +def takes(fixed: Fixed[int, str]): ... + +takes(Two()) +``` + +## a base that cannot be subscripted as the class is made + +a stub can call a class generic that is not subscriptable at runtime: typeshed's `map` is generic, +but `map[int]` raises. the arguments would have to be written in the transpiled header, so they are +not inferred for such a base + +```by +class M(map): # error: [missing-type-argument] + override def __next__(self) -> int: + return 1 +``` + +## a written type argument is used as written + +```by +class A[T]: + x: T + +class B(A[object]): + x = 1 + +def takes(a: A[object]): ... + +takes(B()) +``` + +## only basedpython infers it + +```toml +[environment] +python-version = "3.12" +``` + +a `.py` file keeps python's meaning: a base written without type arguments is inherited at its +defaults, which is `Unknown` for a type parameter with none + +```py +class A[T]: + x: T + + def get(self) -> T: + return self.x + +class B(A): # error: [missing-type-argument] + x = 1 + +reveal_type(B().get()) # revealed: Unknown +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 6f28c12346..a730099b0b 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -67,12 +67,13 @@ use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, NodeIndex}; use ruff_text_size::{Ranged, TextRange}; use ty_python_core::definition::Definition; -use ty_python_core::scope::ScopeId; +use ty_python_core::scope::{FileScopeId, ScopeId}; use ty_python_core::{ProgramFile, place_table, use_def_map}; mod dynamic_literal; mod enum_literal; mod implicit_attributes; +mod inferred_base; mod known; mod named_tuple; mod slots; @@ -3349,7 +3350,21 @@ impl<'db> QualifiedClassName<'db> { /// `m` inside the namespace of a class `C` in the module `a.b` would return /// `["a", "b", "C", ""]`. pub(super) fn components_excluding_self(&self) -> Vec { - let (file, file_scope_id, skip_count) = match self.class { + let (file, file_scope_id, skip_count) = self.enclosing_scope(); + display::qualified_name_components_from_scope(self.db, file, file_scope_id, skip_count) + } + + /// basedpython: [`Self::components_excluding_self`] without the module: the path to this + /// class from the top of its own file. `["C"]` for a class `D` nested in a class `C` + pub(super) fn components_within_file(&self) -> Vec { + let (file, file_scope_id, skip_count) = self.enclosing_scope(); + display::scope_name_components(self.db, file, file_scope_id, skip_count) + } + + /// the scope the class is defined in, and how many scopes to skip to step out of the class + /// itself + fn enclosing_scope(&self) -> (ProgramFile<'db>, FileScopeId, usize) { + match self.class { ClassLiteral::Static(class) => { let body_scope = class.body_scope(self.db); // Skip the class body scope itself. @@ -3377,9 +3392,7 @@ impl<'db> QualifiedClassName<'db> { let scope = enum_lit.scope(self.db); (scope.program_file(self.db), scope.file_scope_id(self.db), 0) } - }; - - display::qualified_name_components_from_scope(self.db, file, file_scope_id, skip_count) + } } } diff --git a/crates/ty_python_semantic/src/types/class/inferred_base.rs b/crates/ty_python_semantic/src/types/class/inferred_base.rs new file mode 100644 index 0000000000..8735c259b2 --- /dev/null +++ b/crates/ty_python_semantic/src/types/class/inferred_base.rs @@ -0,0 +1,491 @@ +//! basedpython: the type arguments a generic base takes from the class that inherits it, when +//! the class writes the base without them +//! +//! ```by +//! class A[T]: +//! x: T +//! +//! class B(A): # inherits `A[int]` +//! x = 1 +//! ``` +//! +//! every member `B` writes itself overrides the one `A` declares, so it has to fit `A`'s +//! declaration. the type arguments `A` is inherited at are the ones that make every override fit, +//! solved the way a call solves a generic function's type parameters from its arguments +//! +//! only what `B` *writes* counts, never what it inherits: an unannotated method parameter takes +//! its type from the method it overrides, so reading it here would make the base depend on itself + +use std::cell::RefCell; + +use ruff_db::parsed::parsed_module; +use ruff_python_ast::{self as ast, name::Name}; +use rustc_hash::FxHashSet; +use ty_python_core::{ + definition::{Definition, DefinitionKind, DefinitionState}, + place_table, semantic_index, use_def_map, +}; + +use crate::place::promote_undeclared; +use crate::types::constraints::ConstraintSetBuilder; +use crate::types::generics::{GenericContext, Specialization, SpecializationBuilder}; +use crate::types::overrides::is_constructor_like_method; +use crate::types::signatures::{ReturnCallableTypeVarScope, Signature}; +use crate::types::{ + BoundTypeVarInstance, StaticClassLiteral, Type, TypeQualifiers, TypeVarVariance, any_over_type, + binding_type, inferred_declaration, +}; +use crate::{Db, ProgramEnvironment}; + +/// the specialization of `base` that `class` inherits when its header writes `base` with no type +/// arguments +/// +/// every type parameter of `base` an override constrains takes what the overrides solve it to, +/// and every other one its default. `None` — the base is inherited as written, and +/// `missing-type-argument` says so where an argument is needed — unless: +/// +/// - `base` can be subscripted as the class is made, since the transpiled class writes the +/// arguments into its header +/// - some override constrains a type parameter, and every type parameter without a default +/// - each solution is a type the class header could write: nothing gradual, and no type variable +/// but the class's own +/// - every override fits the base at that specialization +#[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn inferred_base_specialization<'db>( + db: &'db dyn Db, + class: StaticClassLiteral<'db>, + base: StaticClassLiteral<'db>, +) -> Option> { + let generic_context = base.generic_context(db)?; + let env = &ProgramEnvironment::from_scope(class.body_scope(db)); + if !is_subscriptable_at_runtime(db, env, base) { + return None; + } + + let declaring = base.identity_specialization(db); + let declaring_instance = Type::instance(db, env, declaring); + let declaring_object = Type::from(declaring); + let mentions_base_typevar = |ty: Type<'db>| { + any_over_type(db, env, ty, false, |ty| { + matches!(ty, Type::TypeVar(typevar) + if generic_context.variables(db).any(|variable| variable == unattributed(db, typevar))) + }) + }; + + let mut overrides = Vec::new(); + for written in written_members(db, class) { + match written { + WrittenMember::Attribute { name, definition } => { + // the base is read first, so a member it does not declare in terms of its type + // parameters costs nothing + let member = declaring_instance.member(db, env, &name); + if member.qualifiers.intersects(RENAMED) { + continue; + } + let Some(declared) = member.place.ignore_possibly_undefined() else { + continue; + }; + if !mentions_base_typevar(declared) { + continue; + } + let Some(written) = written_attribute_type(db, env, definition) else { + continue; + }; + // an attribute that can be assigned through the base is invariant: a subclass + // that narrowed it would let a write through the base store a value the + // subclass rules out + let fit = if member + .qualifiers + .intersects(TypeQualifiers::FINAL | TypeQualifiers::READ_ONLY) + { + TypeVarVariance::Covariant + } else { + TypeVarVariance::Invariant + }; + overrides.push(Override { + declared, + written, + fit, + }); + } + WrittenMember::Method { name, definition } => { + let Some(declared) = declaring_object + .member(db, env, &name) + .place + .ignore_possibly_undefined() + .and_then(|declared| unbound_method(db, env, declared)) + else { + continue; + }; + if !declared + .parameters() + .iter() + .any(|parameter| mentions_base_typevar(parameter.annotated_type())) + && !mentions_base_typevar(declared.return_ty) + { + continue; + } + let Some(written) = written_method(db, env, definition) else { + continue; + }; + overrides.extend(method_overrides(&declared, &written)); + } + } + } + if overrides.is_empty() { + return None; + } + + let constraints = ConstraintSetBuilder::new(); + let mut builder = SpecializationBuilder::new(db, env, &constraints, generic_context); + for Override { + declared, + written, + fit, + } in &overrides + { + // a member that cannot fit the declaration at any specialization is the override + // check's to report, against the base as it is written + builder + .infer_with_polarity(*declared, *written, *fit) + .ok()?; + } + let inference = builder.build_inference_with(|_, _| None).ok()?; + let solved = RefCell::new(Vec::new()); + inference.merged_specialization_with(db, |typevar, inferred| { + solved.borrow_mut().push((typevar, inferred)); + None + }); + let solved = solved.into_inner(); + + // the class header can name its own type parameters, `class H[U](A[U])`, and nothing else + // a solution could mention: not a method's type parameter, not the `Self` of a method, not + // an anonymous `some` hole + let own: FxHashSet> = class + .generic_context(db) + .map(|context| context.variables(db).collect()) + .unwrap_or_default(); + let writable = |ty: Type<'db>| { + !ty.has_dynamic(db, env) + && !any_over_type( + db, + env, + ty, + false, + |ty| matches!(ty, Type::TypeVar(typevar) if !own.contains(&unattributed(db, typevar))), + ) + }; + let mut any_solved = false; + for (typevar, inferred) in &solved { + match inferred { + Some(inferred) if writable(*inferred) => any_solved = true, + Some(_) => return None, + None if typevar.default_type(db).is_some() => {} + None => return None, + } + } + if !any_solved { + return None; + } + + let specialization = + generic_context.specialize_recursive(db, solved.iter().map(|(_, inferred)| *inferred)); + overrides + .iter() + .all( + |Override { + declared, + written, + fit, + }| { + let declared = declared.apply_specialization(db, specialization); + match fit { + TypeVarVariance::Invariant => written.is_equivalent_to(db, env, declared), + TypeVarVariance::Contravariant => declared.is_assignable_to(db, env, *written), + TypeVarVariance::Covariant | TypeVarVariance::Bivariant => { + written.is_assignable_to(db, env, declared) + } + } + }, + ) + .then_some(specialization) +} + +/// the type variable `typevar` names: `P` for `P.args` and `P.kwargs`, which are how a callable +/// spells a `ParamSpec` +fn unattributed<'db>( + db: &'db dyn Db, + typevar: BoundTypeVarInstance<'db>, +) -> BoundTypeVarInstance<'db> { + if typevar.paramspec_attr(db).is_some() { + typevar.without_paramspec_attr(db) + } else { + typevar + } +} + +/// a member declared with a visibility keyword is emitted under a name of its own, so it neither +/// overrides nor is overridden by a member written under the plain one +const RENAMED: TypeQualifiers = TypeQualifiers::PRIVATE.union(TypeQualifiers::PROTECTED); + +/// whether `class[...]` can be evaluated as a class header is: the transpiled subclass writes its +/// inferred arguments there +/// +/// a class whose source runs is generic only through the machinery that makes it subscriptable +/// — a type parameter list, `Generic[...]`, or a subscriptable base. a stub describes a class it +/// does not define, and calls `map` generic without `map[int]` evaluating, so a stub's class is +/// subscriptable only where the stub says it has a `__class_getitem__` +fn is_subscriptable_at_runtime<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + class: StaticClassLiteral<'db>, +) -> bool { + !class.file(db).is_stub(db) + || Type::from(class.default_specialization(db)) + .member(db, env, "__class_getitem__") + .place + .ignore_possibly_undefined() + .is_some() +} + +/// one thing an override says about the base's type parameters: `written` has to relate to +/// `declared` the way `fit` says — exactly, as a subtype, or as a supertype +struct Override<'db> { + declared: Type<'db>, + written: Type<'db>, + fit: TypeVarVariance, +} + +/// a member `class` writes in its own body that can override one it inherits, not yet typed +enum WrittenMember<'db> { + /// `x: int`, `let x: int`, or `x = 1` + Attribute { + name: Name, + definition: Definition<'db>, + }, + /// a `def` + Method { + name: Name, + definition: Definition<'db>, + }, +} + +/// every member `class` writes in its own body that can override one it inherits +fn written_members<'db>( + db: &'db dyn Db, + class: StaticClassLiteral<'db>, +) -> Vec> { + let body_scope = class.body_scope(db); + let table = place_table(db, body_scope); + let use_def = use_def_map(db, body_scope); + let mut members = Vec::new(); + let mut declared = FxHashSet::default(); + + for (symbol_id, _) in use_def.all_end_of_scope_symbol_declarations() { + let name = table.symbol(symbol_id).name(); + for declaration in use_def.reachable_symbol_declarations(symbol_id) { + let DefinitionState::Defined(definition) = declaration.declaration else { + continue; + }; + // a declared name's bindings are what its declarations say they are + declared.insert(symbol_id); + if !overrides_by_name(name) { + continue; + } + let name = name.clone(); + match definition.kind(db) { + DefinitionKind::AnnotatedAssignment(_) => { + members.push(WrittenMember::Attribute { name, definition }); + } + DefinitionKind::Function(_) => { + members.push(WrittenMember::Method { name, definition }); + } + _ => {} + } + } + } + + for (symbol_id, bindings) in use_def.all_end_of_scope_symbol_bindings() { + let name = table.symbol(symbol_id).name(); + if declared.contains(&symbol_id) || !overrides_by_name(name) { + continue; + } + for binding in bindings { + let DefinitionState::Defined(definition) = binding.binding else { + continue; + }; + if matches!(definition.kind(db), DefinitionKind::Assignment(_)) { + members.push(WrittenMember::Attribute { + name: name.clone(), + definition, + }); + } + } + } + + members +} + +/// whether a member called `name` overrides the base's member of that name: a constructor is not +/// held to the one it overrides, and a name-mangled `__name` is a different name in every class +fn overrides_by_name(name: &str) -> bool { + let mangled = name.starts_with("__") && !name.ends_with("__"); + !mangled && !is_constructor_like_method(name) +} + +/// the type an attribute's own declaration gives it: its annotation, or for an untyped +/// class-body assignment the promoted type of its value. `None` for a declaration under a +/// visibility keyword, which is emitted under a name of its own +fn written_attribute_type<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + definition: Definition<'db>, +) -> Option> { + match definition.kind(db) { + DefinitionKind::AnnotatedAssignment(_) => { + let declared = inferred_declaration(db, definition).declared()?; + (!declared.qualifiers().intersects(RENAMED)).then(|| declared.inner_type()) + } + DefinitionKind::Assignment(_) => { + Some(promote_undeclared(db, env, binding_type(db, definition))) + } + _ => None, + } +} + +/// the modifiers that leave a `def` an ordinary instance method under its own name. the others +/// either make it something else (`static`) or rename it (`private`, `protected`) +const PLAIN_METHOD_MODIFIERS: &[&str] = &["final", "abstract", "override", "open", "export"]; + +/// a `def` in the class body with its receiver bound away, typed from what it writes itself +/// +/// a decorator makes the member whatever the decorator returns, which only inferring the decorator +/// says, so a decorated `def` is not read here +fn written_method<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + definition: Definition<'db>, +) -> Option> { + let DefinitionKind::Function(function) = definition.kind(db) else { + return None; + }; + let module = parsed_module(db, definition.program_file(db).python_file(db)).load(db); + let node = function.node(&module); + let plain = node.decorator_list.iter().all(|decorator| { + matches!(&decorator.expression, ast::Expr::Name(marker) + if marker.is_invalid() && PLAIN_METHOD_MODIFIERS.contains(&marker.id.as_str())) + }); + if !plain { + return None; + } + // a parameter list that repeats `_` is renamed by a lowering this does not run, so the + // names it would compare by are not the ones the method has + let names: Vec<_> = node + .parameters + .iter() + .map(ast::AnyParameterRef::name) + .collect(); + if names + .iter() + .enumerate() + .any(|(i, name)| names[..i].contains(name)) + { + return None; + } + + let index = semantic_index(db, definition.program_file(db)); + let generic_context = node + .type_params + .as_ref() + .map(|type_params| GenericContext::from_type_params(db, index, definition, type_params)); + // the receiver is bound away below, so whether it is positional-only changes nothing + let signature = Signature::from_function( + db, + env, + generic_context, + definition, + node, + true, + ReturnCallableTypeVarScope::Public, + None, + ); + Some(WrittenSignature { + signature: signature.bind_self(db, env, None), + writes_return: node.returns.is_some() || node.is_asserts_return, + }) +} + +/// a method's signature, typed from what it writes itself +struct WrittenSignature<'db> { + signature: Signature<'db>, + /// whether the `def` writes its return type. one it leaves out is read off its body, which is + /// typed in terms of the very base being inferred + writes_return: bool, +} + +/// a method the base declares, read off its class object, with the receiver bound away the same +/// way [`written_method`] binds it +fn unbound_method<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + declared: Type<'db>, +) -> Option> { + let Type::FunctionLiteral(function) = declared else { + return None; + }; + if function.is_classmethod(db) || function.is_staticmethod(db) { + return None; + } + let [signature] = function.signature(db).overloads.as_slice() else { + return None; + }; + Some(signature.bind_self(db, env, None)) +} + +/// what overriding `declared` with `written` says about the base's type parameters +/// +/// parameters correspond the way a call binds arguments to them: positional parameters by +/// position and keyword-only ones by name, while a `*args` or `**kwargs` stands for a run of +/// arguments that nothing corresponds to. a parameter the override leaves unannotated takes its +/// type from the one it overrides, so it says nothing, and neither does a return type it leaves out +fn method_overrides<'db>( + declared: &Signature<'db>, + written: &WrittenSignature<'db>, +) -> Vec> { + let mut overrides = Vec::new(); + if declared.parameters().is_standard() { + let mut declared_positional = declared + .parameters() + .iter() + .take_while(|parameter| parameter.is_positional()); + for parameter in written.signature.parameters() { + let corresponding = if parameter.is_positional() { + declared_positional.next() + } else if parameter.is_keyword_only() { + declared.parameters().iter().find(|candidate| { + candidate.is_keyword_only() && candidate.name() == parameter.name() + }) + } else { + None + }; + if parameter.inferred_annotation { + continue; + } + if let Some(corresponding) = corresponding { + overrides.push(Override { + declared: corresponding.annotated_type(), + written: parameter.annotated_type(), + fit: TypeVarVariance::Contravariant, + }); + } + } + } + if written.writes_return { + overrides.push(Override { + declared: declared.return_ty, + written: written.signature.return_ty, + fit: TypeVarVariance::Covariant, + }); + } + overrides +} diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index b653a36a2b..8e6a454d24 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -14,6 +14,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use std::cell::RefCell; use super::implicit_attributes::implicit_attribute_names; +use super::inferred_base::inferred_base_specialization; use crate::{ Db, FxIndexMap, FxIndexSet, TypeQualifiers, place::{ @@ -801,10 +802,24 @@ impl<'db> StaticClassLiteral<'db> { let class_definition = semantic_index(db, program_file).expect_single_definition(class_stmt); + let is_basedpython = class.file(db).source_type(db).is_basedpython(); let mut bases: Vec> = expanded_class_base_entries(db, class.known(db), class_stmt, class_definition) .into_iter() .map(ExpandedClassBaseEntry::ty) + .map(|base| { + // basedpython: a generic base written without type arguments is + // inherited at the ones the class body's overrides solve for + if is_basedpython + && let Type::ClassLiteral(ClassLiteral::Static(literal)) = base + && let Some(specialization) = + inferred_base_specialization(db, class, literal) + { + Type::from(literal.apply_specialization(db, |_| specialization)) + } else { + base + } + }) .collect(); // basedpython `enum class Foo` and `protocol Foo` parse to a class with a diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index b8b33280cf..41c76ad0d9 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -571,6 +571,9 @@ impl std::ops::DerefMut for TypeDetailGuard<'_, '_, '_, '_> { enum QualificationLevel { ModuleName, FileAndLineNumber, + /// basedpython: the class's path inside its own file, `Outer.Inner`, for a spelling that is + /// written in that same file + WithinFile, } impl QualificationLevel { @@ -979,6 +982,10 @@ pub struct SourceSpelling { /// the names of the classes [`Self::text`] writes bare, each read nowhere in it but as /// that class pub bare_classes: Vec, + /// whether [`Self::text`] reaches a class nested in another class of the same file through + /// the outer one, `Outer.Inner`. such a path names the outer class, which is unbound while + /// it is being made, so it resolves only where it is not evaluated as it is written + pub reads_nested_class: bool, } /// Collects every class a type names, so each can be spelled for a particular file. @@ -1064,9 +1071,27 @@ impl<'db> Type<'db> { let classes = collector.classes.borrow(); let mut qualified = FxHashMap::default(); + let mut reads_nested_class = false; for class in classes.iter().copied() { let name = class.name(db); - if !file_binds_class(db, env, file, name, class) { + if file_binds_class(db, env, file, name, class) { + continue; + } + // a class of this very file that the file does not bind by name is nested in + // another of its classes, and is reached through it rather than by importing the + // module the spelling is written in + if class.file(db) == file.file(db) { + if !class + .qualified_name(db) + .components_within_file() + .iter() + .all(|component| ruff_python_stdlib::identifiers::is_identifier(component)) + { + return None; + } + reads_nested_class = true; + qualified.insert(&**name, QualificationLevel::WithinFile); + } else { qualified.insert(&**name, QualificationLevel::ModuleName); } } @@ -1075,7 +1100,10 @@ impl<'db> Type<'db> { // its module just as much let mut modules = std::collections::BTreeSet::new(); for class in classes.iter().copied() { - if !qualified.contains_key(&**class.name(db)) { + if !matches!( + qualified.get(&**class.name(db)), + Some(QualificationLevel::ModuleName) + ) { continue; } let module = importable_module_of(db, env, class)?; @@ -1135,6 +1163,7 @@ impl<'db> Type<'db> { text: self.display_with(db, env, settings).to_string(), modules: modules.into_iter().collect(), bare_classes, + reads_nested_class, }) } } @@ -1243,6 +1272,21 @@ pub(super) fn qualified_name_components_from_scope( file: ProgramFile<'_>, file_scope_id: FileScopeId, skip_count: usize, +) -> Vec { + let mut name_parts = scope_name_components(db, file, file_scope_id, skip_count); + if let Some(module) = file_to_module(db, file.resolver_file(db)) { + name_parts.insert(0, public_module_name(db, &module).to_string()); + } + name_parts +} + +/// basedpython: the components of a name inside its own file, outermost first: each class and +/// function it is nested in, without the module the file is +pub(super) fn scope_name_components( + db: &dyn Db, + file: ProgramFile<'_>, + file_scope_id: FileScopeId, + skip_count: usize, ) -> Vec { let module_ast = parsed_module(db, file.python_file(db)).load(db); let index = semantic_index(db, file); @@ -1270,10 +1314,6 @@ pub(super) fn qualified_name_components_from_scope( } } - if let Some(module) = file_to_module(db, file.resolver_file(db)) { - name_parts.push(public_module_name(db, &module).to_string()); - } - name_parts.reverse(); name_parts } @@ -1342,11 +1382,19 @@ impl<'db> FmtDetailed<'db> for ClassDisplay<'_, 'db> { let qualification_level = self.settings.qualified.get(&**self.class.name(self.db)); let ty = Type::ClassLiteral(self.class); - if qualification_level.is_some() { - let qualified_name = self.class.qualified_name(self.db); - write!(f.with_type(ty), "{qualified_name}")?; - } else { - write!(f.with_type(ty), "{}", self.class.name(self.db))?; + match qualification_level { + Some(QualificationLevel::WithinFile) => { + let mut f = f.with_type(ty); + for parent in self.class.qualified_name(self.db).components_within_file() { + write!(f, "{parent}.")?; + } + f.write_str(self.class.name(self.db))?; + } + Some(_) => { + let qualified_name = self.class.qualified_name(self.db); + write!(f.with_type(ty), "{qualified_name}")?; + } + None => write!(f.with_type(ty), "{}", self.class.name(self.db))?, } if qualification_level == Some(&QualificationLevel::FileAndLineNumber) { diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 8d0b8a66f6..13e0c31b1b 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -4471,6 +4471,18 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { ) } + /// [`Self::infer`] for an `actual` that sits in a position of variance `polarity`: a covariant + /// position bounds the formal's type variables from below, a contravariant one from above, + /// and an invariant one exactly + pub(crate) fn infer_with_polarity( + &mut self, + formal: Type<'db>, + actual: Type<'db>, + polarity: TypeVarVariance, + ) -> Result<(), SpecializationError<'db>> { + self.infer_map_impl(formal, actual, polarity, &mut FxHashSet::default()) + } + fn infer_map_impl( &mut self, formal: Type<'db>, diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index a282cd21bb..d920869b8d 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -9,7 +9,8 @@ use crate::types::EnumLiteralType; use crate::types::call::bind::CheckTypesMode; use crate::types::call::{CallArguments, CallError, MatchedArgument}; use crate::types::class::{ - DynamicClassAnchor, DynamicEnumAnchor, DynamicNamedTupleAnchor, type_as_abstract_method, + DynamicClassAnchor, DynamicEnumAnchor, DynamicNamedTupleAnchor, expanded_class_base_entries, + type_as_abstract_method, }; use crate::types::constraints::ConstraintSetBuilder; use crate::types::context_sensitive::for_each_candidate; @@ -3417,6 +3418,74 @@ pub fn inherited_parameter_annotation<'db>( (!ty.is_unknown() && !ty.is_inferred_parameter_hole(db)).then_some(ty) } +/// basedpython: one argument of a base's subscript as a class header writes it +#[derive(Debug, Clone)] +pub enum BaseTypeArgument<'db> { + /// the argument of an ordinary type parameter, `int` in `A[int]` + Type(Type<'db>), + /// the argument of a `*Ts` that is no fixed number of types, written unpacked: + /// `A[*tuple[int, ...]]` + Unpacked(Type<'db>), +} + +/// basedpython: the type arguments each generic base `class_def` writes without them is inherited +/// at, solved from the members the class body overrides — `class B(A): x = 1` inherits `A[int]` +/// +/// each entry is the base expression's range and the arguments as the subscript writes them, in +/// the order the base declares its type parameters. a `*Ts` that is a fixed number of types is +/// written as those types, one argument each. a base written with its arguments, or whose +/// arguments the body does not solve, has no entry +pub fn inferred_base_type_arguments<'db>( + model: &SemanticModel<'db>, + class_def: &ast::StmtClassDef, +) -> Vec<(TextRange, Vec>)> { + let db = model.db(); + let index = semantic_index(db, db.program_file(model.file())); + let definition = index.expect_single_definition(class_def); + let Some(ClassLiteral::Static(class)) = original_class_type(db, definition) else { + return Vec::new(); + }; + let inherited = class.explicit_bases(db); + expanded_class_base_entries(db, class.known(db), class_def, definition) + .into_iter() + .zip(inherited) + .filter_map(|(entry, inherited)| { + if entry.ty() == *inherited { + return None; + } + let Type::GenericAlias(alias) = inherited else { + return None; + }; + let specialization = alias.specialization(db); + let mut arguments = Vec::new(); + for (typevar, argument) in specialization + .generic_context(db) + .variables(db) + .zip(specialization.types(db)) + { + if !typevar.is_typevartuple(db) { + arguments.push(BaseTypeArgument::Type(*argument)); + continue; + } + match argument + .exact_tuple_instance_spec(db) + .as_deref() + .and_then(|spec| spec.as_fixed_length().cloned()) + { + Some(fixed) => arguments.extend( + fixed + .all_elements() + .iter() + .map(|element| BaseTypeArgument::Type(*element)), + ), + None => arguments.push(BaseTypeArgument::Unpacked(*argument)), + } + } + Some((entry.source_node().range(), arguments)) + }) + .collect() +} + /// basedpython: the bound an unannotated parameter of a `def` takes from what its body does with /// it — the `some` hole an inferred signature opens for it /// diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 0ccd78894b..1b55ede314 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -342,7 +342,15 @@ pub(crate) fn check_static_class_definitions<'db>( for (i, entry) in expanded_base_entries.iter().enumerate() { let source_node = entry.source_node(); let base_class = entry.ty(); - report_missing_type_arguments(context, base_class, source_node); + // basedpython: a base whose type arguments the class body solves is inherited + // specialized, and the bases the class sees are exactly its expanded entries + let arguments_inferred = class + .explicit_bases(db) + .get(i) + .is_some_and(|inherited| *inherited != base_class); + if !arguments_inferred { + report_missing_type_arguments(context, base_class, source_node); + } if class_kind == Some(CodeGeneratorKind::NamedTuple) && !matches!( diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index cdc66b68e1..e1e10a02dc 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -423,6 +423,7 @@ pub struct InlayHintOptions { inherited_parameter_defaults: Option, inferred_return_types: Option, inferred_parameter_types: Option, + inferred_base_type_arguments: Option, property_types: Option, implicit_arguments: Option, enum_values: Option, @@ -454,6 +455,7 @@ impl InlayHintOptions { inherited_parameter_defaults: self.inherited_parameter_defaults.unwrap_or(true), inferred_return_types: self.inferred_return_types.unwrap_or(true), inferred_parameter_types: self.inferred_parameter_types.unwrap_or(true), + inferred_base_type_arguments: self.inferred_base_type_arguments.unwrap_or(true), property_types: self.property_types.unwrap_or(true), implicit_arguments: self.implicit_arguments.unwrap_or(true), enum_values: self.enum_values.unwrap_or(true), 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 6c868baaa9..0984216ac3 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 @@ -38,6 +38,7 @@ Settings: WorkspaceSettings { inherited_parameter_defaults: true, inferred_return_types: true, inferred_parameter_types: true, + inferred_base_type_arguments: true, property_types: true, implicit_arguments: true, enum_values: true, diff --git a/docs/basedpython/features/editor.md b/docs/basedpython/features/editor.md index 2012b12b0f..dce6b83e40 100644 --- a/docs/basedpython/features/editor.md +++ b/docs/basedpython/features/editor.md @@ -224,6 +224,7 @@ on | `inheritedParameterDefaults` | the [default](inherited-defaults.md) it takes from that method | | `inferredReturnTypes` | the return type of a `def` that leaves it out | | `inferredParameterTypes` | the `some` bound an unannotated parameter takes from its body | +| `inferredBaseTypeArguments` | the type arguments a generic base takes from its overrides | | `propertyTypes` | the type a [property](properties.md) leaves to its accessors | | `implicitArguments` | the [context arguments](context-parameters.md) a call fills | | `enumValues` | the value an [enum](enums.md) member takes implicitly | diff --git a/docs/basedpython/features/index.md b/docs/basedpython/features/index.md index 13742d5252..e25d7a63c8 100644 --- a/docs/basedpython/features/index.md +++ b/docs/basedpython/features/index.md @@ -164,6 +164,7 @@ the forms a class, function or binding can take - [decorated function parameters](decorated-parameters.md) - [decorating anything](decorate-anything.md) - [inherited default values](inherited-defaults.md) +- [inferred base type arguments](inferred-base-arguments.md) diff --git a/docs/basedpython/features/inferred-base-arguments.md b/docs/basedpython/features/inferred-base-arguments.md new file mode 100644 index 0000000000..5eab552f2a --- /dev/null +++ b/docs/basedpython/features/inferred-base-arguments.md @@ -0,0 +1,98 @@ +# inferred base type arguments + +a generic base written without type arguments takes them from the members the +class overrides: + +```by +class Box[T]: + item: T + + def get(self) -> T: + return self.item + +class IntBox(Box): + item = 1 + +reveal_type(IntBox().get()) # int +``` + +transpiles to: + +```python +class Box[T]: + item: T + + def get(self) -> T: + return self.item + +class IntBox(Box[int]): + item: int = 1 + +reveal_type(IntBox().get()) +``` + +every member a class writes overrides the one its base declares, so it has to +fit the base's declaration. the type arguments the base is inherited at are the +ones that make every override fit, solved the way a call solves a generic +function's type parameters from its arguments + +## what counts + +an attribute counts by its annotation, or by the type its value declares — +`item = 1` declares `item: int`. an attribute that can be assigned through the +base has to match its declaration exactly, since a write through the base could +otherwise store a value the subclass rules out + +a method counts by the types it writes itself, its annotated parameters and its +annotated return type: + +```by +class Store[K, V]: + def get(self, key: K) -> V: + raise NotImplementedError + +class Names(Store): # Store[int, str] + override def get(self, key: int) -> str: + return "" +``` + +what a method leaves out says nothing about the base: an unannotated parameter +takes its type from the method it overrides, and a return type it does not +write is read off its body, which is typed in terms of the base being solved. +a decorated method, a constructor, a name-mangled `__name` and a member under a +visibility keyword do not count either + +a type parameter with a default takes what the overrides solve it to, and its +default when none of them names it + +## when the arguments are not inferred + +the base needs its type arguments written, and `missing-type-argument` says so, +when: + +- a type parameter without a default is named by no override + +- no one specialization fits every override: + + ```by + class Mixed(Box): # error: missing type argument + item = 1 + + override def get(self) -> str: # `item` asks for `Box[int]` + return "" + ``` + +- an override is typed by something the class header cannot name — a method's + `Self`, a method's own type parameter, an anonymous `some` parameter — or by + the class being defined, whose base is what is being solved + +- the base cannot be subscripted as the class is made: a stub can call a class + generic that is not, like `map` + +a class's own type parameters can be named, so `class H[U](Box): item: U` +inherits `Box[U]`. type arguments written in the header are used as written + +## editor support + +the inferred arguments are shown as an inlay hint after the base, under the +`inferredBaseTypeArguments` setting diff --git a/zensical.toml b/zensical.toml index 4d52ff6ea5..2db307f10f 100644 --- a/zensical.toml +++ b/zensical.toml @@ -173,6 +173,7 @@ features = [ { "decorated function parameters" = "features/decorated-parameters.md" }, { "decorating anything" = "features/decorate-anything.md" }, { "inherited default values" = "features/inherited-defaults.md" }, + { "inferred base type arguments" = "features/inferred-base-arguments.md" }, ] }, { "expressions and statements" = [ { "context-sensitive resolution" = "features/context-sensitive-resolution.md" }, From 7c82e1f415b4ee0bc05f893824510d58ebd4657f Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:24:15 +1000 Subject: [PATCH 7/8] lower an extension member decorated @classmethod, @staticmethod or @property to a plain backing function, so calling it no longer raises TypeError --- .../by_transforms/src/transforms/extension.rs | 19 ++-- crates/by_transforms/src/type_info.rs | 31 ++++++ .../tests/extension_receiver_runtime.rs | 97 +++++++++++++++++++ crates/ty_python_semantic/src/lib.rs | 4 +- .../src/types/extensions.rs | 30 ++++++ 5 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 crates/by_transforms/tests/extension_receiver_runtime.rs diff --git a/crates/by_transforms/src/transforms/extension.rs b/crates/by_transforms/src/transforms/extension.rs index 0da20d51ac..804fe07907 100644 --- a/crates/by_transforms/src/transforms/extension.rs +++ b/crates/by_transforms/src/transforms/extension.rs @@ -269,15 +269,18 @@ impl<'a> ExtensionBlockPass<'a> { } first_member = false; - let kind = member_kind(func, source); - // real decorators other than `property` are kept (dedented to the - // module level); `property` and the synthetic modifier markers are - // consumed by the lowering itself + // the kind every call of the member was resolved as, so the backing function takes + // the receiver those calls pass it + let kind = types + .extension_member_kind(class, func.name.as_str()) + .unwrap_or_else(|| member_kind(func, source)); + // real decorators are kept (dedented to the module level), except one that decides + // how the member binds its receiver: the backing function is called with the + // receiver already chosen, so `@classmethod` there would make it uncallable. those + // and the synthetic modifier markers are consumed by the lowering itself for decorator in &func.decorator_list { - if is_synthetic_decorator(source, decorator) { - continue; - } - if matches!(&decorator.expression, Expr::Name(name) if name.id.as_str() == "property") + if is_synthetic_decorator(source, decorator) + || types.decides_receiver_binding(decorator) { continue; } diff --git a/crates/by_transforms/src/type_info.rs b/crates/by_transforms/src/type_info.rs index ac0c0227cd..2205486c40 100644 --- a/crates/by_transforms/src/type_info.rs +++ b/crates/by_transforms/src/type_info.rs @@ -783,6 +783,19 @@ pub(crate) trait TypeInfo { /// `class_def` is not an extension fn extension_backing_function(&self, class_def: &StmtClassDef, member: &str) -> Option; + /// basedpython: what kind of member `member` of the extension `class_def` declares is — the + /// kind every call of it is resolved as. `None` when `class_def` is not an extension + fn extension_member_kind( + &self, + class_def: &StmtClassDef, + member: &str, + ) -> Option; + + /// whether `decorator` is `classmethod`, `staticmethod` or `property` — one that decides how a + /// member binds its receiver rather than wrapping what it does, under whatever name it is + /// written + fn decides_receiver_binding(&self, decorator: &ruff_python_ast::Decorator) -> bool; + /// whether `expr` is a field-specifier call — `pydantic.Field(...)`, /// `dataclasses.field(...)`, `attrs.field(...)`, and the like. the checker /// models these as assignable to the field's declared type, but their @@ -1637,6 +1650,24 @@ impl TypeInfo for SemanticModel<'_> { ty_python_semantic::extension_backing_function(self.db(), class, member) } + fn extension_member_kind( + &self, + class_def: &StmtClassDef, + member: &str, + ) -> Option { + let class = class_def.inferred_type(self)?.as_class_literal()?; + ty_python_semantic::extension_member_kind(self.db(), class, member) + } + + fn decides_receiver_binding(&self, decorator: &ruff_python_ast::Decorator) -> bool { + decorator + .expression + .inferred_type(self) + .is_some_and(|decorator| { + ty_python_semantic::decides_receiver_binding(self.db(), decorator) + }) + } + fn static_resource(&self, path: &str, binding: &str) -> Result { let file = ty_python_semantic::resolve_static_resource(self.db(), self.file(), path) .map_err(|error| error.to_string())?; diff --git a/crates/by_transforms/tests/extension_receiver_runtime.rs b/crates/by_transforms/tests/extension_receiver_runtime.rs new file mode 100644 index 0000000000..4bdb4c8bfa --- /dev/null +++ b/crates/by_transforms/tests/extension_receiver_runtime.rs @@ -0,0 +1,97 @@ +//! runtime test for how an extension member receives its receiver +//! +//! an extension member lowers to a module-level backing function, and every call of it is +//! rewritten to call that function with the receiver already chosen: the instance for a method +//! or a property, the class for a class method or a class-level property, nothing for a static +//! method. a member made a class method, a static method or a property by a decorator — written +//! `@classmethod`, `@staticmethod`, `@property`, as well as through the `class def`, `static def` +//! and accessor-block spellings — must lower to a plain function for those calls to reach it: +//! left on the backing function, `@classmethod` makes it a `classmethod` object, which python +//! cannot call +//! +//! no third-party packages are needed, so any `python3` will do; if none is found the test skips +//! rather than fails + +use std::process::Command; + +use by_transforms::{Config, PythonVersion, transpile}; + +mod common; +use common::python; + +const PROGRAM: &str = r##" +class Widget: ... +class Sub(Widget): ... + +extension Widget: + class def made(cls): + return cls.__name__ + + @classmethod + def decorated_made(cls): + return cls.__name__ + + static def built(): + return "built" + + @staticmethod + def decorated_built(): + return "decorated built" + + @property + def label(self): + return type(self).__name__ + + def named(self): + return type(self).__name__ + +# a class method receives the class it is reached through, from the class or an instance +assert Sub.made() == "Sub", "`class def` through the class" +assert Sub().made() == "Sub", "`class def` through an instance" +assert Sub.decorated_made() == "Sub", "`@classmethod` through the class" +assert Sub().decorated_made() == "Sub", "`@classmethod` through an instance" + +# a static method receives nothing +assert Widget.built() == "built", "`static def` through the class" +assert Sub().built() == "built", "`static def` through an instance" +assert Widget.decorated_built() == "decorated built", "`@staticmethod` through the class" +assert Sub().decorated_built() == "decorated built", "`@staticmethod` through an instance" + +# a property and a method receive the instance +assert Sub().label == "Sub", "`@property` reads off the instance" +assert Sub().named() == "Sub", "a method is called on the instance" + +print("ok") +"##; + +#[test] +#[expect( + clippy::print_stderr, + reason = "a skipped test must say why it skipped, or it reads as a pass" +)] +fn extension_members_receive_their_receiver() { + let Some(python) = python() else { + eprintln!("skipping extension receiver runtime test: no `python3` interpreter found"); + return; + }; + + let config = Config { + min_version: PythonVersion::PY313, + ..Config::default() + }; + let transpiled = transpile(PROGRAM, &config).expect("transpile should succeed"); + + let output = Command::new(&python) + .arg("-c") + .arg(&transpiled) + .output() + .expect("failed to spawn python"); + + assert!( + output.status.success(), + "transpiled extension program failed on {python}:\n--- stdout ---\n{}\n--- stderr ---\n{}\n--- transpiled ---\n{transpiled}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ok"); +} diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 6351935f88..141e755c09 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -52,8 +52,8 @@ pub use types::conversions::{ ConversionImport, ConversionInfo, ConversionRuntime, DISCARD_ADAPTER, }; pub use types::extensions::{ - ExtensionAttributeInfo, ExtensionMemberKind, extension_backing_function, - extension_backing_functions, + ExtensionAttributeInfo, ExtensionMemberKind, decides_receiver_binding, + extension_backing_function, extension_backing_functions, extension_member_kind, }; pub use types::ide_support::{ ImplementationsFinder, ImportAliasResolution, OverridableMember, ResolvedDefinition, diff --git a/crates/ty_python_semantic/src/types/extensions.rs b/crates/ty_python_semantic/src/types/extensions.rs index 23093d9ccd..7f5ca2e7f4 100644 --- a/crates/ty_python_semantic/src/types/extensions.rs +++ b/crates/ty_python_semantic/src/types/extensions.rs @@ -521,6 +521,36 @@ pub fn extension_backing_function<'db>( .then(|| backing_function_name(db, extension, member)) } +/// what kind of member `member` of `extension` is — the same kind every call of it is resolved +/// as — or `None` when `extension` is not an extension or declares no such member +pub fn extension_member_kind<'db>( + db: &'db dyn Db, + extension: ClassLiteral<'db>, + member: &str, +) -> Option { + let ClassLiteral::Static(extension) = extension else { + return None; + }; + if !extension.is_extension(db) { + return None; + } + Some(classify_member(db, own_member(db, extension, member)?)) +} + +/// whether a decorator of type `decorator` is `classmethod`, `staticmethod` or `property`: one of +/// the decorators that decide which of [`ExtensionMemberKind`]'s kinds a member is +pub fn decides_receiver_binding<'db>(db: &'db dyn Db, decorator: Type<'db>) -> bool { + decorator + .as_class_literal() + .and_then(|class| class.known(db)) + .is_some_and(|known| { + matches!( + known, + KnownClass::Classmethod | KnownClass::Staticmethod | KnownClass::Property + ) + }) +} + /// a successful extension-member resolution #[derive(Debug, Clone, Copy)] pub(crate) struct ExtensionMemberResolution<'db> { From 4eeff6f82fd1cdc53603ec234105750a9234916b Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:08:01 +1000 Subject: [PATCH 8/8] report a lowered t-string field's text with the whitespace before the end of the field, as python 3.14.8 does, and drop it at runtime on 3.14.0 to 3.14.7, which did not --- .../by_transforms/src/runtime/_by_runtime.py | 9 ++++ .../src/transforms/template_expression.rs | 48 ++++++++++--------- .../tests/tstring_expression_runtime.rs | 33 ++++++++----- 3 files changed, 56 insertions(+), 34 deletions(-) diff --git a/crates/by_transforms/src/runtime/_by_runtime.py b/crates/by_transforms/src/runtime/_by_runtime.py index 60be12911e..f671a46f46 100644 --- a/crates/by_transforms/src/runtime/_by_runtime.py +++ b/crates/by_transforms/src/runtime/_by_runtime.py @@ -396,17 +396,26 @@ def __iter__(self): # `None` a field nothing lowered, which keeps the interpolation python built and so # stays byte-identical to what python would have reported # +# the author's text keeps the whitespace before the `=`, conversion, format spec or +# `}` that ends the field, which is what python reports from 3.14.8 on. 3.14.0 to +# 3.14.7 dropped it (python/cpython#154719), so there it is dropped here too, or a +# lowered field would report different text from an untouched field beside it +# # the import is made here rather than at the top of the file because this module is # shared with runtimes that have no `string.templatelib`. only a t-string can reach # this helper, and a t-string needs 3.14 def _by_template(_t, _exprs): from string.templatelib import Interpolation, Template + from sys import version_info + _drops_trailing_space = version_info < (3, 14, 8) _strings = _t.strings _parts = [] for _index, _field in enumerate(_t.interpolations): _parts.append(_strings[_index]) _expr = _exprs[_index] + if _expr is not None and _drops_trailing_space: + _expr = _expr.rstrip() _parts.append( _field if _expr is None diff --git a/crates/by_transforms/src/transforms/template_expression.rs b/crates/by_transforms/src/transforms/template_expression.rs index 3300e4c3e3..0bcb378d72 100644 --- a/crates/by_transforms/src/transforms/template_expression.rs +++ b/crates/by_transforms/src/transforms/template_expression.rs @@ -2,14 +2,18 @@ //! //! python 3.14 gives every replacement field of a `t"..."` to the program as a //! `string.templatelib.Interpolation`, whose `expression` attribute is the *source text* -//! of the field: everything between the `{` and the end of the expression, its grouping -//! parentheses kept and its trailing whitespace dropped. +//! of the field: everything between the `{` and the `=`, conversion, format spec or `}` +//! that ends it, whitespace and grouping parentheses included. //! //! ```python //! a = 3 -//! t"{ (a) !r}".interpolations[0].expression # ' (a)' +//! t"{ (a) !r}".interpolations[0].expression # ' (a) ' //! ``` //! +//! python 3.14.0 to 3.14.7 dropped the whitespace before that end (python/cpython#154719). +//! the lowering writes the text the way later releases report it, and [`_by_template`] +//! drops the trailing whitespace again when it runs on one of those releases +//! //! that text is what the attribute is for — a template library names a cache entry by it, //! shows it in an error, or renders a `=`-style debug view from it. python reads it off //! the file it compiled, which here is the lowered one, so `t"{a!}"` handed a reader @@ -153,26 +157,24 @@ impl Wrap { /// the source text python hands a reader as a field's `expression` /// -/// python takes it from just past the `{` to the end of the expression, keeping whatever -/// grouping parentheses the expression was written inside and dropping the whitespace -/// after them. the expression node's own range holds neither, so both ends are read off -/// the field: the start from its brace, and the end by stepping over the parentheses that -/// close between the expression's last token and the `=`, conversion, format spec or `}` -/// that ends the field — nothing else can stand there +/// python takes it from just past the `{` to the `=`, conversion, format spec or `}` that +/// ends the field, keeping whatever grouping parentheses and whitespace stand around the +/// expression. the expression node's own range holds neither, so both ends are read off +/// the field: the start from its brace, and the end by stepping over the parentheses and +/// whitespace between the expression's last token and the end of the field — nothing +/// else can stand there +/// +/// this is the text python reports from 3.14.8 on; [`_by_template`](crate::runtime) drops +/// the trailing whitespace on the earlier 3.14 releases, which did pub(crate) fn author_text<'src>(source: &'src str, field: &InterpolatedElement) -> &'src str { let start = usize::from(field.range().start()) + '{'.len_utf8(); let expression_end = usize::from(field.expression.range().end()); let Some(tail) = source.get(expression_end..usize::from(field.range().end())) else { return ""; }; - let mut closing = 0; - for (offset, ch) in tail.char_indices() { - match ch { - ')' => closing = offset + ch.len_utf8(), - ch if ch.is_whitespace() => {} - _ => break, - } - } + let closing = tail + .find(|ch: char| ch != ')' && !ch.is_whitespace()) + .unwrap_or(tail.len()); source .get(start..expression_end + closing) .unwrap_or_default() @@ -491,7 +493,7 @@ mod tests { fn a_debug_field_reports_the_author_text() { assert_eq!( lowered("def go(a: int?):\n print(t\"{(a!) = }\")\n"), - "print(_by_template(t\"(a!) = {(_force_unwrap(a))!r}\", (\"(a!)\",)))" + "print(_by_template(t\"(a!) = {(_force_unwrap(a))!r}\", (\"(a!) \",)))" ); } @@ -505,19 +507,19 @@ mod tests { ); } - /// python reads the text from just past the `{` to the end of the expression, keeping - /// the grouping parentheses and dropping the whitespace after them. `fields_carry_the_ - /// text_python_would_have_reported` in the runtime test holds these to a real 3.14 + /// python reads the text from just past the `{` to the end of the field, keeping the + /// grouping parentheses and the whitespace around them. `tstring_expression_runtime` + /// holds these to a real 3.14 #[test] fn the_author_text_is_the_span_python_would_have_reported() { assert_eq!( lowered("def go(a: int?):\n print(t\"{ (a!) }\")\n"), - "print(_by_template(t\"{ (_force_unwrap(a)) }\", (\" (a!)\",)))" + "print(_by_template(t\"{ (_force_unwrap(a)) }\", (\" (a!) \",)))" ); // a conversion and a format spec end the text the same way the closing brace does assert_eq!( lowered("def go(a: int?):\n print(t\"{ (a!) !r:>6}\")\n"), - "print(_by_template(t\"{ (_force_unwrap(a)) !r:>6}\", (\" (a!)\",)))" + "print(_by_template(t\"{ (_force_unwrap(a)) !r:>6}\", (\" (a!) \",)))" ); } diff --git a/crates/by_transforms/tests/tstring_expression_runtime.rs b/crates/by_transforms/tests/tstring_expression_runtime.rs index f3051a3043..79f2eeeb54 100644 --- a/crates/by_transforms/tests/tstring_expression_runtime.rs +++ b/crates/by_transforms/tests/tstring_expression_runtime.rs @@ -5,9 +5,11 @@ //! that field, so the text a lowered field reports is a claim about the *source*, //! not about the lowering. Only an interpreter can settle it: the transform's unit //! tests can say what was emitted, but not what python makes of it — and the exact -//! span python reports (its leading whitespace, its grouping parentheses, its -//! dropped trailing whitespace) is python's own rule, which is easy to state -//! wrongly from the outside. +//! span python reports (its whitespace, its grouping parentheses) is python's own +//! rule, which is easy to state wrongly from the outside — and which changed in +//! 3.14.8, when python stopped dropping the whitespace before the end of a field +//! (python/cpython#154719). Both programs write the text as 3.14.8 reports it, and +//! `reported` drops that whitespace again on an earlier 3.14. //! //! Every assertion here is written twice over: the basedpython program asserts what //! the field reports, and [`PYTHON_REFERENCE`] asserts that the same expectation @@ -23,8 +25,12 @@ use interpreters::Interpreter; /// basedpython whose t-string fields hold expressions that lower const PROGRAM: &str = r#" +import sys from string.templatelib import Template +def reported(text: str) -> str: + return text if sys.version_info >= (3, 14, 8) else text.rstrip() + def show(a: int?, b: int, d: dict[str, int]) -> None: def text(t) -> list: return [i.expression for i in t.interpolations] @@ -38,18 +44,18 @@ def show(a: int?, b: int, d: dict[str, int]) -> None: # `typeof`, which is folded in the syntax tree rather than emitted as an edit assert text(t"{typeof(b)}") == ["typeof(b)"], text(t"{typeof(b)}") # the author's own spacing and grouping parentheses, which python keeps - assert text(t"{ (a!) }") == [" (a!)"], text(t"{ (a!) }") + assert text(t"{ (a!) }") == [reported(" (a!) ")], text(t"{ (a!) }") # a conversion and a format spec end the reported text where the brace would - assert text(t"{ (a!) !r:>6}") == [" (a!)"], text(t"{ (a!) !r:>6}") + assert text(t"{ (a!) !r:>6}") == [reported(" (a!) ")], text(t"{ (a!) !r:>6}") # a field nothing lowered keeps the interpolation python built for it - assert text(t"{b} {a!} { b }") == ["b", "a!", " b"], text(t"{b} {a!} { b }") + assert text(t"{b} {a!} { b }") == ["b", "a!", reported(" b ")], text(t"{b} {a!} { b }") # the values and the rendering are the ones the lowering produced assert t"{a!}".values == (3,), t"{a!}".values assert t"{ (a!) !r:>6}".values == (3,), "spec values" assert t"{a!}".strings == ("", ""), t"{a!}".strings # a `=` field, which python itself splits into literal text and a field beside it assert list(t"{(a!) = }")[0] == "(a!) = ", list(t"{(a!) = }")[0] - assert text(t"{(a!) = }") == ["(a!)"], text(t"{(a!) = }") + assert text(t"{(a!) = }") == [reported("(a!) ")], text(t"{(a!) = }") # a template nothing lowered is left exactly as written assert text(t"{b}") == ["b"], text(t"{b}") # a string tag is handed the template with the same texts @@ -67,18 +73,23 @@ print("ok") /// the same fields with the basedpython operators taken out, so the expectations above /// are checked against python's own reporting rather than against themselves const PYTHON_REFERENCE: &str = r#" +import sys + +def reported(text): + return text if sys.version_info >= (3, 14, 8) else text.rstrip() + def show(a, b, d): def text(t): return [i.expression for i in t.interpolations] assert text(t"{a}") == ["a"], text(t"{a}") - assert text(t"{ (a) }") == [" (a)"], text(t"{ (a) }") - assert text(t"{ (a) !r:>6}") == [" (a)"], text(t"{ (a) !r:>6}") - assert text(t"{b} {a} { b }") == ["b", "a", " b"], text(t"{b} {a} { b }") + assert text(t"{ (a) }") == [reported(" (a) ")], text(t"{ (a) }") + assert text(t"{ (a) !r:>6}") == [reported(" (a) ")], text(t"{ (a) !r:>6}") + assert text(t"{b} {a} { b }") == ["b", "a", reported(" b ")], text(t"{b} {a} { b }") assert t"{a}".values == (3,), t"{a}".values assert t"{a}".strings == ("", ""), t"{a}".strings assert list(t"{(a) = }")[0] == "(a) = ", list(t"{(a) = }")[0] - assert text(t"{(a) = }") == ["(a)"], text(t"{(a) = }") + assert text(t"{(a) = }") == [reported("(a) ")], text(t"{(a) = }") assert text(t"{b}") == ["b"], text(t"{b}") show(3, 7, {"k": 5})