Skip to content

Commit d11b254

Browse files
committed
Add tests for case-sensitive module resolution
1 parent 37fbe58 commit d11b254

File tree

4 files changed

+154
-20
lines changed

4 files changed

+154
-20
lines changed
Lines changed: 126 additions & 0 deletions
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 A:
17+
x: int = 1
18+
```
19+
20+
```python
21+
from a import A
22+
23+
reveal_type(A().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 A:
34+
x: int = 1
35+
```
36+
37+
```python
38+
# error: [unresolved-import]
39+
from a import A
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 A:
56+
x: int = 1
57+
```
58+
59+
`/search-2/a.py`:
60+
61+
```py
62+
class A:
63+
x: str = "test"
64+
```
65+
66+
```python
67+
from a import A as ALower
68+
from A import A as AUpper
69+
70+
reveal_type(AUpper().x) # revealed: int
71+
reveal_type(ALower().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 A:
85+
x: int = 1
86+
```
87+
88+
`correctly_cased.py`:
89+
90+
```python
91+
from db.a import A
92+
93+
reveal_type(A().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 A
103+
104+
# error: [unresolved-import]
105+
from DB.A import A
106+
107+
# error: [unresolved-import]
108+
from db.A import A
109+
```
110+
111+
## Incorrectly 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 A:
120+
x: int = 1
121+
```
122+
123+
```python
124+
# error: [unresolved-import]
125+
from a import A
126+
```

crates/red_knot_test/src/config.rs

Lines changed: 12 additions & 2 deletions
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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, ParseDiagnostic};
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,11 +118,11 @@ fn run_test(
118118
}
119119

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

125-
let full_path = embedded.full_path(&project_root);
125+
let full_path = SystemPath::absolute(embedded.relative_path(), &project_root);
126126

127127
if let Some(ref typeshed_path) = custom_typeshed_path {
128128
if let Ok(relative_path) = full_path.strip_prefix(typeshed_path.join("stdlib")) {
@@ -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

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::{borrow::Cow, collections::hash_map::Entry};
22

33
use anyhow::bail;
4-
use ruff_db::system::{SystemPath, SystemPathBuf};
4+
use ruff_db::system::SystemPath;
55
use rustc_hash::FxHashMap;
66

77
use ruff_index::{newtype_index, IndexVec};
@@ -282,15 +282,6 @@ impl EmbeddedFile<'_> {
282282
pub(crate) fn relative_path(&self) -> &str {
283283
self.path.as_str()
284284
}
285-
286-
pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf {
287-
let relative_path = self.relative_path();
288-
if relative_path.starts_with('/') {
289-
SystemPathBuf::from(relative_path)
290-
} else {
291-
project_root.join(relative_path)
292-
}
293-
}
294285
}
295286

296287
#[derive(Debug)]
@@ -606,10 +597,13 @@ impl<'s> Parser<'s> {
606597
}
607598

608599
if let Some(explicit_path) = self.explicit_path {
609-
if !lang.is_empty()
600+
let expected_extension = if lang == "python" { "py" } else { lang };
601+
602+
if !expected_extension.is_empty()
610603
&& lang != "text"
611-
&& explicit_path.contains('.')
612-
&& !explicit_path.ends_with(&format!(".{lang}"))
604+
&& !SystemPath::new(explicit_path)
605+
.extension()
606+
.is_none_or(|extension| extension.eq_ignore_ascii_case(expected_extension))
613607
{
614608
bail!(
615609
"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)