Skip to content

Commit 6cc2d02

Browse files
authored
[red-knot] Support stub packages (#17204)
## Summary This PR adds support for stub packages, except for partial stub packages (a stub package is always considered non-partial). I read the specification at [typing.python.org/en/latest/spec/distributing.html#stub-only-packages](https://typing.python.org/en/latest/spec/distributing.html#stub-only-packages) but I found it lacking some details, especially on how to handle namespace packages or when the regular and stub packages disagree on whether they're namespace packages. I tried to document my decisions in the mdtests where the specification isn't clear and compared the behavior to Pyright. Mypy seems to only support stub packages in the venv folder. At least, it never picked up my stub packages otherwise. I decided not to spend too much time fighting mypyp, which is why I focused the comparison around Pyright Closes #16612 ## Test plan Added mdtests
1 parent c12c76e commit 6cc2d02

4 files changed

Lines changed: 442 additions & 35 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
# Stub packages
2+
3+
Stub packages are packages named `<package>-stubs` that provide typing stubs for `<package>`. See
4+
[specification](https://typing.python.org/en/latest/spec/distributing.html#stub-only-packages).
5+
6+
## Simple stub
7+
8+
```toml
9+
[environment]
10+
extra-paths = ["/packages"]
11+
```
12+
13+
`/packages/foo-stubs/__init__.pyi`:
14+
15+
```pyi
16+
class Foo:
17+
name: str
18+
age: int
19+
```
20+
21+
`/packages/foo/__init__.py`:
22+
23+
```py
24+
class Foo: ...
25+
```
26+
27+
`main.py`:
28+
29+
```py
30+
from foo import Foo
31+
32+
reveal_type(Foo().name) # revealed: str
33+
```
34+
35+
## Stubs only
36+
37+
The regular package isn't required for type checking.
38+
39+
```toml
40+
[environment]
41+
extra-paths = ["/packages"]
42+
```
43+
44+
`/packages/foo-stubs/__init__.pyi`:
45+
46+
```pyi
47+
class Foo:
48+
name: str
49+
age: int
50+
```
51+
52+
`main.py`:
53+
54+
```py
55+
from foo import Foo
56+
57+
reveal_type(Foo().name) # revealed: str
58+
```
59+
60+
## `-stubs` named module
61+
62+
A module named `<module>-stubs` isn't a stub package.
63+
64+
```toml
65+
[environment]
66+
extra-paths = ["/packages"]
67+
```
68+
69+
`/packages/foo-stubs.pyi`:
70+
71+
```pyi
72+
class Foo:
73+
name: str
74+
age: int
75+
```
76+
77+
`main.py`:
78+
79+
```py
80+
from foo import Foo # error: [unresolved-import]
81+
82+
reveal_type(Foo().name) # revealed: Unknown
83+
```
84+
85+
## Namespace package in different search paths
86+
87+
A namespace package with multiple stub packages spread over multiple search paths.
88+
89+
```toml
90+
[environment]
91+
extra-paths = ["/stubs1", "/stubs2", "/packages"]
92+
```
93+
94+
`/stubs1/shapes-stubs/polygons/pentagon.pyi`:
95+
96+
```pyi
97+
class Pentagon:
98+
sides: int
99+
area: float
100+
```
101+
102+
`/stubs2/shapes-stubs/polygons/hexagon.pyi`:
103+
104+
```pyi
105+
class Hexagon:
106+
sides: int
107+
area: float
108+
```
109+
110+
`/packages/shapes/polygons/pentagon.py`:
111+
112+
```py
113+
class Pentagon: ...
114+
```
115+
116+
`/packages/shapes/polygons/hexagon.py`:
117+
118+
```py
119+
class Hexagon: ...
120+
```
121+
122+
`main.py`:
123+
124+
```py
125+
from shapes.polygons.hexagon import Hexagon
126+
from shapes.polygons.pentagon import Pentagon
127+
128+
reveal_type(Pentagon().sides) # revealed: int
129+
reveal_type(Hexagon().area) # revealed: int | float
130+
```
131+
132+
## Inconsistent stub packages
133+
134+
Stub packages where one is a namespace package and the other is a regular package. Module resolution
135+
should stop after the first non-namespace stub package. This matches Pyright's behavior.
136+
137+
```toml
138+
[environment]
139+
extra-paths = ["/stubs1", "/stubs2", "/packages"]
140+
```
141+
142+
`/stubs1/shapes-stubs/__init__.pyi`:
143+
144+
```pyi
145+
```
146+
147+
`/stubs1/shapes-stubs/polygons/__init__.pyi`:
148+
149+
```pyi
150+
```
151+
152+
`/stubs1/shapes-stubs/polygons/pentagon.pyi`:
153+
154+
```pyi
155+
class Pentagon:
156+
sides: int
157+
area: float
158+
```
159+
160+
`/stubs2/shapes-stubs/polygons/hexagon.pyi`:
161+
162+
```pyi
163+
class Hexagon:
164+
sides: int
165+
area: float
166+
```
167+
168+
`/packages/shapes/polygons/pentagon.py`:
169+
170+
```py
171+
class Pentagon: ...
172+
```
173+
174+
`/packages/shapes/polygons/hexagon.py`:
175+
176+
```py
177+
class Hexagon: ...
178+
```
179+
180+
`main.py`:
181+
182+
```py
183+
from shapes.polygons.pentagon import Pentagon
184+
from shapes.polygons.hexagon import Hexagon # error: [unresolved-import]
185+
186+
reveal_type(Pentagon().sides) # revealed: int
187+
reveal_type(Hexagon().area) # revealed: Unknown
188+
```
189+
190+
## Namespace stubs for non-namespace package
191+
192+
The runtime package is a regular package but the stubs are namespace packages. Pyright skips the
193+
stub package if the "regular" package isn't a namespace package. I'm not aware that the behavior
194+
here is specified, and using the stubs without probing the runtime package first requires slightly
195+
fewer lookups.
196+
197+
```toml
198+
[environment]
199+
extra-paths = ["/packages"]
200+
```
201+
202+
`/packages/shapes-stubs/polygons/pentagon.pyi`:
203+
204+
```pyi
205+
class Pentagon:
206+
sides: int
207+
area: float
208+
```
209+
210+
`/packages/shapes-stubs/polygons/hexagon.pyi`:
211+
212+
```pyi
213+
class Hexagon:
214+
sides: int
215+
area: float
216+
```
217+
218+
`/packages/shapes/__init__.py`:
219+
220+
```py
221+
```
222+
223+
`/packages/shapes/polygons/__init__.py`:
224+
225+
```py
226+
```
227+
228+
`/packages/shapes/polygons/pentagon.py`:
229+
230+
```py
231+
class Pentagon: ...
232+
```
233+
234+
`/packages/shapes/polygons/hexagon.py`:
235+
236+
```py
237+
class Hexagon: ...
238+
```
239+
240+
`main.py`:
241+
242+
```py
243+
from shapes.polygons.pentagon import Pentagon
244+
from shapes.polygons.hexagon import Hexagon
245+
246+
reveal_type(Pentagon().sides) # revealed: int
247+
reveal_type(Hexagon().area) # revealed: int | float
248+
```
249+
250+
## Stub package using `__init__.py` over `.pyi`
251+
252+
It's recommended that stub packages use `__init__.pyi` files over `__init__.py` but it doesn't seem
253+
to be an enforced convention. At least, Pyright is fine with the following.
254+
255+
```toml
256+
[environment]
257+
extra-paths = ["/packages"]
258+
```
259+
260+
`/packages/shapes-stubs/__init__.py`:
261+
262+
```py
263+
class Pentagon:
264+
sides: int
265+
area: float
266+
267+
class Hexagon:
268+
sides: int
269+
area: float
270+
```
271+
272+
`/packages/shapes/__init__.py`:
273+
274+
```py
275+
class Pentagon: ...
276+
class Hexagon: ...
277+
```
278+
279+
`main.py`:
280+
281+
```py
282+
from shapes import Hexagon, Pentagon
283+
284+
reveal_type(Pentagon().sides) # revealed: int
285+
reveal_type(Hexagon().area) # revealed: int | float
286+
```

crates/red_knot_python_semantic/src/module_resolver/module.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ impl ModuleKind {
9696
pub const fn is_package(self) -> bool {
9797
matches!(self, ModuleKind::Package)
9898
}
99+
pub const fn is_module(self) -> bool {
100+
matches!(self, ModuleKind::Module)
101+
}
99102
}
100103

101104
/// Enumeration of various core stdlib modules in which important types are located

crates/red_knot_python_semantic/src/module_resolver/path.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ impl ModulePath {
116116
| SearchPathInner::SitePackages(search_path)
117117
| SearchPathInner::Editable(search_path) => {
118118
let absolute_path = search_path.join(relative_path);
119+
119120
system_path_to_file(resolver.db.upcast(), absolute_path.join("__init__.py")).is_ok()
120-
|| system_path_to_file(resolver.db.upcast(), absolute_path.join("__init__.py"))
121+
|| system_path_to_file(resolver.db.upcast(), absolute_path.join("__init__.pyi"))
121122
.is_ok()
122123
}
123124
SearchPathInner::StandardLibraryCustom(search_path) => {
@@ -632,6 +633,19 @@ impl PartialEq<SearchPath> for VendoredPathBuf {
632633
}
633634
}
634635

636+
impl fmt::Display for SearchPath {
637+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
638+
match &*self.0 {
639+
SearchPathInner::Extra(system_path_buf)
640+
| SearchPathInner::FirstParty(system_path_buf)
641+
| SearchPathInner::SitePackages(system_path_buf)
642+
| SearchPathInner::Editable(system_path_buf)
643+
| SearchPathInner::StandardLibraryCustom(system_path_buf) => system_path_buf.fmt(f),
644+
SearchPathInner::StandardLibraryVendored(vendored_path_buf) => vendored_path_buf.fmt(f),
645+
}
646+
}
647+
}
648+
635649
#[cfg(test)]
636650
mod tests {
637651
use ruff_db::Db;

0 commit comments

Comments
 (0)