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
1 change: 1 addition & 0 deletions crates/pyrefly_config/src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,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 @@ -3943,6 +3943,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 @@ -367,6 +367,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 or instance 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 @@ -634,6 +636,7 @@ impl ErrorKind {
ErrorKind::NonConvergentRecursion => Severity::Warn,
ErrorKind::NotRequiredKeyAccess => Severity::Ignore,
ErrorKind::OpenUnpacking => Severity::Ignore,
ErrorKind::PossiblyUninitializedAttribute => Severity::Ignore,
ErrorKind::PotentialBadKeywordArgument => Severity::Ignore,
ErrorKind::PytorchEfficiencyLintCudaCall => Severity::Ignore,
ErrorKind::PytorchEfficiencyLintItemCall => Severity::Ignore,
Expand Down
4 changes: 4 additions & 0 deletions pyrefly/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ harness = false
name = "micro"
harness = false

[[bench]]
name = "attribute_initialization"
harness = false

[[bench]]
name = "pytorch"
harness = false
Expand Down
27 changes: 27 additions & 0 deletions pyrefly/benches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ lands at the fbsource repo root `target/criterion/`. It is throwaway — do not
commit it, and do not add repo-root ignore entries (it is already gitignored
appropriately).

## Attribute initialization

These synthetic benchmarks measure the cost of tracking instance attribute
initialization. They do not require a PyTorch checkout.

- `micro` includes the `attribute_initialization` group. It varies the number
of attributes and branch joins, loop joins, or early returns independently.
Each iteration checks an in-memory module with the standard library already loaded.
- `attribute_initialization` drives a warm LSP server with three worker threads.
It edits a file containing a constructor with 64 attributes and 64 loops with branches,
then waits for diagnostics. Each edit alternates between zero and one error
to ensure that the response belongs to the new edit. Initialization checks
are enabled. Server setup and the first check are outside the measured region.

Build both targets before measuring. Run them sequentially, with no concurrent
builds or tests:

```bash
cargo bench -p pyrefly --bench micro --bench attribute_initialization --no-run
cargo bench -p pyrefly --bench micro -- attribute_initialization --noplot
cargo bench -p pyrefly --bench attribute_initialization -- --noplot
```

Use Criterion's `--save-baseline before` on the original version and
`--baseline before` on the changed version to compare results. Synthetic
results isolate this code path. They do not establish whole-project LSP performance.

## TSP benchmark

`benches/tsp.rs` -- buck `tsp_bench`, cargo bench `tsp`, Criterion id
Expand Down
103 changes: 103 additions & 0 deletions pyrefly/benches/attribute_initialization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

//! Measure edits to a file containing a constructor with many attributes and joins.
//! Server initialization and the first check are outside the measured region.

use std::fmt::Write as _;
use std::fs;

use criterion::Criterion;
use criterion::criterion_group;
use criterion::criterion_main;
use lsp_types::DidChangeTextDocumentNotification;
use lsp_types::Uri;
use pyrefly::commands::lsp::IndexingMode;
use pyrefly::commands::lsp::LspArgs;
use pyrefly_lsp_test::object_model::InitializeSettings;
use pyrefly_lsp_test::object_model::LspInteraction;
use pyrefly_lsp_test::object_model::LspInteractionArgs;
use pyrefly_util::telemetry::NoTelemetry;
use pyrefly_util::thread_pool::TEST_THREAD_COUNT;
use serde_json::json;
use tempfile::tempdir;

fn attribute_initialization_edit(c: &mut Criterion) {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("pyrefly.toml"),
"[errors]\npossibly-uninitialized-attribute = 'error'\n",
)
.unwrap();
let path = dir.path().join("constructor.py");
let mut source =
String::from("class C:\n def __init__(self, flag: bool, values: list[int]) -> None:\n");
for i in 0..64 {
let _ = writeln!(source, " self.attribute_{i}: int = 0");
}
for i in 0..64 {
let _ = writeln!(
source,
" for value in values:\n if flag:\n self.attribute_{i} = value"
);
}
let with_error = format!("{source}\nresult: int = 'error'\n");
fs::write(&path, &source).unwrap();
let mut interaction = LspInteraction::new_with_args(LspInteractionArgs {
args: LspArgs {
indexing_mode: IndexingMode::None,
workspace_indexing_limit: 50,
build_system_blocking: false,
},
telemetry: Box::new(NoTelemetry),
thread_count: TEST_THREAD_COUNT,
thrift_remapper: None,
});
interaction.set_root(dir.path().to_path_buf());
interaction
.initialize(InitializeSettings {
configuration: Some(Some(json!([
{"pyrefly": {"displayTypeErrors": "force-on"}}
]))),
..Default::default()
})
.unwrap();
interaction.client.did_open("constructor.py");
interaction
.client
.expect_publish_diagnostics_eventual_error_count(path.clone(), 0)
.unwrap();

let mut version = 1;
c.bench_function("attribute_initialization/edit_to_diagnostics", |b| {
b.iter(|| {
version += 1;
let has_error = version % 2 == 0;
let text = if has_error { &with_error } else { &source };
interaction
.client
.send_notification::<DidChangeTextDocumentNotification>(json!({
"textDocument": {
"uri": Uri::from_file_path(&path).unwrap(),
"version": version,
},
"contentChanges": [{"text": text}],
}));
// Alternating the error count ensures that each edit completes.
interaction
.client
.expect_publish_diagnostics_eventual_error_count(
path.clone(),
usize::from(has_error),
)
.unwrap();
});
});
}

criterion_group!(benches, attribute_initialization_edit);
criterion_main!(benches);
44 changes: 44 additions & 0 deletions pyrefly/benches/micro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,49 @@ fn measure_module(
}

/// Smoke benchmark validating the harness end-to-end.
/// Attribute initialization must track branches without constructing unused type joins.
fn attribute_initialization(c: &mut Criterion) {
let mut group = c.benchmark_group("attribute_initialization");
for (attributes, joins) in [(16, 16), (64, 16), (16, 64), (64, 64)] {
for shape in ["branches", "loops", "returns"] {
let mut source = String::from(
"class C:\n def __init__(self, flag: bool, values: list[int]) -> None:\n",
);
for i in 0..attributes {
let _ = writeln!(source, " self.attribute_{i}: int = 0");
}
for i in 0..joins {
let attribute = i % attributes;
match shape {
"branches" => {
let _ = writeln!(
source,
" if flag:\n self.attribute_{attribute} = {i}\n else:\n self.attribute_{attribute} = -{i}"
);
}
"loops" => {
let _ = writeln!(
source,
" for value in values:\n self.attribute_{attribute} = value"
);
}
"returns" => {
let _ = writeln!(source, " if flag:\n return");
}
_ => unreachable!("all benchmark shapes are listed above"),
}
}
let name = format!("{shape}_{attributes}_attributes_{joins}_joins");
let code = Arc::new(FileContents::from_source(source));
assert_eq!(check_module(code.dupe(), "bench", BENCH_FILE), 0, "{name}");
group.bench_function(name, |b| {
b.iter(|| check_module(code.dupe(), "bench", BENCH_FILE))
});
}
}
group.finish();
}

fn smoke(c: &mut Criterion) {
measure(c, "smoke", "x: int = 1".to_owned(), 0);
}
Expand Down Expand Up @@ -957,6 +1000,7 @@ fn suggestion_nested(c: &mut Criterion) {

criterion_group!(
benches,
attribute_initialization,
smoke,
enum_members,
enum_value_access,
Expand Down
34 changes: 34 additions & 0 deletions pyrefly/lib/alt/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3016,6 +3016,40 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
.emit();
}
}
BindingExpect::UninitializedAttributeCheck {
name,
range,
class,
is_instance,
initializations,
} => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We never check to see if an attribute is defined on a base class, so something like this would fail:

class Base:
  timeout = 10

class Child(Base):
  def __init__(self, t: int | None = None) -> None:
    if t is not None:
      self.timeout = t  # error, even though it alwas exists in `Base`

The same thing happens with super().__init__()

We should be able to fix this by skipping the error when a base other than object defines the name

Can we also add a test for this one

let initialized = initializations.iter().any(|initialized| {
initialized.is_initialized(|key| self.get_idx(key).ty().is_never())
});
let inherited_or_dynamic = !initialized
&& self.get_idx(*class).0.as_ref().is_some_and(|cls| {
(*is_instance && self.get_class_member(cls, &dunder::GETATTR).is_some())
|| 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_or_dynamic {
errors
.error_builder(
*range,
ErrorKind::PossiblyUninitializedAttribute,
format!("Attribute `{name}` may be uninitialized"),
)
.emit();
}
}
BindingExpect::ForwardRefUnion {
left,
right,
Expand Down
32 changes: 32 additions & 0 deletions pyrefly/lib/binding/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ use crate::binding::base_class::BaseClassGeneric;
use crate::binding::bindings::BindingEntry;
use crate::binding::bindings::BindingTable;
use crate::binding::bindings::Bindings;
use crate::binding::bindings::InitializationStatus;
use crate::binding::django::DjangoFieldInfo;
use crate::binding::narrow::NarrowOp;
use crate::binding::narrow::NarrowingSubject;
Expand Down Expand Up @@ -1116,6 +1117,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 @@ -1142,6 +1145,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 @@ -1165,6 +1169,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 @@ -1317,6 +1322,16 @@ pub enum BindingExpect {
/// If any don't, the variable may be uninitialized.
termination_keys: Vec<Idx<Key>>,
},
/// Check attribute initialization from the class body or any defining method.
UninitializedAttributeCheck {
name: Name,
range: TextRange,
class: Idx<KeyClass>,
/// Instance attributes can also be provided by `__getattr__`.
is_instance: bool,
/// Any defining method can establish initialization on all its returning paths.
initializations: Vec<InitializationStatus>,
},
/// 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 @@ -1489,6 +1504,23 @@ impl DisplayWith<Bindings> for BindingExpect {
termination_keys
)
}
Self::UninitializedAttributeCheck {
name,
range,
class,
is_instance,
initializations,
} => {
write!(
f,
"UninitializedAttributeCheck({}, {}, {:?}, {}, {:?})",
name,
ctx.module().display(range),
class,
is_instance,
initializations
)
}
Self::ForwardRefUnion {
left,
right,
Expand Down
Loading
Loading