Skip to content

Commit 48f906e

Browse files
authored
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.
1 parent ebd172e commit 48f906e

File tree

4 files changed

+156
-10
lines changed

4 files changed

+156
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Case Sensitive Imports
2+
3+
TODO: This test should use the real file system instead of the memory file system.
4+
5+
Python's import system is case-sensitive even on case-insensitive file system. This means, importing
6+
a module `a` should fail if the file in the search paths is named `A.py`. See
7+
[PEP 235](https://peps.python.org/pep-0235/).
8+
9+
## Correct casing
10+
11+
Importing a module where the name matches the file name's casing should succeed.
12+
13+
`a.py`:
14+
15+
```py
16+
class Foo:
17+
x: int = 1
18+
```
19+
20+
```python
21+
from a import Foo
22+
23+
reveal_type(Foo().x) # revealed: int
24+
```
25+
26+
## Incorrect casing
27+
28+
Importing a module where the name does not match the file name's casing should fail.
29+
30+
`A.py`:
31+
32+
```py
33+
class Foo:
34+
x: int = 1
35+
```
36+
37+
```python
38+
# error: [unresolved-import]
39+
from a import Foo
40+
```
41+
42+
## Multiple search paths with different cased modules
43+
44+
The resolved module is the first matching the file name's casing but Python falls back to later
45+
search paths if the file name's casing does not match.
46+
47+
```toml
48+
[environment]
49+
extra-paths = ["/search-1", "/search-2"]
50+
```
51+
52+
`/search-1/A.py`:
53+
54+
```py
55+
class Foo:
56+
x: int = 1
57+
```
58+
59+
`/search-2/a.py`:
60+
61+
```py
62+
class Bar:
63+
x: str = "test"
64+
```
65+
66+
```python
67+
from A import Foo
68+
from a import Bar
69+
70+
reveal_type(Foo().x) # revealed: int
71+
reveal_type(Bar().x) # revealed: str
72+
```
73+
74+
## Intermediate segments
75+
76+
`db/__init__.py`:
77+
78+
```py
79+
```
80+
81+
`db/a.py`:
82+
83+
```py
84+
class Foo:
85+
x: int = 1
86+
```
87+
88+
`correctly_cased.py`:
89+
90+
```python
91+
from db.a import Foo
92+
93+
reveal_type(Foo().x) # revealed: int
94+
```
95+
96+
Imports where some segments are incorrectly cased should fail.
97+
98+
`incorrectly_cased.py`:
99+
100+
```python
101+
# error: [unresolved-import]
102+
from DB.a import Foo
103+
104+
# error: [unresolved-import]
105+
from DB.A import Foo
106+
107+
# error: [unresolved-import]
108+
from db.A import Foo
109+
```
110+
111+
## Incorrect extension casing
112+
113+
The extension of imported python modules must be `.py` or `.pyi` but not `.PY` or `Py` or any
114+
variant where some characters are uppercase.
115+
116+
`a.PY`:
117+
118+
```py
119+
class Foo:
120+
x: int = 1
121+
```
122+
123+
```python
124+
# error: [unresolved-import]
125+
from a import Foo
126+
```

crates/red_knot_test/src/config.rs

+12-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
1111
use anyhow::Context;
1212
use red_knot_python_semantic::PythonPlatform;
13+
use ruff_db::system::{SystemPath, SystemPathBuf};
1314
use ruff_python_ast::PythonVersion;
1415
use serde::Deserialize;
1516

@@ -36,11 +37,17 @@ impl MarkdownTestConfig {
3637
.and_then(|env| env.python_platform.clone())
3738
}
3839

39-
pub(crate) fn typeshed(&self) -> Option<&str> {
40+
pub(crate) fn typeshed(&self) -> Option<&SystemPath> {
4041
self.environment
4142
.as_ref()
4243
.and_then(|env| env.typeshed.as_deref())
4344
}
45+
46+
pub(crate) fn extra_paths(&self) -> Option<&[SystemPathBuf]> {
47+
self.environment
48+
.as_ref()
49+
.and_then(|env| env.extra_paths.as_deref())
50+
}
4451
}
4552

4653
#[derive(Deserialize, Debug, Default, Clone)]
@@ -53,7 +60,10 @@ pub(crate) struct Environment {
5360
pub(crate) python_platform: Option<PythonPlatform>,
5461

5562
/// Path to a custom typeshed directory.
56-
pub(crate) typeshed: Option<String>,
63+
pub(crate) typeshed: Option<SystemPathBuf>,
64+
65+
/// Additional search paths to consider when resolving modules.
66+
pub(crate) extra_paths: Option<Vec<SystemPathBuf>>,
5767
}
5868

5969
#[derive(Deserialize, Debug, Clone)]

crates/red_knot_test/src/lib.rs

+9-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ruff_db::diagnostic::{DisplayDiagnosticConfig, OldDiagnosticTrait, OldParseD
99
use ruff_db::files::{system_path_to_file, File, Files};
1010
use ruff_db::panic::catch_unwind;
1111
use ruff_db::parsed::parsed_module;
12-
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
12+
use ruff_db::system::{DbWithTestSystem, SystemPath, SystemPathBuf};
1313
use ruff_db::testing::{setup_logging, setup_logging_with_filter};
1414
use ruff_source_file::{LineIndex, OneIndexed};
1515
use std::fmt::Write;
@@ -106,7 +106,7 @@ fn run_test(
106106
) -> Result<(), Failures> {
107107
let project_root = db.project_root().to_path_buf();
108108
let src_path = SystemPathBuf::from("/src");
109-
let custom_typeshed_path = test.configuration().typeshed().map(SystemPathBuf::from);
109+
let custom_typeshed_path = test.configuration().typeshed().map(SystemPath::to_path_buf);
110110
let mut typeshed_files = vec![];
111111
let mut has_custom_versions_file = false;
112112

@@ -118,8 +118,8 @@ fn run_test(
118118
}
119119

120120
assert!(
121-
matches!(embedded.lang, "py" | "pyi" | "text"),
122-
"Supported file types are: py, pyi, text"
121+
matches!(embedded.lang, "py" | "pyi" | "python" | "text"),
122+
"Supported file types are: py (or python), pyi, text, and ignore"
123123
);
124124

125125
let full_path = embedded.full_path(&project_root);
@@ -178,7 +178,11 @@ fn run_test(
178178
python_platform: test.configuration().python_platform().unwrap_or_default(),
179179
search_paths: SearchPathSettings {
180180
src_roots: vec![src_path],
181-
extra_paths: vec![],
181+
extra_paths: test
182+
.configuration()
183+
.extra_paths()
184+
.unwrap_or_default()
185+
.to_vec(),
182186
custom_typeshed: custom_typeshed_path,
183187
python_path: PythonPath::KnownSitePackages(vec![]),
184188
},

crates/red_knot_test/src/parser.rs

+9-3
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,10 @@ impl EmbeddedFile<'_> {
283283
self.path.as_str()
284284
}
285285

286+
/// Returns the full path using unix file-path convention.
286287
pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf {
288+
// Don't use `SystemPath::absolute` here because it's platform dependent
289+
// and we want to use unix file-path convention.
287290
let relative_path = self.relative_path();
288291
if relative_path.starts_with('/') {
289292
SystemPathBuf::from(relative_path)
@@ -606,10 +609,13 @@ impl<'s> Parser<'s> {
606609
}
607610

608611
if let Some(explicit_path) = self.explicit_path {
609-
if !lang.is_empty()
612+
let expected_extension = if lang == "python" { "py" } else { lang };
613+
614+
if !expected_extension.is_empty()
610615
&& lang != "text"
611-
&& explicit_path.contains('.')
612-
&& !explicit_path.ends_with(&format!(".{lang}"))
616+
&& !SystemPath::new(explicit_path)
617+
.extension()
618+
.is_none_or(|extension| extension.eq_ignore_ascii_case(expected_extension))
613619
{
614620
bail!(
615621
"File extension of test file path `{explicit_path}` in test `{test_name}` does not match language specified `{lang}` of code block"

0 commit comments

Comments
 (0)