Skip to content
Closed
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
47 changes: 32 additions & 15 deletions crates/basilisk-checker/src/imports/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use basilisk_resolver::scope::{ImportKind, ImportedModuleApi, PackageDepKind};

use super::builtins::populate_builtin_classes;
use super::fs_cache::FsCache;
use super::resolve::{classify_unresolved, resolve_module_with_importer_cached};
use super::resolve::{
classify_unresolved, resolve_module_with_importer_cached, resolve_relative_import_cached,
};
use super::ImportSearchPaths;

/// Resolve every import in a single module against the search paths, in place.
Expand Down Expand Up @@ -36,21 +38,32 @@ pub fn resolve_module_imports(
let mut captured: Vec<(String, ImportedModuleApi)> = Vec::new();

for import in &mut resolved.imports {
let result = match import.kind {
ImportKind::Plain | ImportKind::From | ImportKind::Star => {
resolve_module_with_importer_cached(
&import.module,
search_paths,
Some(&importing_file),
&fs,
)
}
// A `from`-import with leading dots resolves against the importing
// file's package, never the search paths (GitHub #369). The absolute
// path's importer-directory fallback only ever reached single-dot
// siblings by accident; parent packages need the level walk.
let result = if import.relative_level > 0 {
resolve_relative_import_cached(
&importing_file,
import.relative_level,
&import.module,
&fs,
)
} else {
resolve_module_with_importer_cached(
&import.module,
search_paths,
Some(&importing_file),
&fs,
)
};
if let Some(r) = result {
import.resolution = r.resolution;
import.resolved_path = Some(r.path);
} else {
} else if import.relative_level == 0 {
// Classify why the import is unresolved for actionable diagnostics.
// Relative imports are exempt: they name a workspace-local package,
// so the registry's "not installed" classification would mislead.
import.unresolved_reason = Some(classify_unresolved(&import.module, search_paths));
}

Expand All @@ -69,11 +82,15 @@ pub fn resolve_module_imports(
captured.push((binding, api));
}

import.stub_distribution =
stub_distribution(&import.module, search_paths, Some(importing_file.as_path()));
// A relative import names a workspace-local module; it can never be a
// typeshed distribution or a uv-managed package.
if import.relative_level == 0 {
import.stub_distribution =
stub_distribution(&import.module, search_paths, Some(importing_file.as_path()));

// Annotate with package metadata from the uv registry.
enrich_package_metadata(import, search_paths);
// Annotate with package metadata from the uv registry.
enrich_package_metadata(import, search_paths);
}
}

for (binding, api) in captured {
Expand Down
17 changes: 14 additions & 3 deletions crates/basilisk-checker/src/imports/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,15 +372,26 @@ pub fn resolve_relative_import(
module_name: &str,
_search_paths: &ImportSearchPaths,
) -> Option<ResolvedImport> {
let fs = FsCache::new();
resolve_relative_import_cached(importing_file, level, module_name, &FsCache::new())
}

/// [`resolve_relative_import`] with a caller-supplied [`FsCache`]; the
/// per-module import loop in [`super::resolve_module_imports`] uses this so
/// every import of a file shares one set of directory listings.
pub(crate) fn resolve_relative_import_cached(
importing_file: &Path,
level: u32,
module_name: &str,
fs: &FsCache,
) -> Option<ResolvedImport> {
let mut base = importing_file.parent()?.to_path_buf();
for _ in 1..level {
base = base.parent()?.to_path_buf();
}
if module_name.is_empty() {
return try_resolve_init(&base, &fs);
return try_resolve_init(&base, fs);
}
try_resolve_in_dir(module_name, &base, &fs)
try_resolve_in_dir(module_name, &base, fs)
}

/// Try resolving a dotted module name within a single directory.
Expand Down
1 change: 1 addition & 0 deletions crates/basilisk-checker/src/rules/imports_unresolved.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ mod tests {
fn make_import(module: &str, reason: Option<UnresolvedReason>) -> ImportInfo {
ImportInfo {
module: module.to_owned(),
relative_level: 0,
names: vec![],
span: Span::new(0, 15),
name_spans: Vec::new(),
Expand Down
6 changes: 6 additions & 0 deletions crates/basilisk-checker/src/rules/missing_type_stubs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ fn make_import(
) -> ImportInfo {
ImportInfo {
module: module.to_owned(),
relative_level: 0,
names: vec![],
span: Span::new(0, span_end),
name_spans: Vec::new(),
Expand Down Expand Up @@ -230,6 +231,7 @@ fn skips_site_packages_package_with_py_typed_marker() -> Result<(), Box<dyn std:

let import = ImportInfo {
module: "httpx_fake".to_owned(),
relative_level: 0,
names: vec![],
span: Span::new(0, 16),
name_spans: Vec::new(),
Expand Down Expand Up @@ -283,6 +285,7 @@ fn skips_nested_submodule_when_root_package_has_py_typed() -> Result<(), Box<dyn

let import = ImportInfo {
module: "sqlalchemy_fake.orm".to_owned(),
relative_level: 0,
names: vec!["Session".to_owned()],
span: Span::new(0, 32),
name_spans: Vec::new(),
Expand Down Expand Up @@ -338,6 +341,7 @@ fn skips_flat_file_submodule_when_root_package_has_py_typed(

let import = ImportInfo {
module: "pydantic_ai_fake.direct".to_owned(),
relative_level: 0,
names: vec!["model_request".to_owned()],
span: Span::new(0, 40),
name_spans: Vec::new(),
Expand Down Expand Up @@ -390,6 +394,7 @@ fn skips_deeper_nested_submodule_when_root_package_has_py_typed(

let import = ImportInfo {
module: "deeppkg_fake.sub.deep".to_owned(),
relative_level: 0,
names: vec!["helper".to_owned()],
span: Span::new(0, 40),
name_spans: Vec::new(),
Expand Down Expand Up @@ -441,6 +446,7 @@ fn skips_httpx_underscore_flat_submodule_when_root_has_py_typed(

let import = ImportInfo {
module: "httpx_fake._client".to_owned(),
relative_level: 0,
names: vec!["Client".to_owned()],
span: Span::new(0, 34),
name_spans: Vec::new(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ mod tests {
) -> ImportInfo {
ImportInfo {
module: module.to_owned(),
relative_level: 0,
names: vec![],
span: Span::new(0, span_end),
name_spans: Vec::new(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Tests for [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY
// Edge cases for the enum literal-expansion equivalence of
// [TYPEINF-SUBTYPING-UNION] (`assignment_compatibility/enum_expand.rs`,
// GitHub #374): membership subtleties the base complete/partial-union tests
// in `assignment_compatibility_tests.rs` do not cover — single-member enums,
// `nonmember(...)` attributes, and unions naming a different enum's members.

use super::common::*;

#[test]
fn single_member_enum_assigned_to_its_literal_no_diagnostic(
) -> Result<(), Box<dyn std::error::Error>> {
// A one-member enum's single literal IS the complete union.
let source = r#"
from enum import Enum
from typing import Literal

class Single(Enum):
ONLY = 1

def test(s: Single) -> None:
x: Literal[Single.ONLY] = s
"#;
let diags = run(source)?;
let msgs = messages_for(&diags, "assignment_compatibility");
assert!(
msgs.is_empty(),
"Literal[Single.ONLY] is the complete union of a one-member enum; \
should not fire, got: {msgs:?}"
);
Ok(())
}

#[test]
fn nonmember_attribute_does_not_count_toward_completeness() -> Result<(), Box<dyn std::error::Error>>
{
// `nonmember(...)` attributes and sunder/dunder names are not enum
// members, so the union of the two real members is still complete.
let source = r#"
from enum import Enum, nonmember
from typing import Literal

class Answer(Enum):
Yes = 1
No = 2
helper = nonmember(3)

def test(a: Answer) -> None:
x: Literal[Answer.Yes, Answer.No] = a
"#;
let diags = run(source)?;
let msgs = messages_for(&diags, "assignment_compatibility");
assert!(
msgs.is_empty(),
"nonmember attributes are not members; Literal[Yes, No] is complete, got: {msgs:?}"
);
Ok(())
}

#[test]
fn literal_union_of_wrong_enum_still_fires() -> Result<(), Box<dyn std::error::Error>> {
// The union names another enum's members; assigning Answer must fire.
let source = r#"
from enum import Enum
from typing import Literal

class Answer(Enum):
Yes = 1
No = 2

class Other(Enum):
A = 1
B = 2

def test(a: Answer) -> None:
x: Literal[Other.A, Other.B] = a
"#;
let diags = run(source)?;
let msgs = messages_for(&diags, "assignment_compatibility");
assert!(
!msgs.is_empty(),
"a literal union of a DIFFERENT enum's members must reject the value, got no diagnostic"
);
Ok(())
}
2 changes: 2 additions & 0 deletions crates/basilisk-checker/tests/checker_rules_a_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ mod annotations_typeexpr;
mod assignment_call_synthesis;
#[path = "checker/assignment_compatibility_tests.rs"]
mod assignment_compatibility;
#[path = "checker/assignment_compatibility_2_tests.rs"]
mod assignment_compatibility_2;
#[path = "checker/callables_annotation_tests.rs"]
mod callables_annotation;
#[path = "checker/calls_argument_type_tests.rs"]
Expand Down
1 change: 1 addition & 0 deletions crates/basilisk-checker/tests/import_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ pub fn module_with_plain_imports(modules: &[&str]) -> basilisk_resolver::Resolve
.iter()
.map(|module| basilisk_resolver::ImportInfo {
module: (*module).to_owned(),
relative_level: 0,
names: vec![],
span: basilisk_resolver::Span::new(0, 0),
name_spans: Vec::new(),
Expand Down
Loading
Loading