Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/pyrefly_types/src/callable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ pub struct FuncFlags {
/// `dataclass_transform` call. See
/// https://typing.python.org/en/latest/spec/dataclasses.html#specification.
pub dataclass_transform_metadata: Option<DataclassTransformMetadata>,
/// The attrs field name for a method decorated with `@field.default`.
pub attrs_default_field: Option<Name>,
}

/// The index of a function definition (`def ..():` statement) within the module,
Expand Down
6 changes: 6 additions & 0 deletions crates/pyrefly_types/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,12 @@ impl Type {
.is_some_and(|meta| matches!(meta.role, PropertyRole::SetterDecorator))
}

pub fn attrs_default_field(&self) -> Option<&Name> {
self.visit_toplevel_func_metadata::<Option<&Name>>(&|meta: &FuncMetadata| {
meta.flags.attrs_default_field.as_ref()
})
}

pub fn is_property_setter_with_getter(&self) -> Option<Type> {
self.property_metadata().and_then(|meta| match meta.role {
PropertyRole::Setter => Some(meta.getter.clone()),
Expand Down
35 changes: 35 additions & 0 deletions pyrefly/lib/alt/class/class_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2401,6 +2401,16 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
DataclassMember::NotAField
} else {
let flags = field.dataclass_flags_of(self.heap);
let flags = if flags.default.is_none()
&& self.get_metadata_for_class(cls).is_attrs_class()
&& let Some(default_ty) = self.attrs_default_for_field(cls, name)
{
let mut flags = flags;
flags.default = Some(default_ty);
flags
} else {
flags
};
if field.is_init_var() {
DataclassMember::InitVar(member, flags)
} else {
Expand All @@ -2409,6 +2419,31 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
}
}

fn attrs_default_for_field(&self, cls: &Class, field_name: &Name) -> Option<Type> {
for name in cls.class_body_fields() {
if name == field_name {
continue;
}
let Some(field) = self.get_non_synthesized_field_from_current_class_only(cls, name)
else {
continue;
};
if !matches!(&field.0, ClassFieldInner::Method { .. }) {
continue;
}
let ty = field.ty();
if ty
.attrs_default_field()
.is_some_and(|default_name| default_name == field_name)
{
return ty
.callable_return_type(self.heap)
.or_else(|| Some(self.heap.mk_any_implicit()));
}
}
Comment on lines +2422 to +2443
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attrs_default_for_field linearly scans all class_body_fields() and runs several lookups to find a matching @<field>.default method. Since get_dataclass_member is called in loops over all fields (e.g. dataclass synthesis/validation), this introduces an O(n^2) walk for attrs classes with many fields. Consider precomputing a map of {field_name -> default_return_type} once per class (e.g. during class metadata/field synthesis) or otherwise caching the result to avoid repeated scans.

Copilot uses AI. Check for mistakes.
None
}

fn check_and_sanitize_type_parameters(
&self,
class: &Class,
Expand Down
8 changes: 8 additions & 0 deletions pyrefly/lib/alt/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
&'a self,
decorator: &'a Decorator,
) -> Option<SpecialDecorator<'a>> {
if let Some(field) = decorator.attrs_default_field.as_ref() {
return Some(SpecialDecorator::AttrsDefault(field));
}
if decorator
.ty
.property_metadata()
Expand Down Expand Up @@ -793,6 +796,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
flags.is_abstract_method = true;
true
}
SpecialDecorator::AttrsDefault(field) => {
flags.attrs_default_field = Some((*field).clone());
true
}
_ => false,
}
}
Expand Down Expand Up @@ -1148,6 +1155,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
SpecialDecorator::Final => "final",
SpecialDecorator::EnumNonmember => "nonmember",
SpecialDecorator::AbstractMethod => "abstractmethod",
SpecialDecorator::AttrsDefault(_) => "default",
_ => return,
};
self.error(
Expand Down
12 changes: 10 additions & 2 deletions pyrefly/lib/alt/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4692,11 +4692,19 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
}

pub fn solve_decorator(&self, x: &BindingDecorator, errors: &ErrorCollector) -> Arc<Decorator> {
let mut ty = self.expr_infer(&x.expr, errors);
let mut ty = if x.attrs_default_field.is_some() {
self.expr_infer(&x.expr, &self.error_swallower())
} else {
self.expr_infer(&x.expr, errors)
};
self.pin_all_placeholder_types(&mut ty, true, x.expr.range(), errors);
self.expand_vars_mut(&mut ty);
let deprecation = parse_deprecation(&x.expr);
Arc::new(Decorator { ty, deprecation })
Arc::new(Decorator {
ty,
deprecation,
attrs_default_field: x.attrs_default_field.clone(),
})
}

pub fn solve_decorated_function(
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/alt/special_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
let decorator = Decorator {
ty: callee.clone(),
deprecation: None,
attrs_default_field: None,
};
let special_decorator = self.get_special_decorator(&decorator)?;
// Does this call have a single positional argument?
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/alt/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ impl<Ans: LookupAnswer> Solve<Ans> for KeyDecorator {
Decorator {
ty: heap.mk_any_implicit(),
deprecation: None,
attrs_default_field: None,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions pyrefly/lib/alt/types/decorated_function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub struct DecoratedFunction {
pub struct Decorator {
pub ty: Type,
pub deprecation: Option<Deprecation>,
pub attrs_default_field: Option<Name>,
}

impl Display for Decorator {
Expand All @@ -101,6 +102,7 @@ pub enum SpecialDecorator<'a> {
DataclassTransformCall(&'a TypeMap),
EnumNonmember,
AbstractMethod,
AttrsDefault(&'a Name),
}

impl UndecoratedFunction {
Expand Down
14 changes: 12 additions & 2 deletions pyrefly/lib/binding/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ assert_bytes!(BindingClassSynthesizedFields, 4);
assert_bytes!(BindingLegacyTypeParam, 16);
assert_words!(BindingYield, 4);
assert_words!(BindingYieldFrom, 4);
assert_words!(BindingDecorator, 10);
assert_words!(BindingDecorator, 13);
assert_bytes!(BindingDecoratedFunction, 20);
assert_words!(BindingUndecoratedFunction, 15);

Expand Down Expand Up @@ -1628,11 +1628,21 @@ pub enum FunctionStubOrImpl {
#[derive(Clone, Debug)]
pub struct BindingDecorator {
pub expr: Expr,
pub attrs_default_field: Option<Name>,
}

impl DisplayWith<Bindings> for BindingDecorator {
fn fmt(&self, f: &mut fmt::Formatter<'_>, ctx: &Bindings) -> fmt::Result {
write!(f, "BindingDecorator({})", ctx.module().display(&self.expr))
if let Some(field) = &self.attrs_default_field {
write!(
f,
"BindingDecorator({}, attrs_default_field={})",
ctx.module().display(&self.expr),
field
)
} else {
write!(f, "BindingDecorator({})", ctx.module().display(&self.expr))
}
}
}

Expand Down
56 changes: 55 additions & 1 deletion pyrefly/lib/binding/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use ruff_python_ast::ExprYieldFrom;
use ruff_python_ast::Identifier;
use ruff_python_ast::Operator;
use ruff_python_ast::StringLiteral;
use ruff_python_ast::name::Name;
use ruff_text_size::Ranged;
use ruff_text_size::TextRange;
use starlark_map::Hashed;
Expand Down Expand Up @@ -58,6 +59,7 @@ use crate::binding::bindings::NameLookupResult;
use crate::binding::narrow::AtomicNarrowOp;
use crate::binding::narrow::NarrowOps;
use crate::binding::narrow::NarrowSource;
use crate::binding::scope::FlowStyle;
use crate::binding::scope::Scope;
use crate::config::error_kind::ErrorKind;
use crate::error::context::ErrorInfo;
Expand Down Expand Up @@ -1085,13 +1087,65 @@ impl<'a> BindingsBuilder<'a> {
) -> Vec<Idx<KeyDecorator>> {
let mut decorator_keys = Vec::with_capacity(decorators.len());
for mut x in decorators {
let attrs_default_field = self.attrs_default_decorator_field(&x.expression);
self.ensure_expr(&mut x.expression, usage);
let k = self.insert_binding(
KeyDecorator(x.range),
BindingDecorator { expr: x.expression },
BindingDecorator {
expr: x.expression,
attrs_default_field,
},
);
decorator_keys.push(k);
}
decorator_keys
}

fn attrs_default_decorator_field(&self, expr: &Expr) -> Option<Name> {
if self.scopes.enclosing_class_and_metadata_keys().is_none() {
return None;
}
let Expr::Attribute(attr) = expr else {
return None;
};
if attr.attr.id.as_str() != "default" {
return None;
}
let Expr::Name(base_name) = &*attr.value else {
return None;
};
let (_, style) = self.scopes.binding_idx_for_name(&base_name.id)?;
let FlowStyle::ClassField {
initial_value: Some(initial_value),
} = style
else {
return None;
};
if self.is_attrs_field_specifier(&initial_value) {
Some(base_name.id.clone())
} else {
None
}
}

fn is_attrs_field_specifier(&self, expr: &Expr) -> bool {
let Expr::Call(call) = expr else {
return false;
};
match &*call.func {
Expr::Name(name) => matches!(name.id.as_str(), "field" | "attrib" | "ib"),
Expr::Attribute(attr) => {
let attr_name = attr.attr.id.as_str();
if !matches!(attr_name, "field" | "attrib" | "ib") {
return false;
}
if let Expr::Name(base) = &*attr.value {
matches!(base.id.as_str(), "attr" | "attrs")
} else {
false
}
}
_ => false,
}
Comment on lines +1131 to +1149
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_attrs_field_specifier currently treats any call to a function named field/attrib/ib as an attrs field specifier, without verifying that the callee actually comes from the attr/attrs modules. This can incorrectly mark unrelated field() usages as attrs fields, which then (a) suppresses decorator type errors via error_swallower() and (b) sets attrs_default_field metadata unexpectedly. Consider checking the binding for the callee (e.g., FlowStyle::Import/ImportAs with ModuleName::attr()/ModuleName::attrs()) to avoid false positives, similar to how attrs classes are detected in class metadata.

Copilot uses AI. Check for mistakes.
}
}
5 changes: 2 additions & 3 deletions pyrefly/lib/test/attrs/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use crate::attrs_testcase;

attrs_testcase!(
bug = "Correctly recognize field and default decorator",
field_default_decorator,
r#"
from attrs import define, field
Expand All @@ -17,11 +16,11 @@ from attrs import define, field
class C:
a: dict = field()

@a.default # E: Object of class `dict` has no attribute `default`
@a.default
def _default_a(self):
return {}

c = C() # E: Missing argument `a` in function `C.__init__`
c = C()
"#,
Comment on lines 10 to 24
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new @field.default support is only covered by the from attrs import field form. Since the implementation also has branches for attr.ib/attr.attrib/attrs.field-style calls (and should avoid false positives when field() is not from attrs), it would be good to add tests for at least one import attr; x = attr.ib() case and one negative case where a user-defined field() is used and @x.default should still error.

Copilot uses AI. Check for mistakes.
);

Expand Down