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
90 changes: 87 additions & 3 deletions pyrefly/lib/commands/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use pyrefly_python::module_path::ModuleStyle;
use pyrefly_python::nesting_context::NestingContext;
use pyrefly_python::short_identifier::ShortIdentifier;
use pyrefly_types::callable::PropertyRole;
use pyrefly_types::class::ClassDefIndex;
use pyrefly_types::class::{ClassDefIndex, ClassType};
use pyrefly_types::types::Type;
use pyrefly_util::forgetter::Forgetter;
use pyrefly_util::includes::Includes;
Expand Down Expand Up @@ -1690,6 +1690,60 @@ impl ReportArgs {
.collect()
}

/// `module.Cls.member` names for each public class, including MRO-inherited ones.
fn collect_class_members(
module: &Module,
bindings: &Bindings,
answers: &Answers,
transaction: &Transaction,
handle: &Handle,
tco_classes: &SmallSet<Idx<KeyClass>>,
) -> SmallSet<String> {
let fqname_prefix = if module.name() != ModuleName::unknown() {
format!("{}.", module.name())
} else {
String::new()
};

let mut members = SmallSet::new();
for idx in bindings.keys::<KeyClass>() {
if tco_classes.contains(&idx) {
continue;
}
let BindingClass::ClassDef(binding) = bindings.get(idx) else {
continue;
};
if Self::has_function_ancestor(&binding.parent) {
continue;
}
let Some(cls) = answers.get_idx(idx).and_then(|r| r.0.clone()) else {
continue;
};

let qname = Self::class_qualified_name(module, &binding.parent, &binding.def.name);
let fqname = format!("{fqname_prefix}{qname}");

let mro = answers
.get_idx(bindings.key_to_idx(&KeyClassMro(cls.index())))
.unwrap_or_else(|| Arc::new(ClassMro::Cyclic));
let mro: &[_] = match mro.as_ref() {
ClassMro::Resolved(a) => a,
ClassMro::Cyclic => &[],
};
for obj in std::iter::once(&cls).chain(mro.iter().map(ClassType::class_object)) {
if obj.module_name().as_str() == "builtins" {
continue;
}
if let Some(fields) = transaction.get_class_fields(handle, obj) {
for name in fields.names() {
members.insert(format!("{fqname}.{name}"));
}
}
}
}
members
}

/// When a `.pyi` stub only covers a subset of a `.py` file's public
/// symbols, add the uncovered symbols from the `.py` so that completeness
/// metrics reflect the full module interface.
Expand All @@ -1700,19 +1754,23 @@ impl ReportArgs {
py_functions: Vec<Function>,
py_variables: Vec<Variable>,
py_classes: Vec<ReportClass>,
stub_class_members: &SmallSet<String>,
) {
let stub_func_names: SmallSet<String> =
stub_functions.iter().map(|f| f.name.clone()).collect();
for py_func in py_functions {
if !stub_func_names.contains(&py_func.name) {
if !stub_func_names.contains(&py_func.name)
&& !stub_class_members.contains(&py_func.name)
{
stub_functions.push(py_func);
}
}

let stub_var_names: SmallSet<String> =
stub_variables.iter().map(|v| v.name.clone()).collect();
for py_var in py_variables {
if !stub_var_names.contains(&py_var.name) {
if !stub_var_names.contains(&py_var.name) && !stub_class_members.contains(&py_var.name)
{
stub_variables.push(py_var);
}
}
Expand Down Expand Up @@ -2005,13 +2063,22 @@ impl ReportArgs {
&py_answers,
&py_tco_classes,
));
let stub_class_members = Self::collect_class_members(
&module,
&bindings,
&answers,
transaction,
handle,
&tco_classes,
);
Self::merge_uncovered_py_symbols(
&mut functions,
&mut variables,
&mut classes,
py_functions,
py_variables,
py_classes,
&stub_class_members,
);
}

Expand Down Expand Up @@ -2283,13 +2350,22 @@ mod tests {
));

// Merge uncovered symbols from .py into the stub report
let stub_class_members = ReportArgs::collect_class_members(
&module,
&bindings,
&answers,
&pyi_txn,
&pyi_handle,
&tco_classes,
);
ReportArgs::merge_uncovered_py_symbols(
&mut functions,
&mut variables,
&mut classes,
py_functions,
py_variables,
py_classes,
&stub_class_members,
);

ReportArgs::build_module_report(
Expand Down Expand Up @@ -2354,6 +2430,14 @@ mod tests {
compare_snapshot("partial_stub.expected.json", &report);
}

/// gh-3519: don't double-count methods whose stub coverage is inherited.
#[test]
fn test_report_inherited_method_via_stub() {
let report =
build_stub_module_report("stub_inherited_methods.pyi", "stub_inherited_methods.py");
compare_snapshot("stub_inherited_methods.expected.json", &report);
}

/// Stubs-only packages: .py discovered via site-package-path, merged like co-located stubs.
#[test]
fn test_report_external_stub_merge() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "test",
"path": "test.pyi",
"names": [
"test.Base.m",
"test.Base",
"test.Sub"
],
"line_count": 10,
"symbol_reports": [
{
"kind": "function",
"name": "test.Base.m",
"n_typable": 2,
"n_typed": 2,
"n_any": 0,
"n_untyped": 0,
"location": {
"line": 7,
"column": 5
}
},
{
"kind": "class",
"name": "test.Base",
"n_typable": 0,
"n_typed": 0,
"n_any": 0,
"n_untyped": 0,
"location": {
"line": 6,
"column": 1
}
},
{
"kind": "class",
"name": "test.Sub",
"n_typable": 0,
"n_typed": 0,
"n_any": 0,
"n_untyped": 0,
"location": {
"line": 9,
"column": 1
}
}
],
"type_ignores": [],
"n_typable": 2,
"n_typed": 2,
"n_any": 0,
"n_untyped": 0,
"coverage": 100.0,
"strict_coverage": 100.0,
"n_functions": 0,
"n_methods": 1,
"n_function_params": 0,
"n_method_params": 1,
"n_classes": 2,
"n_attrs": 0,
"n_properties": 0,
"n_type_ignores": 0
}
14 changes: 14 additions & 0 deletions pyrefly/lib/test/report/test_files/stub_inherited_methods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 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.


class Base:
def m(self, x):
return None


class Sub(Base):
def m(self, x):
return None
9 changes: 9 additions & 0 deletions pyrefly/lib/test/report/test_files/stub_inherited_methods.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 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.

class Base:
def m(self, x: int) -> None: ...

class Sub(Base): ...
Loading