[red-knot] Implicit instance attributes in generic methods (#17769)

## Summary

Add the ability to detect instance attribute assignments in class
methods that are generic.

This does not address the code duplication mentioned in #16928. I can
open a ticket for this after this has been merged.

closes #16928

## Test Plan

Added regression test.
This commit is contained in:
David Peter 2025-05-02 10:20:37 +02:00 committed by GitHub
parent 17050e2ec5
commit 3cf44e401a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69 additions and 11 deletions

View file

@ -0,0 +1,9 @@
# Regression test for an issue that came up while working
# on https://github.com/astral-sh/ruff/pull/17769
class C:
def method[T](self, x: T) -> T:
def inner():
self.attr = 1
C().attr

View file

@ -1949,6 +1949,27 @@ reveal_type(C.a_type) # revealed: type
reveal_type(C.a_none) # revealed: None
```
### Generic methods
We also detect implicit instance attributes on methods that are themselves generic. We have an extra
test for this because generic functions have an extra type-params scope in between the function body
scope and the outer scope, so we need to make sure that our implementation can still recognize `f`
as a method of `C` here:
```toml
[environment]
python-version = "3.12"
```
```py
class C:
def f[T](self, t: T) -> T:
self.x: int = 1
return t
reveal_type(C().x) # revealed: int
```
## Enum classes
Enums are not supported yet; attribute access on an enum class is inferred as `Todo`.

View file

@ -18,7 +18,8 @@ use crate::semantic_index::builder::SemanticIndexBuilder;
use crate::semantic_index::definition::{Definition, DefinitionNodeKey, Definitions};
use crate::semantic_index::expression::Expression;
use crate::semantic_index::symbol::{
FileScopeId, NodeWithScopeKey, NodeWithScopeRef, Scope, ScopeId, ScopedSymbolId, SymbolTable,
FileScopeId, NodeWithScopeKey, NodeWithScopeRef, Scope, ScopeId, ScopeKind, ScopedSymbolId,
SymbolTable,
};
use crate::semantic_index::use_def::{EagerBindingsKey, ScopedEagerBindingsId, UseDefMap};
use crate::Db;
@ -109,12 +110,26 @@ pub(crate) fn attribute_assignments<'db, 's>(
let index = semantic_index(db, file);
let class_scope_id = class_body_scope.file_scope_id(db);
ChildrenIter::new(index, class_scope_id).filter_map(|(file_scope_id, maybe_method)| {
maybe_method.node().as_function()?;
let attribute_table = index.instance_attribute_table(file_scope_id);
ChildrenIter::new(index, class_scope_id).filter_map(|(child_scope_id, scope)| {
let (function_scope_id, function_scope) =
if scope.node().scope_kind() == ScopeKind::Annotation {
// This could be a generic method with a type-params scope.
// Go one level deeper to find the function scope. The first
// descendant is the (potential) function scope.
let function_scope_id = scope.descendants().start;
(function_scope_id, index.scope(function_scope_id))
} else {
(child_scope_id, scope)
};
function_scope.node().as_function()?;
let attribute_table = index.instance_attribute_table(function_scope_id);
let symbol = attribute_table.symbol_id_by_name(name)?;
let use_def = &index.use_def_maps[file_scope_id];
Some((use_def.instance_attribute_bindings(symbol), file_scope_id))
let use_def = &index.use_def_maps[function_scope_id];
Some((
use_def.instance_attribute_bindings(symbol),
function_scope_id,
))
})
}

View file

@ -183,13 +183,26 @@ impl<'db> SemanticIndexBuilder<'db> {
fn is_method_of_class(&self) -> Option<FileScopeId> {
let mut scopes_rev = self.scope_stack.iter().rev();
let current = scopes_rev.next()?;
if self.scopes[current.file_scope_id].kind() != ScopeKind::Function {
return None;
}
let parent = scopes_rev.next()?;
match (
self.scopes[current.file_scope_id].kind(),
self.scopes[parent.file_scope_id].kind(),
) {
(ScopeKind::Function, ScopeKind::Class) => Some(parent.file_scope_id),
match self.scopes[parent.file_scope_id].kind() {
ScopeKind::Class => Some(parent.file_scope_id),
ScopeKind::Annotation => {
// If the function is generic, the parent scope is an annotation scope.
// In this case, we need to go up one level higher to find the class scope.
let grandparent = scopes_rev.next()?;
if self.scopes[grandparent.file_scope_id].kind() == ScopeKind::Class {
Some(grandparent.file_scope_id)
} else {
None
}
}
_ => None,
}
}