Add tests for case-sensitive module resolution (#16517)

## Summary

Python's module resolver is case sensitive. 

This PR adds mdtests that assert that our module resolution is case
sensitive.

The tests currently all pass because our in memory file system is case
sensitive.
I'll add support for using the real file system to the mdtest framework
in a separate PR.

This PR also adds support for specifying extra search paths to the
mdtest framework.

## Test Plan
The tests fail when running them using the real file system.
This commit is contained in:
Micha Reiser 2025-03-06 09:19:23 +00:00 committed by GitHub
parent ebd172e732
commit 48f906e06c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 156 additions and 10 deletions

View File

@ -0,0 +1,126 @@
# Case Sensitive Imports
TODO: This test should use the real file system instead of the memory file system.
Python's import system is case-sensitive even on case-insensitive file system. This means, importing
a module `a` should fail if the file in the search paths is named `A.py`. See
[PEP 235](https://peps.python.org/pep-0235/).
## Correct casing
Importing a module where the name matches the file name's casing should succeed.
`a.py`:
```py
class Foo:
x: int = 1
```
```python
from a import Foo
reveal_type(Foo().x) # revealed: int
```
## Incorrect casing
Importing a module where the name does not match the file name's casing should fail.
`A.py`:
```py
class Foo:
x: int = 1
```
```python
# error: [unresolved-import]
from a import Foo
```
## Multiple search paths with different cased modules
The resolved module is the first matching the file name's casing but Python falls back to later
search paths if the file name's casing does not match.
```toml
[environment]
extra-paths = ["/search-1", "/search-2"]
```
`/search-1/A.py`:
```py
class Foo:
x: int = 1
```
`/search-2/a.py`:
```py
class Bar:
x: str = "test"
```
```python
from A import Foo
from a import Bar
reveal_type(Foo().x) # revealed: int
reveal_type(Bar().x) # revealed: str
```
## Intermediate segments
`db/__init__.py`:
```py
```
`db/a.py`:
```py
class Foo:
x: int = 1
```
`correctly_cased.py`:
```python
from db.a import Foo
reveal_type(Foo().x) # revealed: int
```
Imports where some segments are incorrectly cased should fail.
`incorrectly_cased.py`:
```python
# error: [unresolved-import]
from DB.a import Foo
# error: [unresolved-import]
from DB.A import Foo
# error: [unresolved-import]
from db.A import Foo
```
## Incorrect extension casing
The extension of imported python modules must be `.py` or `.pyi` but not `.PY` or `Py` or any
variant where some characters are uppercase.
`a.PY`:
```py
class Foo:
x: int = 1
```
```python
# error: [unresolved-import]
from a import Foo
```

View File

@ -10,6 +10,7 @@
use anyhow::Context; use anyhow::Context;
use red_knot_python_semantic::PythonPlatform; use red_knot_python_semantic::PythonPlatform;
use ruff_db::system::{SystemPath, SystemPathBuf};
use ruff_python_ast::PythonVersion; use ruff_python_ast::PythonVersion;
use serde::Deserialize; use serde::Deserialize;
@ -36,11 +37,17 @@ impl MarkdownTestConfig {
.and_then(|env| env.python_platform.clone()) .and_then(|env| env.python_platform.clone())
} }
pub(crate) fn typeshed(&self) -> Option<&str> { pub(crate) fn typeshed(&self) -> Option<&SystemPath> {
self.environment self.environment
.as_ref() .as_ref()
.and_then(|env| env.typeshed.as_deref()) .and_then(|env| env.typeshed.as_deref())
} }
pub(crate) fn extra_paths(&self) -> Option<&[SystemPathBuf]> {
self.environment
.as_ref()
.and_then(|env| env.extra_paths.as_deref())
}
} }
#[derive(Deserialize, Debug, Default, Clone)] #[derive(Deserialize, Debug, Default, Clone)]
@ -53,7 +60,10 @@ pub(crate) struct Environment {
pub(crate) python_platform: Option<PythonPlatform>, pub(crate) python_platform: Option<PythonPlatform>,
/// Path to a custom typeshed directory. /// Path to a custom typeshed directory.
pub(crate) typeshed: Option<String>, pub(crate) typeshed: Option<SystemPathBuf>,
/// Additional search paths to consider when resolving modules.
pub(crate) extra_paths: Option<Vec<SystemPathBuf>>,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]

View File

@ -9,7 +9,7 @@ use ruff_db::diagnostic::{DisplayDiagnosticConfig, OldDiagnosticTrait, OldParseD
use ruff_db::files::{system_path_to_file, File, Files}; use ruff_db::files::{system_path_to_file, File, Files};
use ruff_db::panic::catch_unwind; use ruff_db::panic::catch_unwind;
use ruff_db::parsed::parsed_module; use ruff_db::parsed::parsed_module;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; use ruff_db::system::{DbWithTestSystem, SystemPath, SystemPathBuf};
use ruff_db::testing::{setup_logging, setup_logging_with_filter}; use ruff_db::testing::{setup_logging, setup_logging_with_filter};
use ruff_source_file::{LineIndex, OneIndexed}; use ruff_source_file::{LineIndex, OneIndexed};
use std::fmt::Write; use std::fmt::Write;
@ -106,7 +106,7 @@ fn run_test(
) -> Result<(), Failures> { ) -> Result<(), Failures> {
let project_root = db.project_root().to_path_buf(); let project_root = db.project_root().to_path_buf();
let src_path = SystemPathBuf::from("/src"); let src_path = SystemPathBuf::from("/src");
let custom_typeshed_path = test.configuration().typeshed().map(SystemPathBuf::from); let custom_typeshed_path = test.configuration().typeshed().map(SystemPath::to_path_buf);
let mut typeshed_files = vec![]; let mut typeshed_files = vec![];
let mut has_custom_versions_file = false; let mut has_custom_versions_file = false;
@ -118,8 +118,8 @@ fn run_test(
} }
assert!( assert!(
matches!(embedded.lang, "py" | "pyi" | "text"), matches!(embedded.lang, "py" | "pyi" | "python" | "text"),
"Supported file types are: py, pyi, text" "Supported file types are: py (or python), pyi, text, and ignore"
); );
let full_path = embedded.full_path(&project_root); let full_path = embedded.full_path(&project_root);
@ -178,7 +178,11 @@ fn run_test(
python_platform: test.configuration().python_platform().unwrap_or_default(), python_platform: test.configuration().python_platform().unwrap_or_default(),
search_paths: SearchPathSettings { search_paths: SearchPathSettings {
src_roots: vec![src_path], src_roots: vec![src_path],
extra_paths: vec![], extra_paths: test
.configuration()
.extra_paths()
.unwrap_or_default()
.to_vec(),
custom_typeshed: custom_typeshed_path, custom_typeshed: custom_typeshed_path,
python_path: PythonPath::KnownSitePackages(vec![]), python_path: PythonPath::KnownSitePackages(vec![]),
}, },

View File

@ -283,7 +283,10 @@ impl EmbeddedFile<'_> {
self.path.as_str() self.path.as_str()
} }
/// Returns the full path using unix file-path convention.
pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf { pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf {
// Don't use `SystemPath::absolute` here because it's platform dependent
// and we want to use unix file-path convention.
let relative_path = self.relative_path(); let relative_path = self.relative_path();
if relative_path.starts_with('/') { if relative_path.starts_with('/') {
SystemPathBuf::from(relative_path) SystemPathBuf::from(relative_path)
@ -606,10 +609,13 @@ impl<'s> Parser<'s> {
} }
if let Some(explicit_path) = self.explicit_path { if let Some(explicit_path) = self.explicit_path {
if !lang.is_empty() let expected_extension = if lang == "python" { "py" } else { lang };
if !expected_extension.is_empty()
&& lang != "text" && lang != "text"
&& explicit_path.contains('.') && !SystemPath::new(explicit_path)
&& !explicit_path.ends_with(&format!(".{lang}")) .extension()
.is_none_or(|extension| extension.eq_ignore_ascii_case(expected_extension))
{ {
bail!( bail!(
"File extension of test file path `{explicit_path}` in test `{test_name}` does not match language specified `{lang}` of code block" "File extension of test file path `{explicit_path}` in test `{test_name}` does not match language specified `{lang}` of code block"