Skip to content
Closed
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
1 change: 1 addition & 0 deletions crates/pyrefly_config/src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ impl Preset {
(ErrorKind::MethodAssign, Severity::Error),
(ErrorKind::MissingOverrideDecorator, Severity::Error),
(ErrorKind::OpenUnpacking, Severity::Error),
(ErrorKind::PossiblyUninitializedAttribute, Severity::Error),
(ErrorKind::PotentialBadKeywordArgument, Severity::Error),
(ErrorKind::UnusedIgnore, Severity::Error),
]);
Expand Down
32 changes: 32 additions & 0 deletions crates/pyrefly_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3960,6 +3960,38 @@ output-format = "omit-errors"
assert_eq!(config.root.strict_partial_subtyping, Some(true));
}

#[test]
fn test_possibly_uninitialized_attribute_configuration() {
for (source, initialization_severity, missing_severity) in [
("", Severity::Ignore, Severity::Error),
("preset = 'strict'", Severity::Error, Severity::Error),
(
"preset = 'strict'\n[errors]\npossibly-uninitialized-attribute = 'ignore'",
Severity::Ignore,
Severity::Error,
),
(
"preset = 'strict'\n[errors]\nmissing-attribute = 'ignore'",
Severity::Error,
Severity::Ignore,
),
] {
let mut config = ConfigFile::parse_config(source).unwrap();
config.configure();
let errors = config.root.errors.as_ref().unwrap();
assert_eq!(
errors.severity(ErrorKind::PossiblyUninitializedAttribute),
initialization_severity,
"configuration: {source}",
);
assert_eq!(
errors.severity(ErrorKind::MissingAttribute),
missing_severity,
"configuration: {source}",
);
}
}

#[test]
fn test_preset_off_silences_all_errors() {
// The `off` preset silences every error kind and leaves all other
Expand Down
3 changes: 3 additions & 0 deletions crates/pyrefly_config/src/error_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ pub enum ErrorKind {
/// A value whose type is not itself `Any` but contains an implicit `Any`,
/// such as `list[Unknown]` or `Unknown | None`.
PartiallyUnknownType,
/// A class-body attribute may be uninitialized on some control-flow paths.
PossiblyUninitializedAttribute,
/// A potential conflict between an explicit keyword argument and a NotRequired
/// TypedDict field. The field may be absent at runtime, so the conflict is not
/// guaranteed. This is a separate error code from BadKeywordArgument to allow
Expand Down Expand Up @@ -653,6 +655,7 @@ impl ErrorKind {
ErrorKind::NotRequiredKeyAccess => Severity::Ignore,
ErrorKind::OpenUnpacking => Severity::Ignore,
ErrorKind::OrderedDataclassInheritance => Severity::Warn,
ErrorKind::PossiblyUninitializedAttribute => Severity::Ignore,
ErrorKind::PotentialBadKeywordArgument => Severity::Ignore,
ErrorKind::PytorchEfficiencyLintCudaCall => Severity::Ignore,
ErrorKind::PytorchEfficiencyLintItemCall => Severity::Ignore,
Expand Down
29 changes: 29 additions & 0 deletions pyrefly/lib/alt/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3199,6 +3199,35 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
.emit();
}
}
BindingExpect::UninitializedAttributeCheck {
name,
range,
class,
termination_keys,
} => {
let initialized = termination_keys
.as_ref()
.is_some_and(|keys| keys.iter().all(|key| self.get_idx(*key).ty().is_never()));
let inherited = !initialized
&& self.get_idx(*class).0.as_ref().is_some_and(|cls| {
self.get_mro_for_class(cls)
.ancestors_no_object()
.iter()
.any(|base| {
base.class_object() != cls
&& self.get_class_member(base.class_object(), name).is_some()
})
});
if !initialized && !inherited {
errors
.error_builder(
*range,
ErrorKind::PossiblyUninitializedAttribute,
format!("Attribute `{name}` may be uninitialized"),
)
.emit();
}
}
BindingExpect::ForwardRefUnion {
left,
right,
Expand Down
27 changes: 27 additions & 0 deletions pyrefly/lib/binding/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,8 @@ pub enum KeyExpect {
PrivateAttributeAccess(TextRange),
/// Deferred uninitialized variable check.
UninitializedCheck(TextRange),
/// Check that an attribute declaration is initialized on every control-flow path.
UninitializedAttributeCheck(TextRange),
/// Forward reference string literal in union type check.
ForwardRefUnion(TextRange),
/// A name used in annotation position that may be an invalid implicit alias.
Expand All @@ -1159,6 +1161,7 @@ impl Ranged for KeyExpect {
| KeyExpect::ExceptClauseReachability(range)
| KeyExpect::PrivateAttributeAccess(range)
| KeyExpect::UninitializedCheck(range)
| KeyExpect::UninitializedAttributeCheck(range)
| KeyExpect::ForwardRefUnion(range)
| KeyExpect::ImplicitAliasCheck(range)
| KeyExpect::ValidateImplicitReturn(range)
Expand All @@ -1182,6 +1185,7 @@ impl DisplayWith<ModuleInfo> for KeyExpect {
KeyExpect::ExceptClauseReachability(r) => ("ExceptClauseReachability", r),
KeyExpect::PrivateAttributeAccess(r) => ("PrivateAttributeAccess", r),
KeyExpect::UninitializedCheck(r) => ("UninitializedCheck", r),
KeyExpect::UninitializedAttributeCheck(r) => ("UninitializedAttributeCheck", r),
KeyExpect::ForwardRefUnion(r) => ("ForwardRefUnion", r),
KeyExpect::ImplicitAliasCheck(r) => ("ImplicitAliasCheck", r),
KeyExpect::ValidateImplicitReturn(r) => ("ValidateImplicitReturn", r),
Expand Down Expand Up @@ -1355,6 +1359,14 @@ pub enum BindingExpect {
/// If any don't, the variable may be uninitialized.
termination_keys: Vec<Idx<Key>>,
},
/// Check whether a class-body attribute is initialized on every completing path.
UninitializedAttributeCheck {
name: Name,
range: TextRange,
class: Idx<KeyClass>,
/// All missing paths must terminate. None means a path lacks termination evidence.
termination_keys: Option<Vec<Idx<Key>>>,
},
/// Check for forward reference string literal in union type.
/// At runtime, `type.__or__` cannot handle string literals, so expressions
/// like `int | "str"` will raise a TypeError.
Expand Down Expand Up @@ -1527,6 +1539,21 @@ impl DisplayWith<Bindings> for BindingExpect {
termination_keys
)
}
Self::UninitializedAttributeCheck {
name,
range,
class,
termination_keys,
} => {
write!(
f,
"UninitializedAttributeCheck({}, {}, {:?}, {:?})",
name,
ctx.module().display(range),
class,
termination_keys
)
}
Self::ForwardRefUnion {
left,
right,
Expand Down
17 changes: 16 additions & 1 deletion pyrefly/lib/binding/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ impl<'a> BindingsBuilder<'a> {
body,
&NestingContext::class(ShortIdentifier::new(&x.name), parent.dupe()),
);
let (field_definitions, nn_module_registrations) =
let (field_definitions, nn_module_registrations, uninitialized_fields) =
self.scopes.finish_class_and_get_field_definitions();
self.insert_binding_idx(
class_indices.synthesized_fields_idx,
Expand Down Expand Up @@ -500,6 +500,21 @@ impl<'a> BindingsBuilder<'a> {
let attrs_field_specifier =
self.attrs_field_specifier(&definition, name.key(), range, &attrs_decorators);

// Stub files declare attributes without runtime initialization.
if !self.module_info.path().is_interface()
&& let Some(termination_keys) = uninitialized_fields.get(name.key())
{
self.insert_binding(
KeyExpect::UninitializedAttributeCheck(range),
BindingExpect::UninitializedAttributeCheck {
name: name.key().clone(),
range,
class: class_indices.class_idx,
termination_keys: termination_keys.clone(),
},
);
}

fields.insert_hashed(
name.clone(),
ClassFieldProperties::new(
Expand Down
44 changes: 41 additions & 3 deletions pyrefly/lib/binding/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,9 @@ struct FlowInfo {
struct FlowValue {
idx: Idx<Key>,
style: FlowStyle,
/// True when every reaching value comes from a function definition.
/// Branch merges preserve this flag when they replace the flow style.
is_function_def: bool,
}

/// The most recent narrow for a name.
Expand All @@ -757,7 +760,11 @@ struct FlowNarrow {
impl FlowInfo {
fn new_value(idx: Idx<Key>, style: FlowStyle) -> Self {
Self {
value: Some(FlowValue { idx, style }),
value: Some(FlowValue {
idx,
is_function_def: matches!(style, FlowStyle::FunctionDef { .. }),
style,
}),
narrow: None,
narrow_depth: 0,
loop_prior: idx,
Expand All @@ -775,7 +782,11 @@ impl FlowInfo {

fn updated_value(&self, idx: Idx<Key>, style: FlowStyle, in_loop: bool) -> Self {
Self {
value: Some(FlowValue { idx, style }),
value: Some(FlowValue {
idx,
is_function_def: matches!(style, FlowStyle::FunctionDef { .. }),
style,
}),
// Note that any existing narrow is wiped when a new value is bound.
narrow: None,
narrow_depth: 0,
Expand Down Expand Up @@ -2601,6 +2612,7 @@ impl Scopes {
let scope = self.current_mut();
if let Some(value) = scope.flow.get_value_mut(name) {
value.style = FlowStyle::Uninitialized;
value.is_function_def = false;
}
scope.deleted_names.insert(name.clone());
}
Expand Down Expand Up @@ -3209,13 +3221,18 @@ impl Scopes {
/// coming from self-assignment in methods. If both occur, only the class body
/// definition is tracked.
/// - Panics if the current scope is not a class body.
///
/// The final map contains possibly uninitialized class-body fields. Its optional
/// keys can prove that every path without an assignment terminates.
pub fn finish_class_and_get_field_definitions(
&mut self,
) -> (
SmallMap<Name, (ClassFieldDefinition, TextRange)>,
NNModuleRegistrations,
SmallMap<Name, Option<Vec<Idx<Key>>>>,
) {
let mut field_definitions = SmallMap::new();
let mut uninitialized_fields = SmallMap::new();
let class_body = self.pop();
let mut class_scope = {
if let ScopeKind::Class(class_scope) = class_body.kind {
Expand Down Expand Up @@ -3325,6 +3342,18 @@ impl Scopes {
definition: value.idx,
},
};
if !value.is_function_def {
match &value.style {
FlowStyle::PossiblyUninitialized => {
uninitialized_fields.insert_hashed(name.owned(), None);
}
FlowStyle::MaybeInitialized(keys) => {
uninitialized_fields
.insert_hashed(name.owned(), Some(keys.clone()));
}
_ => {}
}
}
field_definitions.insert_hashed(name.owned(), (definition, static_info.range));
}
});
Expand Down Expand Up @@ -3385,7 +3414,11 @@ impl Scopes {
}
},
);
(field_definitions, nn_module_registrations)
(
field_definitions,
nn_module_registrations,
uninitialized_fields,
)
}

/// Return a pair Some((method_name, class_key)) if we are currently in a method
Expand Down Expand Up @@ -4201,6 +4234,7 @@ impl<'a> BindingsBuilder<'a> {
let mut branch_idxs = SmallSet::with_capacity(merge_branches.len());
let mut branch_infos = Vec::with_capacity(merge_branches.len());
let mut styles = Vec::with_capacity(merge_branches.len());
let mut is_function_def = true;
let mut styled_idxs = SmallSet::with_capacity(merge_branches.len());
let mut n_values = 0;
let mut n_possibly_set_branches = 0;
Expand Down Expand Up @@ -4264,6 +4298,7 @@ impl<'a> BindingsBuilder<'a> {
if Some(v.idx) == phi_idx {
continue;
}
is_function_def &= v.is_function_def;
value_idxs.insert(v.idx);
// An invariant in Pyrefly is that we only set style when we
// set a value, so duplicate value_idxs always have the same style.
Expand Down Expand Up @@ -4340,6 +4375,7 @@ impl<'a> BindingsBuilder<'a> {
value: Some(FlowValue {
idx: *value_idxs.first().unwrap(),
style: compute_final_style(styles),
is_function_def,
}),
narrow: Some(FlowNarrow { idx: merged_idx }),
narrow_depth: 1,
Expand All @@ -4361,6 +4397,7 @@ impl<'a> BindingsBuilder<'a> {
value: Some(FlowValue {
idx: merged_idx,
style: compute_final_style(styles),
is_function_def,
}),
narrow: None,
narrow_depth: 0,
Expand Down Expand Up @@ -4514,6 +4551,7 @@ impl<'a> BindingsBuilder<'a> {
info.value = Some(FlowValue {
idx: phi_idx,
style: FlowStyle::LoopRecursion,
is_function_def: false,
});
info.narrow = None;
}
Expand Down
Loading
Loading