Skip to content

Commit f73b2a9

Browse files
AlexWaygoodGlyphack
authored andcommitted
[red-knot] Improve handling of visibility constraints in external modules when resolving * imports (astral-sh#17286)
1 parent 9eb7f92 commit f73b2a9

File tree

8 files changed

+180
-47
lines changed

8 files changed

+180
-47
lines changed

crates/red_knot_python_semantic/resources/mdtest/import/star.md

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -227,23 +227,23 @@ print((
227227
D,
228228
E,
229229
F,
230-
G, # TODO: could emit diagnostic about being possibly unbound
231-
H,
230+
G, # error: [possibly-unresolved-reference]
231+
H, # error: [possibly-unresolved-reference]
232232
I,
233233
J,
234234
K,
235235
L,
236-
M, # TODO: could emit diagnostic about being possibly unbound
237-
N, # TODO: could emit diagnostic about being possibly unbound
238-
O, # TODO: could emit diagnostic about being possibly unbound
239-
P, # TODO: could emit diagnostic about being possibly unbound
240-
Q, # TODO: could emit diagnostic about being possibly unbound
241-
R, # TODO: could emit diagnostic about being possibly unbound
242-
S, # TODO: could emit diagnostic about being possibly unbound
243-
T, # TODO: could emit diagnostic about being possibly unbound
244-
U, # TODO: could emit diagnostic about being possibly unbound
245-
V, # TODO: could emit diagnostic about being possibly unbound
246-
W, # TODO: could emit diagnostic about being possibly unbound
236+
M, # error: [possibly-unresolved-reference]
237+
N, # error: [possibly-unresolved-reference]
238+
O, # error: [possibly-unresolved-reference]
239+
P, # error: [possibly-unresolved-reference]
240+
Q, # error: [possibly-unresolved-reference]
241+
R, # error: [possibly-unresolved-reference]
242+
S, # error: [possibly-unresolved-reference]
243+
T, # error: [possibly-unresolved-reference]
244+
U, # TODO: could emit [possibly-unresolved-reference here] (https://github.com/astral-sh/ruff/issues/16996)
245+
V, # error: [possibly-unresolved-reference]
246+
W, # error: [possibly-unresolved-reference]
247247
typing,
248248
OrderedDict,
249249
Foo,
@@ -479,15 +479,21 @@ reveal_type(s) # revealed: Unknown
479479
# error: [unresolved-reference]
480480
reveal_type(t) # revealed: Unknown
481481

482-
# TODO: these should all reveal `Unknown | int`.
482+
# TODO: these should all reveal `Unknown | int` and should not emit errors.
483483
# (We don't generally model elsewhere in red-knot that bindings from walruses
484484
# "leak" from comprehension scopes into outer scopes, but we should.)
485485
# See https://github.com/astral-sh/ruff/issues/16954
486+
# error: [unresolved-reference]
486487
reveal_type(g) # revealed: Unknown
488+
# error: [unresolved-reference]
487489
reveal_type(i) # revealed: Unknown
490+
# error: [unresolved-reference]
488491
reveal_type(k) # revealed: Unknown
492+
# error: [unresolved-reference]
489493
reveal_type(m) # revealed: Unknown
494+
# error: [unresolved-reference]
490495
reveal_type(o) # revealed: Unknown
496+
# error: [unresolved-reference]
491497
reveal_type(q) # revealed: Unknown
492498
```
493499

@@ -668,15 +674,15 @@ from exporter import *
668674

669675
reveal_type(X) # revealed: bool
670676

671-
# TODO: should emit error: [unresolved-reference]
677+
# error: [unresolved-reference]
672678
reveal_type(Y) # revealed: Unknown
673679

674-
# TODO: The `*` import should not be considered a redefinition
675-
# of the global variable in this module, as the symbol in
680+
# The `*` import is not considered a redefinition
681+
# of the global variable `Z` in this module, as the symbol in
676682
# the `a` module is in a branch that is statically known
677683
# to be dead code given the `python-version` configuration.
678-
# Thus this should reveal `Literal[True]`.
679-
reveal_type(Z) # revealed: Unknown
684+
# Thus this still reveals `Literal[True]`.
685+
reveal_type(Z) # revealed: Literal[True]
680686
```
681687

682688
### Multiple `*` imports with always-false visibility constraints
@@ -707,8 +713,7 @@ from exporter import *
707713
from exporter import *
708714
from exporter import *
709715

710-
# TODO: should still be `Literal[True]
711-
reveal_type(Z) # revealed: Unknown
716+
reveal_type(Z) # revealed: Literal[True]
712717
```
713718

714719
### Ambiguous visibility constraints
@@ -735,7 +740,7 @@ else:
735740
```py
736741
from exporter import *
737742

738-
# TODO: should have a `[possibly-unresolved-reference]` diagnostic
743+
# error: [possibly-unresolved-reference]
739744
reveal_type(A) # revealed: Unknown | Literal[1]
740745

741746
reveal_type(B) # revealed: Unknown | Literal[2, 3]
@@ -798,16 +803,14 @@ if sys.version_info >= (3, 12):
798803
else:
799804
from exporter import *
800805

801-
# TODO: should have an `[unresolved-reference]` diagnostic
806+
# error: [unresolved-reference]
802807
reveal_type(A) # revealed: Unknown
803-
804-
# TODO: should have a `[possibly-unresolved-reference]` diagnostic
808+
# error: [possibly-unresolved-reference]
805809
reveal_type(B) # revealed: bool
806810

807-
# TODO: should have an `[unresolved-reference]` diagnostic
811+
# error: [unresolved-reference]
808812
reveal_type(A) # revealed: Unknown
809-
810-
# TODO: should have a `[possibly-unresolved-reference]` diagnostic
813+
# error: [possibly-unresolved-reference]
811814
reveal_type(B) # revealed: bool
812815
```
813816

@@ -1065,7 +1068,7 @@ from exporter import *
10651068
reveal_type(X) # revealed: bool
10661069
reveal_type(Y) # revealed: bool
10671070

1068-
# TODO: should error with [unresolved-reference]
1071+
# error: [unresolved-reference]
10691072
reveal_type(Z) # revealed: Unknown
10701073
```
10711074

@@ -1100,7 +1103,7 @@ from exporter import *
11001103
reveal_type(X) # revealed: bool
11011104
reveal_type(Y) # revealed: bool
11021105

1103-
# TODO should have an [unresolved-reference] diagnostic
1106+
# error: [unresolved-reference]
11041107
reveal_type(Z) # revealed: Unknown
11051108
```
11061109

crates/red_knot_python_semantic/resources/mdtest/properties.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,14 @@ reveal_type(c.attr) # revealed: Unknown
196196

197197
## Behind the scenes
198198

199+
> TODO: This test is currently disabled pending
200+
> [an upstream Salsa fix](https://github.com/salsa-rs/salsa/pull/741). Once that has been merged,
201+
> re-enable this test by changing the language codes below back to `py`.
202+
199203
In this section, we trace through some of the steps that make properties work. We start with a
200204
simple class `C` and a property `attr`:
201205

202-
```py
206+
```ignore
203207
class C:
204208
def __init__(self):
205209
self._attr: int = 0
@@ -216,7 +220,7 @@ class C:
216220
Next, we create an instance of `C`. As we have seen above, accessing `attr` on the instance will
217221
return an `int`:
218222

219-
```py
223+
```ignore
220224
c = C()
221225
222226
reveal_type(c.attr) # revealed: int
@@ -226,7 +230,7 @@ Behind the scenes, when we write `c.attr`, the first thing that happens is that
226230
up the symbol `attr` on the meta-type of `c`, i.e. the class `C`. We can emulate this static lookup
227231
using `inspect.getattr_static`, to see that `attr` is actually an instance of the `property` class:
228232

229-
```py
233+
```ignore
230234
from inspect import getattr_static
231235
232236
attr_property = getattr_static(C, "attr")
@@ -237,7 +241,7 @@ The `property` class has a `__get__` method, which makes it a descriptor. It als
237241
method, which means that it is a *data* descriptor (if there is no setter, `__set__` is still
238242
available but yields an `AttributeError` at runtime).
239243

240-
```py
244+
```ignore
241245
reveal_type(type(attr_property).__get__) # revealed: <wrapper-descriptor `__get__` of `property` objects>
242246
reveal_type(type(attr_property).__set__) # revealed: <wrapper-descriptor `__set__` of `property` objects>
243247
```
@@ -246,22 +250,22 @@ When we access `c.attr`, the `__get__` method of the `property` class is called,
246250
property object itself as the first argument, and the class instance `c` as the second argument. The
247251
third argument is the "owner" which can be set to `None` or to `C` in this case:
248252

249-
```py
253+
```ignore
250254
reveal_type(type(attr_property).__get__(attr_property, c, C)) # revealed: int
251255
reveal_type(type(attr_property).__get__(attr_property, c, None)) # revealed: int
252256
```
253257

254258
Alternatively, the above can also be written as a method call:
255259

256-
```py
260+
```ignore
257261
reveal_type(attr_property.__get__(c, C)) # revealed: int
258262
```
259263

260264
When we access `attr` on the class itself, the descriptor protocol is also invoked, but the instance
261265
argument is set to `None`. When `instance` is `None`, the call to `property.__get__` returns the
262266
property instance itself. So the following expressions are all equivalent
263267

264-
```py
268+
```ignore
265269
reveal_type(attr_property) # revealed: property
266270
reveal_type(C.attr) # revealed: property
267271
reveal_type(attr_property.__get__(None, C)) # revealed: property
@@ -271,7 +275,7 @@ reveal_type(type(attr_property).__get__(attr_property, None, C)) # revealed: pr
271275
When we set the property using `c.attr = "a"`, the `__set__` method of the property class is called.
272276
This attribute access desugars to
273277

274-
```py
278+
```ignore
275279
type(attr_property).__set__(attr_property, c, "a")
276280
277281
# error: [call-non-callable] "Call of wrapper descriptor `property.__set__` failed: calling the setter failed"
@@ -280,7 +284,7 @@ type(attr_property).__set__(attr_property, c, 1)
280284

281285
which is also equivalent to the following expressions:
282286

283-
```py
287+
```ignore
284288
attr_property.__set__(c, "a")
285289
# error: [call-non-callable]
286290
attr_property.__set__(c, 1)
@@ -293,7 +297,7 @@ C.attr.__set__(c, 1)
293297
Properties also have `fget` and `fset` attributes that can be used to retrieve the original getter
294298
and setter functions, respectively.
295299

296-
```py
300+
```ignore
297301
reveal_type(attr_property.fget) # revealed: Literal[attr]
298302
reveal_type(attr_property.fget(c)) # revealed: int
299303

crates/red_knot_python_semantic/src/semantic_index/builder.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::semantic_index::definition::{
2525
use crate::semantic_index::expression::{Expression, ExpressionKind};
2626
use crate::semantic_index::predicate::{
2727
PatternPredicate, PatternPredicateKind, Predicate, PredicateNode, ScopedPredicateId,
28+
StarImportPlaceholderPredicate,
2829
};
2930
use crate::semantic_index::re_exports::exported_names;
3031
use crate::semantic_index::symbol::{
@@ -1182,10 +1183,40 @@ where
11821183
continue;
11831184
};
11841185

1185-
for export in exported_names(self.db, module.file()) {
1186+
let referenced_module = module.file();
1187+
1188+
// In order to understand the visibility of definitions created by a `*` import,
1189+
// we need to know the visibility of the global-scope definitions in the
1190+
// `referenced_module` the symbols imported from. Much like predicates for `if`
1191+
// statements can only have their visibility constraints resolved at type-inference
1192+
// time, the visibility of these global-scope definitions in the external module
1193+
// cannot be resolved at this point. As such, we essentially model each definition
1194+
// stemming from a `from exporter *` import as something like:
1195+
//
1196+
// ```py
1197+
// if <external_definition_is_visible>:
1198+
// from exporter import name
1199+
// ```
1200+
//
1201+
// For more details, see the doc-comment on `StarImportPlaceholderPredicate`.
1202+
for export in exported_names(self.db, referenced_module) {
11861203
let symbol_id = self.add_symbol(export.clone());
11871204
let node_ref = StarImportDefinitionNodeRef { node, symbol_id };
1205+
let star_import = StarImportPlaceholderPredicate::new(
1206+
self.db,
1207+
self.file,
1208+
symbol_id,
1209+
referenced_module,
1210+
);
1211+
let pre_definition = self.flow_snapshot();
11881212
self.push_additional_definition(symbol_id, node_ref);
1213+
let constraint_id =
1214+
self.record_visibility_constraint(star_import.into());
1215+
let post_definition = self.flow_snapshot();
1216+
self.flow_restore(pre_definition.clone());
1217+
self.record_negated_visibility_constraint(constraint_id);
1218+
self.flow_merge(post_definition);
1219+
self.simplify_visibility_constraints(pre_definition);
11891220
}
11901221

11911222
continue;

crates/red_knot_python_semantic/src/semantic_index/predicate.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use ruff_python_ast::Singleton;
1313

1414
use crate::db::Db;
1515
use crate::semantic_index::expression::Expression;
16-
use crate::semantic_index::symbol::{FileScopeId, ScopeId};
16+
use crate::semantic_index::global_scope;
17+
use crate::semantic_index::symbol::{FileScopeId, ScopeId, ScopedSymbolId};
1718

1819
// A scoped identifier for each `Predicate` in a scope.
1920
#[newtype_index]
@@ -52,6 +53,7 @@ pub(crate) struct Predicate<'db> {
5253
pub(crate) enum PredicateNode<'db> {
5354
Expression(Expression<'db>),
5455
Pattern(PatternPredicate<'db>),
56+
StarImportPlaceholder(StarImportPlaceholderPredicate<'db>),
5557
}
5658

5759
/// Pattern kinds for which we support type narrowing and/or static visibility analysis.
@@ -85,3 +87,78 @@ impl<'db> PatternPredicate<'db> {
8587
self.file_scope(db).to_scope_id(db, self.file(db))
8688
}
8789
}
90+
91+
/// A "placeholder predicate" that is used to model the fact that the boundness of a
92+
/// (possible) definition or declaration caused by a `*` import cannot be fully determined
93+
/// until type-inference time. This is essentially the same as a standard visibility constraint,
94+
/// so we reuse the [`Predicate`] infrastructure to model it.
95+
///
96+
/// To illustrate, say we have a module `exporter.py` like so:
97+
///
98+
/// ```py
99+
/// if <condition>:
100+
/// class A: ...
101+
/// ```
102+
///
103+
/// and we have a module `importer.py` like so:
104+
///
105+
/// ```py
106+
/// A = 1
107+
///
108+
/// from importer import *
109+
/// ```
110+
///
111+
/// Since we cannot know whether or not <condition> is true at semantic-index time,
112+
/// we record a definition for `A` in `b.py` as a result of the `from a import *`
113+
/// statement, but place a predicate on it to record the fact that we don't yet
114+
/// know whether this definition will be visible from all control-flow paths or not.
115+
/// Essentially, we model `b.py` as something similar to this:
116+
///
117+
/// ```py
118+
/// A = 1
119+
///
120+
/// if <star_import_placeholder_predicate>:
121+
/// from a import A
122+
/// ```
123+
///
124+
/// At type-check time, the placeholder predicate for the `A` definition is evaluated by
125+
/// attempting to resolve the `A` symbol in `a.py`'s global namespace:
126+
/// - If it resolves to a definitely bound symbol, then the predicate resolves to [`Truthiness::AlwaysTrue`]
127+
/// - If it resolves to an unbound symbol, then the predicate resolves to [`Truthiness::AlwaysFalse`]
128+
/// - If it resolves to a possibly bound symbol, then the predicate resolves to [`Truthiness::Ambiguous`]
129+
///
130+
/// [Truthiness]: [crate::types::Truthiness]
131+
#[salsa::tracked(debug)]
132+
pub(crate) struct StarImportPlaceholderPredicate<'db> {
133+
pub(crate) importing_file: File,
134+
135+
/// Each symbol imported by a `*` import has a separate predicate associated with it:
136+
/// this field identifies which symbol that is.
137+
///
138+
/// Note that a [`ScopedSymbolId`] is only meaningful if you also know the scope
139+
/// it is relative to. For this specific struct, however, there's no need to store a
140+
/// separate field to hold the ID of the scope. `StarImportPredicate`s are only created
141+
/// for valid `*`-import definitions, and valid `*`-import definitions can only ever
142+
/// exist in the global scope; thus, we know that the `symbol_id` here will be relative
143+
/// to the global scope of the importing file.
144+
pub(crate) symbol_id: ScopedSymbolId,
145+
146+
pub(crate) referenced_file: File,
147+
}
148+
149+
impl<'db> StarImportPlaceholderPredicate<'db> {
150+
pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> {
151+
// See doc-comment above [`StarImportPlaceholderPredicate::symbol_id`]:
152+
// valid `*`-import definitions can only take place in the global scope.
153+
global_scope(db, self.importing_file(db))
154+
}
155+
}
156+
157+
impl<'db> From<StarImportPlaceholderPredicate<'db>> for Predicate<'db> {
158+
fn from(predicate: StarImportPlaceholderPredicate<'db>) -> Self {
159+
Predicate {
160+
node: PredicateNode::StarImportPlaceholder(predicate),
161+
is_positive: true,
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)