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
108 changes: 85 additions & 23 deletions pyrefly/lib/commands/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,16 @@ impl ReportArgs {
}
}

/// Classify an annotation slot from resolver output. Bare qualifiers
/// (e.g. `Final`) have unresolved annotation types but still count as typed.
fn classify_annotation_rank(has_annotation: bool, resolved_is_known: Option<bool>) -> SlotRank {
match resolved_is_known {
Some(is_known) => SlotRank::classify(true, is_known),
None if has_annotation => SlotRank::Typed,
None => SlotRank::Untyped,
}
}

/// Returns true if the name is public: does not start with `_`, or is a dunder (`__x__`).
/// Matches typestats `is_public_name`.
fn is_public_name(name: &str) -> bool {
Expand Down Expand Up @@ -648,12 +658,12 @@ impl ReportArgs {
}
_ => None,
};
let is_type_known = annotation_text.is_some()
&& answers
.get_idx(*annot_idx)
.and_then(|awt| awt.annotation.ty.as_ref().map(Self::is_type_known))
.unwrap_or(false);
let slots = SlotRank::classify(annotation_text.is_some(), is_type_known).into();
let resolved_ty = answers
.get_idx(*annot_idx)
.and_then(|awt| awt.annotation.ty.as_ref().map(Self::is_type_known));
let slots =
Self::classify_annotation_rank(annotation_text.is_some(), resolved_ty)
.into();
variables.push(Variable {
name: qualified_name,
annotation: annotation_text,
Expand Down Expand Up @@ -844,15 +854,13 @@ impl ReportArgs {
}
_ => None,
});
let is_type_known = annotation_text.is_some()
&& annotation_idx
.and_then(|idx| {
answers
.get_idx(idx)
.and_then(|awt| awt.annotation.ty.as_ref().map(Self::is_type_known))
})
.unwrap_or(false);
let slots = SlotRank::classify(annotation_text.is_some(), is_type_known).into();
let resolved_ty = annotation_idx.and_then(|idx| {
answers
.get_idx(idx)
.and_then(|awt| awt.annotation.ty.as_ref().map(Self::is_type_known))
});
let slots =
Self::classify_annotation_rank(annotation_text.is_some(), resolved_ty).into();

attrs.push(Variable {
name: qualified_name,
Expand Down Expand Up @@ -956,10 +964,14 @@ impl ReportArgs {
None
};

let is_return_type_known = return_annotation.is_some()
&& answers
let resolved_return_ty = return_annotation.as_ref().and_then(|_| {
answers
.get_type_at(return_idx)
.is_some_and(|t| Self::is_type_known(&t));
.map(|t| Self::is_type_known(&t))
});
let is_return_type_known =
Self::classify_annotation_rank(return_annotation.is_some(), resolved_return_ty)
== SlotRank::Typed;

let mut parameters = Vec::new();
let implicit_receiver =
Expand Down Expand Up @@ -1002,19 +1014,21 @@ impl ReportArgs {
.as_ref()
.map(|ann| module.code_at(ann.range()).to_owned());

let is_param_type_known = if is_self {
true
} else if param.annotation.is_some() {
let resolved_param_ty = if param.annotation.is_some() {
let annot_key =
KeyAnnotation::Annotation(ShortIdentifier::new(&param.name));
let annot_idx = bindings.key_to_idx(&annot_key);
answers
.get_idx(annot_idx)
.and_then(|awt| awt.annotation.ty.as_ref().map(Self::is_type_known))
.unwrap_or(false)
} else {
false
None
};
let is_param_type_known = is_self
|| Self::classify_annotation_rank(
param_annotation.is_some(),
resolved_param_ty,
) == SlotRank::Typed;

// Implicit dunder params are always excluded, even when annotated.
let is_implicit_param = !is_self
Expand Down Expand Up @@ -2431,6 +2445,54 @@ mod tests {
compare_snapshot("partial_any.expected.json", &report);
}

/// Bare `Final` should be typed, not `Any`
#[test]
fn test_report_bare_final() {
let report = build_module_report_for_test("bare_final.py");
let attr_slots = |name: &str| {
report
.symbol_reports
.iter()
.find_map(|s| match s {
SymbolReport::Attr { name: n, slots, .. } if n == name => Some(*slots),
_ => None,
})
.unwrap_or_else(|| panic!("no attr symbol named {name}"))
};

for name in [
"test.golden",
"test.golden_ratio",
"test.pi",
"test.name",
"test.Constants.rate",
"test.Constants.count",
] {
let slots = attr_slots(name);
assert_eq!(slots.n_typable, 1, "{name} should have 1 typable slot");
assert_eq!(slots.n_typed, 1, "{name} should be typed");
assert_eq!(slots.n_any, 0, "{name} should not be any");
}
}

#[test]
fn test_report_bare_list_annotations() {
let report = build_module_report_for_test("bare_list_annotations.py");
let function_slots = report
.symbol_reports
.iter()
.find_map(|s| match s {
SymbolReport::Function { name, slots, .. } if name == "test.f" => Some(*slots),
_ => None,
})
.unwrap_or_else(|| panic!("no function symbol named test.f"));

assert_eq!(function_slots.n_typable, 2);
assert_eq!(function_slots.n_typed, 2);
assert_eq!(function_slots.n_any, 0);
assert_eq!(function_slots.n_untyped, 0);
}

#[test]
fn test_full_report_has_schema_version() {
let report = build_module_report_for_test("functions.py");
Expand Down
21 changes: 21 additions & 0 deletions pyrefly/lib/test/report/test_files/bare_final.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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.

# Bare `Final` should be typed: https://github.com/facebook/pyrefly/issues/3172

from typing import Final

golden: Final = 1.618033988749895
golden_ratio: Final = golden

pi: Final[float] = 3.14159

name: Final = "hello"


class Constants:
def __init__(self):
self.rate: Final = 0.05
self.count: Final[int] = 10
8 changes: 8 additions & 0 deletions pyrefly/lib/test/report/test_files/bare_list_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 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.


def f(x: list) -> list:
return x
Loading