Skip to content
Open
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
73 changes: 67 additions & 6 deletions pyrefly/lib/commands/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use pyrefly_python::ignore::Ignore;
use pyrefly_python::ignore::Tool;
use pyrefly_python::module::Module;
use pyrefly_python::module_name::ModuleName;
use pyrefly_python::module_path::ModuleStyle;
use pyrefly_python::nesting_context::NestingContext;
use pyrefly_python::short_identifier::ShortIdentifier;
use pyrefly_types::class::ClassDefIndex;
Expand Down Expand Up @@ -61,6 +62,7 @@ use crate::commands::config_finder::ConfigConfigurerWrapper;
use crate::commands::files::FilesArgs;
use crate::commands::util::CommandExitStatus;
use crate::export::exports::ExportLocation;
use crate::module::finder::find_import_filtered;
use crate::state::require::Require;
use crate::state::state::State;
use crate::state::state::Transaction;
Expand Down Expand Up @@ -1483,24 +1485,53 @@ impl ReportArgs {
HashSet::new()
};

// When prefer_stubs is true, build a mapping from .pyi paths to their
// corresponding .py handles.
let pyi_to_py: HashMap<PathBuf, &Handle> = if prefer_stubs {
// Map each .pyi to its corresponding .py: first co-located,
// then by module-name lookup in site-package-path.
let pyi_to_py: HashMap<PathBuf, Handle> = if prefer_stubs {
let py_by_path: HashMap<PathBuf, &Handle> = handles
.iter()
.filter(|h| !h.path().is_interface())
.map(|h| (h.path().as_path().to_path_buf(), h))
.collect();
handles
let mut map: HashMap<PathBuf, Handle> = handles
.iter()
.filter(|h| h.path().is_interface())
.filter_map(|h| {
let py_path = h.path().as_path().with_extension("py");
py_by_path
.get(&py_path)
.map(|&py_h| (h.path().as_path().to_path_buf(), py_h))
.map(|&py_h| (h.path().as_path().to_path_buf(), py_h.clone()))
})
.collect()
.collect();
// Fall back to site-package-path for stubs-only packages.
let mut external_handles = Vec::new();
for h in handles.iter().filter(|h| h.path().is_interface()) {
let pyi_path = h.path().as_path().to_path_buf();
if map.contains_key(&pyi_path) {
continue;
}
let config = holder
.as_ref()
.config_finder()
.python_file(h.module_kind(), h.path());
if let Some(py_module_path) = find_import_filtered(
&config,
h.module(),
None,
Some(ModuleStyle::Executable),
None,
)
.finding()
{
let py_handle = config.handle_from_module_path(py_module_path);
external_handles.push(py_handle.clone());
map.insert(pyi_path, py_handle);
}
}
if !external_handles.is_empty() {
transaction.run(&external_handles, Require::Everything, None);
}
map
} else {
HashMap::new()
};
Expand Down Expand Up @@ -1909,6 +1940,36 @@ mod tests {
compare_snapshot("partial_stub.expected.json", &report);
}

/// Stubs-only packages: .py discovered via site-package-path, merged like co-located stubs.
#[test]
fn test_report_external_stub_merge() {
use pyrefly_config::config::ConfigFile;

let site_dir = tempfile::TempDir::new().unwrap();
let py_code = load_test_file("partial_stub.py");
std::fs::write(site_dir.path().join("test.py"), &py_code).unwrap();

let mut config = ConfigFile::default();
config.python_environment.site_package_path = Some(vec![site_dir.path().to_path_buf()]);
config.interpreters.skip_interpreter_query = true;
config.configure();

let py_module_path = find_import_filtered(
&config,
ModuleName::from_str("test"),
None,
Some(ModuleStyle::Executable),
None,
)
.finding()
.expect("should discover test.py in site-packages");
assert_eq!(py_module_path.as_path(), site_dir.path().join("test.py"));

// the merge should produce the same report as the co-located case
let report = build_stub_module_report("partial_stub.pyi", "partial_stub.py");
compare_snapshot("partial_stub.expected.json", &report);
}

/// When both test.py and test.pyi exist, the .py file is shadowed.
#[test]
fn test_pyi_shadows_py_in_report() {
Expand Down
Loading