[ruff] Allow dataclass attribute value instantiation from nested frozen dataclass (RUF009) (#20352)

## Summary
Resolves #20266

Definition of the frozen dataclass attribute can be instantiation of a
nested frozen dataclass as well as a non-nested one.

### Problem explanation
The `function_call_in_dataclass_default` function is invoked during the
"defined scope" stage, after all scopes have been processed. At this
point, the semantic references the top-level scope. When
`SemanticModel::lookup_attribute` executes, it searches for bindings in
the top-level module scope rather than the class scope, resulting in an
error.

To solve this issue, the lookup should be evaluated through the class
scope.

## Test Plan
- Added test case from issue

Co-authored-by: Igor Drokin <drokinii1017@gmail.com>
This commit is contained in:
Igor Drokin 2025-09-12 23:46:49 +03:00 committed by GitHub
parent 151ba49b36
commit c7f6b85fb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 42 additions and 17 deletions

View file

@ -126,3 +126,12 @@ class D:
@dataclass
class E:
c: C = C()
# https://github.com/astral-sh/ruff/issues/20266
@dataclass(frozen=True)
class C:
@dataclass(frozen=True)
class D:
foo: int = 1
d: D = D() # OK

View file

@ -134,7 +134,7 @@ pub(crate) fn deferred_scopes(checker: &Checker) {
);
}
if checker.is_rule_enabled(Rule::FunctionCallInDataclassDefaultArgument) {
ruff::rules::function_call_in_dataclass_default(checker, class_def);
ruff::rules::function_call_in_dataclass_default(checker, class_def, scope_id);
}
if checker.is_rule_enabled(Rule::MutableClassDefault) {
ruff::rules::mutable_class_default(checker, class_def);

View file

@ -2,10 +2,10 @@ use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::name::{QualifiedName, UnqualifiedName};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::typing::{
is_immutable_annotation, is_immutable_func, is_immutable_newtype_call,
};
use ruff_python_semantic::{ScopeId, SemanticModel};
use ruff_text_size::Ranged;
use crate::Violation;
@ -77,7 +77,11 @@ impl Violation for FunctionCallInDataclassDefaultArgument {
}
/// RUF009
pub(crate) fn function_call_in_dataclass_default(checker: &Checker, class_def: &ast::StmtClassDef) {
pub(crate) fn function_call_in_dataclass_default(
checker: &Checker,
class_def: &ast::StmtClassDef,
scope_id: ScopeId,
) {
let semantic = checker.semantic();
let Some((dataclass_kind, _)) = dataclass_kind(class_def, semantic) else {
@ -144,7 +148,7 @@ pub(crate) fn function_call_in_dataclass_default(checker: &Checker, class_def: &
|| func.as_name_expr().is_some_and(|name| {
is_immutable_newtype_call(name, checker.semantic(), &extend_immutable_calls)
})
|| is_frozen_dataclass_instantiation(func, semantic)
|| is_frozen_dataclass_instantiation(func, semantic, scope_id)
{
continue;
}
@ -165,8 +169,14 @@ fn any_annotated(class_body: &[Stmt]) -> bool {
/// Checks that the passed function is an instantiation of the class,
/// retrieves the ``StmtClassDef`` and verifies that it is a frozen dataclass
fn is_frozen_dataclass_instantiation(func: &Expr, semantic: &SemanticModel) -> bool {
semantic.lookup_attribute(func).is_some_and(|id| {
fn is_frozen_dataclass_instantiation(
func: &Expr,
semantic: &SemanticModel,
scope_id: ScopeId,
) -> bool {
semantic
.lookup_attribute_in_scope(func, scope_id)
.is_some_and(|id| {
let binding = &semantic.binding(id);
let Some(Stmt::ClassDef(class_def)) = binding.statement(semantic) else {
return false;

View file

@ -859,11 +859,17 @@ impl<'a> SemanticModel<'a> {
/// associated with `Class`, then the `BindingKind::FunctionDefinition` associated with
/// `Class.method`.
pub fn lookup_attribute(&self, value: &Expr) -> Option<BindingId> {
self.lookup_attribute_in_scope(value, self.scope_id)
}
/// Lookup a qualified attribute in the certain scope.
pub fn lookup_attribute_in_scope(&self, value: &Expr, scope_id: ScopeId) -> Option<BindingId> {
let unqualified_name = UnqualifiedName::from_expr(value)?;
// Find the symbol in the current scope.
let (symbol, attribute) = unqualified_name.segments().split_first()?;
let mut binding_id = self.lookup_symbol(symbol)?;
let mut binding_id =
self.lookup_symbol_in_scope(symbol, scope_id, self.in_forward_reference())?;
// Recursively resolve class attributes, e.g., `foo.bar.baz` in.
let mut tail = attribute;