brick/tests/python_imports.rs
trvon 67334bec59
fix(python): resolve relative, package and dotted imports to the right file
Since #5 Python calls resolve only through import edges, but the
lexical importer could not produce those edges for the common forms:

- `from .util import helper` never matched (the regex needed a leading
  letter), so the call went unresolved;
- `from pkg import f` looked up the stem "pkg" instead of
  pkg/__init__.py;
- `from x.common import f` failed when y/common.py also existed, and
  `from other.common import f` (a module the repository does not have)
  resolved to y/common.py by basename.

Imports now resolve Python-specifically: leading dots are relative to
the importing package, and a dotted name maps to `a/b.py` or
`a/b/__init__.py` from the repository root or `src/`. Only a
single-component name (`import foo`) may still fall back to a unique
module basename; a dotted name never does. `from . import x` stays
unresolved (the module is in the import list, not the name).

Tests (both failed before): relative, package and duplicate-stem imports
resolve their calls to pkg/util.py, pkg/__init__.py and x/common.py;
an unknown dotted module does not bind to y/common.py.
2026-09-24 21:03:08 -06:00

80 lines
2.8 KiB
Rust

use brick::model::{Building, EdgeKind};
use brick::scan;
use std::fs;
fn scan_files(files: &[(&str, &str)]) -> Building {
let root = tempfile::tempdir().unwrap();
for (path, content) in files {
let file = root.path().join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(file, content).unwrap();
}
scan::scan(root.path()).unwrap()
}
/// (caller name, target path) for every resolved call.
fn resolved_calls(model: &Building) -> Vec<(String, String)> {
model
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Calls)
.filter_map(|edge| {
let source = model.nodes.iter().find(|n| n.id == edge.source)?;
let target = model.nodes.iter().find(|n| n.id == edge.target)?;
Some((source.name.clone(), target.path.clone()?))
})
.collect()
}
#[test]
fn relative_package_and_qualified_python_imports_resolve_to_the_right_file() {
// Python calls resolve only through import edges, and the lexical importer
// produced none for relative imports (the regex needed a leading letter),
// for packages (`from pkg import f` looked up the stem "pkg", not
// pkg/__init__.py), or when two files shared a stem.
let model = scan_files(&[
("pkg/__init__.py", "def from_package():\n return 1\n"),
("pkg/util.py", "def helper():\n return 1\n"),
(
"pkg/rel.py",
"from .util import helper\n\ndef use_relative():\n return helper()\n",
),
("x/common.py", "def only_here():\n return 1\n"),
("y/common.py", "def elsewhere():\n return 2\n"),
(
"main.py",
"from x.common import only_here\nfrom pkg import from_package\n\ndef run():\n only_here()\n return from_package()\n",
),
]);
let calls = resolved_calls(&model);
for (caller, target) in [
("use_relative", "pkg/util.py"),
("run", "x/common.py"),
("run", "pkg/__init__.py"),
] {
assert!(
calls
.iter()
.any(|(source, path)| source == caller && path == target),
"{caller} -> {target} missing: {calls:?}"
);
}
}
#[test]
fn an_unknown_dotted_python_module_does_not_fall_back_to_a_basename() {
// `from other.common import f` names a module this repository does not have;
// matching the stem "common" to y/common.py would invent a dependency.
let model = scan_files(&[
("y/common.py", "def f():\n return 2\n"),
(
"main.py",
"from other.common import f\n\ndef run():\n return f()\n",
),
]);
let calls = resolved_calls(&model);
assert!(
!calls.iter().any(|(_, path)| path == "y/common.py"),
"{calls:?}"
);
}