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
3 changes: 3 additions & 0 deletions crates/pyrefly_config/src/error_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ pub enum ErrorKind {
/// only re-exported when redundantly aliased (`from x import y as y`),
/// listed in `__all__`, or brought in via a wildcard import.
ImplicitReexport,
/// An unqualified import that resolves only relative to the importing file.
ImplicitRelativeImport,
/// An attribute was implicitly defined by assignment to `self` in a method that we
/// do not recognize as always executing (we recognize constructors and some test setup
/// methods).
Expand Down Expand Up @@ -568,6 +570,7 @@ impl ErrorKind {
ErrorKind::ImplicitBool => Severity::Ignore,
ErrorKind::ImplicitImport => Severity::Warn,
ErrorKind::ImplicitReexport => Severity::Ignore,
ErrorKind::ImplicitRelativeImport => Severity::Warn,
ErrorKind::ImplicitlyDefinedAttribute => Severity::Ignore,
ErrorKind::IncompatibleComparison => Severity::Ignore,
ErrorKind::InvalidAbstractMethod => Severity::Ignore,
Expand Down
16 changes: 15 additions & 1 deletion crates/pyrefly_config/src/migration/pyright.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ impl RuleOverrides {
// Import rules
add(
self.report_implicit_relative_import,
ErrorKind::MissingImport,
ErrorKind::ImplicitRelativeImport,
);

// Type argument rules
Expand Down Expand Up @@ -801,6 +801,20 @@ include = ["basedpyright.py"]
);
}

#[test]
fn test_implicit_relative_import_maps_to_dedicated_error_kind() -> anyhow::Result<()> {
let raw_file = r#"{"reportImplicitRelativeImport": "none"}"#;
let config = serde_json::from_str::<PyrightConfig>(raw_file)?.convert();
let errors = config.root.errors.as_ref().unwrap();

assert_eq!(
errors.severity(ErrorKind::ImplicitRelativeImport),
Severity::Ignore
);
assert_eq!(errors.severity(ErrorKind::MissingImport), Severity::Error);
Ok(())
}

#[test]
fn test_report_trailing_commas() -> anyhow::Result<()> {
let raw_file = r#"
Expand Down
109 changes: 108 additions & 1 deletion pyrefly/lib/module/finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ static STDLIB_SUGGESTION_CACHE: LazyLock<LockedMap<ModuleName, Option<ModuleName
LazyLock::new(LockedMap::new);

use crate::config::config::ConfigFile;
use crate::config::config::FallbackSearchPath;
use crate::module::bundled::BundledStub;
use crate::module::third_party::get_bundled_third_party;
use crate::module::typeshed::typeshed;
Expand Down Expand Up @@ -550,7 +551,14 @@ pub fn find_import_internal(
timing,
)
{
path
if matches!(
config.fallback_search_path,
FallbackSearchPath::DirectoryRelative(_)
) {
path.with_error(FindError::ImplicitRelativeImport(module))
} else {
path
}
} else if let Some(path) = find_module(
module,
config.site_package_path(),
Expand Down Expand Up @@ -727,8 +735,10 @@ fn suggest_stdlib_import_uncached(missing: ModuleName) -> Option<ModuleName> {
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;

use pyrefly_config::config::ConfigSource;
use pyrefly_config::config::DirectoryRelativeFallbackSearchPathCache;
use pyrefly_config::environment::environment::PythonEnvironment;
use pyrefly_config::environment::interpreters::Interpreters;
use pyrefly_python::module_path::ModulePathDetails;
Expand Down Expand Up @@ -2777,6 +2787,103 @@ mod tests {
config
}

#[test]
fn test_directory_relative_fallback_reports_implicit_import() {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path();
TestPath::setup_test_directory(
root,
vec![TestPath::dir(
"pkg",
vec![TestPath::file("main.py"), TestPath::file("helper.py")],
)],
);
let mut config = get_config(ConfigSource::File(root.join("pyrefly.toml")));
config.fallback_search_path = FallbackSearchPath::DirectoryRelative(
DirectoryRelativeFallbackSearchPathCache::new(Some(root.to_path_buf())),
);
let module = ModuleName::from_str("helper");
let origin = ModulePath::filesystem(root.join("pkg/main.py"));

let result = find_import_filtered(
&config,
module,
Some(&origin),
None,
&DirEntryCache::new(),
None,
);

assert_eq!(
result,
FindingOrError::Finding(Finding {
finding: ModulePath::filesystem(root.join("pkg/helper.py")),
error: Some(FindError::ImplicitRelativeImport(module)),
})
);
}

#[test]
fn test_explicit_fallback_does_not_report_implicit_import() {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path();
TestPath::setup_test_directory(root, vec![TestPath::file("helper.py")]);
let mut config = get_config(ConfigSource::File(root.join("pyrefly.toml")));
config.fallback_search_path =
FallbackSearchPath::Explicit(Arc::new(vec![root.to_path_buf()]));
let origin = ModulePath::filesystem(root.join("pkg/main.py"));

let result = find_import_filtered(
&config,
ModuleName::from_str("helper"),
Some(&origin),
None,
&DirEntryCache::new(),
None,
);

assert_eq!(
result,
FindingOrError::new_finding(ModulePath::filesystem(root.join("helper.py")))
);
}

#[test]
fn test_absolute_search_path_precedes_directory_relative_fallback() {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path();
TestPath::setup_test_directory(
root,
vec![
TestPath::dir("src", vec![TestPath::file("helper.py")]),
TestPath::dir(
"pkg",
vec![TestPath::file("main.py"), TestPath::file("helper.py")],
),
],
);
let mut config = get_config(ConfigSource::File(root.join("pyrefly.toml")));
config.search_path_from_file = vec![root.join("src")];
config.fallback_search_path = FallbackSearchPath::DirectoryRelative(
DirectoryRelativeFallbackSearchPathCache::new(Some(root.to_path_buf())),
);
let origin = ModulePath::filesystem(root.join("pkg/main.py"));

let result = find_import_filtered(
&config,
ModuleName::from_str("helper"),
Some(&origin),
None,
&DirEntryCache::new(),
None,
);

assert_eq!(
result,
FindingOrError::new_finding(ModulePath::filesystem(root.join("src/helper.py")))
);
}

/// A first-party root plus a site package directory holding representative
/// package layouts for testing untyped import handling.
fn untyped_imports_config(root: &Path, replace_untyped: &[&str]) -> ConfigFile {
Expand Down
13 changes: 13 additions & 0 deletions pyrefly/lib/state/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ use crate::state::state::TransactionTimingCounters;

#[derive(Debug, Clone, Dupe, PartialEq, Eq)]
pub enum FindError {
/// This module resolved only through a directory-relative fallback search.
ImplicitRelativeImport(ModuleName),
/// This module could not be found, and we should emit an error
MissingImport(ModuleName, Arc<Vec1<String>>),
/// This import could not be found, but the user configured it to be ignored
Expand Down Expand Up @@ -94,6 +96,16 @@ impl FindError {

pub fn display(&self) -> (Option<Box<dyn Fn() -> ErrorContext + '_>>, Vec1<String>) {
match self {
Self::ImplicitRelativeImport(module) => (
None,
vec1![
format!(
"Import `{module}` is implicitly relative and may fail when this file is imported as part of a package"
),
"Use an explicit relative import or the full absolute package path instead"
.to_owned(),
],
),
Self::MissingImport(module, err) => {
let mut lines = (**err).clone();
// Compute suggestion lazily at display time, using global cache
Expand Down Expand Up @@ -128,6 +140,7 @@ impl FindError {

pub fn kind(&self) -> Option<ErrorKind> {
match self {
Self::ImplicitRelativeImport(..) => Some(ErrorKind::ImplicitRelativeImport),
Self::MissingImport(..) => Some(ErrorKind::MissingImport),
Self::MissingSource(..) => Some(ErrorKind::MissingSource),
Self::MissingSourceForStubs(..) => Some(ErrorKind::MissingSourceForStubs),
Expand Down
45 changes: 45 additions & 0 deletions pyrefly/lib/test/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ use tempfile::TempDir;
use crate::commands::config_finder::default_config_finder;
use crate::config::config::ConfigFile;
use crate::config::config::ConfigSource;
use crate::config::error_kind::ErrorKind;
use crate::config::error_kind::Severity;
use crate::config::finder::ConfigFinder;
use crate::error::error::print_errors;
use crate::lsp::non_wasm::server::resolve_export_location;
Expand All @@ -53,6 +55,42 @@ use crate::state::require::RequireLevels;
use crate::state::state::State;
use crate::test::util::TestEnv;

#[test]
fn test_directory_relative_import_emits_dedicated_diagnostic() {
let tdir = TempDir::new().unwrap();
let root = tdir.path();
let package = root.join("pkg");
fs::create_dir(&package).unwrap();
fs::write(root.join(ConfigFile::PYREFLY_FILE_NAME), "").unwrap();
fs::write(package.join("helper.py"), "answer: int = 42").unwrap();
let main_path = package.join("main.py");
fs::write(&main_path, "import helper\nvalue: int = helper.answer\n").unwrap();

let mut config = ConfigFile {
source: ConfigSource::File(root.join(ConfigFile::PYREFLY_FILE_NAME)),
enable_fallback_search_path: true,
..Default::default()
};
config.python_environment.set_empty_to_default();
config.interpreters.skip_interpreter_query = true;
config.configure();
let config = ArcId::new(config);
let handle = Handle::new(
ModuleName::from_str("pkg.main"),
ModulePath::filesystem(main_path),
config.get_sys_info(),
);
let state = State::new(ConfigFinder::new_constant(config), TEST_THREAD_COUNT);
let mut transaction = state.new_transaction(Require::Errors, None);

transaction.run(&[handle.dupe()], Require::Errors, None);
let errors = transaction.get_errors([&handle]).collect_display_errors();

assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
assert_eq!(errors[0].error_kind(), ErrorKind::ImplicitRelativeImport);
assert!(errors[0].msg().contains("implicitly relative"));
}

#[derive(Debug)]
struct MutableShapeExtensionsSourceDb {
sys_info: SysInfo,
Expand Down Expand Up @@ -447,6 +485,13 @@ def f(x: Float[Tensor, "batch channels"]) -> None:
enable_fallback_search_path: true,
..Default::default()
};
// This test intentionally models third-party imports with the directory-relative
// fallback. Keep its assertions focused on origin-sensitive tensor shape state.
config
.root
.errors
.get_or_insert_default()
.set_error_severity(ErrorKind::ImplicitRelativeImport, Severity::Ignore);
config.root.jaxtyping = Some(true);
config.python_environment.set_empty_to_default();
config.interpreters.skip_interpreter_query = true;
Expand Down
1 change: 1 addition & 0 deletions scripts/error_presets.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"implicit-bool": ["all"],
"implicit-import": ["legacy", "default", "strict", "all"],
"implicit-reexport": ["all"],
"implicit-relative-import": ["legacy", "default", "strict", "all"],
"implicitly-defined-attribute": ["all"],
"incompatible-comparison": ["all"],
"incompatible-overload-residual": ["legacy", "default", "strict", "all"],
Expand Down
19 changes: 19 additions & 0 deletions website/docs/error-kinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,25 @@ from bar import a # error: `a` is not exported from module `bar`
from foo import a as a # or add `a` to `__all__`
```

## implicit-relative-import

Default severity: `warn`

This error is emitted when an unqualified import resolves only by searching
relative to the importing file. Such an import can work when a file is run as a
script but fail when the same file is imported as part of a package.

Use an explicit relative import or the module's full absolute package path:

```python
# pkg/main.py
import helper # implicit-relative-import

# Fix:
from . import helper
# or: import pkg.helper
```

## implicitly-defined-attribute

Default severity: `ignore`
Expand Down
2 changes: 1 addition & 1 deletion website/docs/migrate/pyright/diagnostics-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ of them:
| BasedPyright rule | Pyrefly |
| --------------------------------------- | -------------------------------- |
| `reportExplicitAny` | `explicit-any` |
| `reportImplicitRelativeImport` | `missing-import` |
| `reportImplicitRelativeImport` | `implicit-relative-import` |
| `reportIncompatibleUnannotatedOverride` | `bad-override-mutable-attribute` |

These are a separate compatibility layer; an upstream Pyright config will not
Expand Down
Loading