Skip to content

Commit 3e40e95

Browse files
committed
[red-knot] Support stub packages
1 parent ffa824e commit 3e40e95

3 files changed

Lines changed: 353 additions & 32 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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+
## Namespace package in different search paths
61+
62+
A namespace package with multiple stub packages spread over multiple search paths.
63+
64+
```toml
65+
[environment]
66+
extra-paths = ["/stubs1", "/stubs2", "/packages"]
67+
```
68+
69+
`/stubs1/shapes-stubs/polygons/pentagon.pyi`:
70+
71+
```pyi
72+
class Pentagon:
73+
sides: int
74+
area: float
75+
```
76+
77+
`/stubs2/shapes-stubs/polygons/hexagon.pyi`:
78+
79+
```pyi
80+
class Hexagon:
81+
sides: int
82+
area: float
83+
```
84+
85+
`/packages/shapes/polygons/pentagon.py`:
86+
87+
```py
88+
class Pentagon: ...
89+
```
90+
91+
`/packages/shapes/polygons/hexagon.py`:
92+
93+
```py
94+
class Hexagon: ...
95+
```
96+
97+
`main.py`:
98+
99+
```py
100+
from shapes.polygons.hexagon import Hexagon
101+
from shapes.polygons.pentagon import Pentagon
102+
103+
reveal_type(Pentagon().sides) # revealed: int
104+
reveal_type(Hexagon().area) # revealed: int | float
105+
```
106+
107+
## Inconsistent stub packages
108+
109+
Stub packages where one is a namespae package and the other is a regular package. Module resolution
110+
should stop after the first non-namespace stub package. This matches pyrights behavior.
111+
112+
```toml
113+
[environment]
114+
extra-paths = ["/stubs1", "/stubs2", "/packages"]
115+
```
116+
117+
`/stubs1/shapes-stubs/__init__.pyi`:
118+
119+
```pyi
120+
```
121+
122+
`/stubs1/shapes-stubs/polygons/__init__.pyi`:
123+
124+
```pyi
125+
```
126+
127+
`/stubs1/shapes-stubs/polygons/pentagon.pyi`:
128+
129+
```pyi
130+
class Pentagon:
131+
sides: int
132+
area: float
133+
```
134+
135+
`/stubs2/shapes-stubs/polygons/hexagon.pyi`:
136+
137+
```pyi
138+
class Hexagon:
139+
sides: int
140+
area: float
141+
```
142+
143+
`/packages/shapes/polygons/pentagon.py`:
144+
145+
```py
146+
class Pentagon: ...
147+
```
148+
149+
`/packages/shapes/polygons/hexagon.py`:
150+
151+
```py
152+
class Hexagon: ...
153+
```
154+
155+
`main.py`:
156+
157+
```py
158+
from shapes.polygons.pentagon import Pentagon
159+
from shapes.polygons.hexagon import Hexagon # error: [unresolved-import]
160+
161+
reveal_type(Pentagon().sides) # revealed: int
162+
reveal_type(Hexagon().area) # revealed: Unknown
163+
```
164+
165+
## Namespace stubs for non-namespace package
166+
167+
The runtime package is a regular package but the stubs are namespace packages. Pyright skips the
168+
stub package if the "regular" package isn't a namespace package. I'm not aware that the behavior
169+
here is specificed, and using the stubs without probing the runtime package first requires slightly
170+
fewer lookups.
171+
172+
```toml
173+
[environment]
174+
extra-paths = ["/packages"]
175+
```
176+
177+
`/packages/shapes-stubs/polygons/pentagon.pyi`:
178+
179+
```pyi
180+
class Pentagon:
181+
sides: int
182+
area: float
183+
```
184+
185+
`/packages/shapes-stubs/polygons/hexagon.pyi`:
186+
187+
```pyi
188+
class Hexagon:
189+
sides: int
190+
area: float
191+
```
192+
193+
`/packages/shapes/__init__.py`:
194+
195+
```py
196+
```
197+
198+
`/packages/shapes/polygons/__init__.py`:
199+
200+
```py
201+
```
202+
203+
`/packages/shapes/polygons/pentagon.py`:
204+
205+
```py
206+
class Pentagon: ...
207+
```
208+
209+
`/packages/shapes/polygons/hexagon.py`:
210+
211+
```py
212+
class Hexagon: ...
213+
```
214+
215+
`main.py`:
216+
217+
```py
218+
from shapes.polygons.pentagon import Pentagon
219+
from shapes.polygons.hexagon import Hexagon
220+
221+
reveal_type(Pentagon().sides) # revealed: int
222+
reveal_type(Hexagon().area) # revealed: int | float
223+
```
224+
225+
## Stub package using `__init__.py` over `.pyi`
226+
227+
It's recommended that stub packages use `__init__.pyi` files over `__init__.py` but it doesn't seem
228+
to be an enforced convention. At least, Pyright is fine with the following.
229+
230+
```toml
231+
[environment]
232+
extra-paths = ["/packages"]
233+
```
234+
235+
`/packages/shapes-stubs/__init__.py`:
236+
237+
```py
238+
class Pentagon:
239+
sides: int
240+
area: float
241+
242+
class Hexagon:
243+
sides: int
244+
area: float
245+
```
246+
247+
`/packages/shapes/__init__.py`:
248+
249+
```py
250+
class Pentagon: ...
251+
class Hexagon: ...
252+
```
253+
254+
`main.py`:
255+
256+
```py
257+
from shapes import Hexagon, Pentagon
258+
259+
reveal_type(Pentagon().sides) # revealed: int
260+
reveal_type(Hexagon().area) # revealed: int | float
261+
```

crates/red_knot_python_semantic/src/module_resolver/path.rs

Lines changed: 2 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) => {

0 commit comments

Comments
 (0)