diff --git a/pyrefly/lib/commands/report.rs b/pyrefly/lib/commands/report.rs index 690543270e..a0f78fafc0 100644 --- a/pyrefly/lib/commands/report.rs +++ b/pyrefly/lib/commands/report.rs @@ -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) -> 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 { @@ -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, @@ -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, @@ -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 = @@ -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(¶m.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 @@ -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"); diff --git a/pyrefly/lib/test/report/test_files/bare_final.py b/pyrefly/lib/test/report/test_files/bare_final.py new file mode 100644 index 0000000000..d6fa60731d --- /dev/null +++ b/pyrefly/lib/test/report/test_files/bare_final.py @@ -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 diff --git a/pyrefly/lib/test/report/test_files/bare_list_annotations.py b/pyrefly/lib/test/report/test_files/bare_list_annotations.py new file mode 100644 index 0000000000..0dd310b5ff --- /dev/null +++ b/pyrefly/lib/test/report/test_files/bare_list_annotations.py @@ -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