Skip to content

Commit 10ace88

Browse files
Track conditional deletions in the semantic model (#10415)
## Summary Given `del X`, we'll typically add a `BindingKind::Deletion` to `X` to shadow the current binding. However, if the deletion is inside of a conditional operation, we _won't_, as in: ```python def f(): global X if X > 0: del X ``` We will, however, track it as a reference to the binding. This PR adds the expression context to those resolved references, so that we can detect that the `X` in `global X` was "assigned to". Closes #10397.
1 parent a8e50a7 commit 10ace88

File tree

10 files changed

+133
-45
lines changed

10 files changed

+133
-45
lines changed

crates/ruff_linter/resources/test/fixtures/pylint/global_variable_not_assigned.py

+7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ def f():
1111
print(X)
1212

1313

14+
def f():
15+
global X
16+
17+
if X > 0:
18+
del X
19+
20+
1421
###
1522
# Non-errors.
1623
###

crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs

+23-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use ruff_diagnostics::{Diagnostic, Fix};
22
use ruff_python_semantic::analyze::visibility;
3-
use ruff_python_semantic::{Binding, BindingKind, Imported, ScopeKind};
3+
use ruff_python_semantic::{Binding, BindingKind, Imported, ResolvedReference, ScopeKind};
44
use ruff_text_size::Ranged;
55

66
use crate::checkers::ast::Checker;
@@ -91,13 +91,29 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
9191
if checker.enabled(Rule::GlobalVariableNotAssigned) {
9292
for (name, binding_id) in scope.bindings() {
9393
let binding = checker.semantic.binding(binding_id);
94+
// If the binding is a `global`, then it's a top-level `global` that was never
95+
// assigned in the current scope. If it were assigned, the `global` would be
96+
// shadowed by the assignment.
9497
if binding.kind.is_global() {
95-
diagnostics.push(Diagnostic::new(
96-
pylint::rules::GlobalVariableNotAssigned {
97-
name: (*name).to_string(),
98-
},
99-
binding.range(),
100-
));
98+
// If the binding was conditionally deleted, it will include a reference within
99+
// a `Del` context, but won't be shadowed by a `BindingKind::Deletion`, as in:
100+
// ```python
101+
// if condition:
102+
// del var
103+
// ```
104+
if binding
105+
.references
106+
.iter()
107+
.map(|id| checker.semantic.reference(*id))
108+
.all(ResolvedReference::is_load)
109+
{
110+
diagnostics.push(Diagnostic::new(
111+
pylint::rules::GlobalVariableNotAssigned {
112+
name: (*name).to_string(),
113+
},
114+
binding.range(),
115+
));
116+
}
101117
}
102118
}
103119
}

crates/ruff_linter/src/checkers/ast/mod.rs

+7-2
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,11 @@ impl<'a> Visitor<'a> for Checker<'a> {
540540
for name in names {
541541
if let Some((scope_id, binding_id)) = self.semantic.nonlocal(name) {
542542
// Mark the binding as "used".
543-
self.semantic.add_local_reference(binding_id, name.range());
543+
self.semantic.add_local_reference(
544+
binding_id,
545+
ExprContext::Load,
546+
name.range(),
547+
);
544548

545549
// Mark the binding in the enclosing scope as "rebound" in the current
546550
// scope.
@@ -2113,7 +2117,8 @@ impl<'a> Checker<'a> {
21132117
// Mark anything referenced in `__all__` as used.
21142118
// TODO(charlie): `range` here should be the range of the name in `__all__`, not
21152119
// the range of `__all__` itself.
2116-
self.semantic.add_global_reference(binding_id, range);
2120+
self.semantic
2121+
.add_global_reference(binding_id, ExprContext::Load, range);
21172122
} else {
21182123
if self.semantic.global_scope().uses_star_imports() {
21192124
if self.enabled(Rule::UndefinedLocalWithImportStarUsage) {

crates/ruff_linter/src/renamer.rs

+1
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ impl Renamer {
255255
| BindingKind::ClassDefinition(_)
256256
| BindingKind::FunctionDefinition(_)
257257
| BindingKind::Deletion
258+
| BindingKind::ConditionalDeletion(_)
258259
| BindingKind::UnboundException(_) => {
259260
Some(Edit::range_replacement(target.to_string(), binding.range()))
260261
}

crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs

+3-5
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,7 @@ pub(crate) fn runtime_import_in_type_checking_block(
121121
checker
122122
.semantic()
123123
.reference(reference_id)
124-
.context()
125-
.is_runtime()
124+
.in_runtime_context()
126125
})
127126
{
128127
let Some(node_id) = binding.source else {
@@ -155,8 +154,7 @@ pub(crate) fn runtime_import_in_type_checking_block(
155154
if checker.settings.flake8_type_checking.quote_annotations
156155
&& binding.references().all(|reference_id| {
157156
let reference = checker.semantic().reference(reference_id);
158-
reference.context().is_typing()
159-
|| reference.in_runtime_evaluated_annotation()
157+
reference.in_typing_context() || reference.in_runtime_evaluated_annotation()
160158
})
161159
{
162160
actions
@@ -268,7 +266,7 @@ fn quote_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding])
268266
.flat_map(|ImportBinding { binding, .. }| {
269267
binding.references.iter().filter_map(|reference_id| {
270268
let reference = checker.semantic().reference(*reference_id);
271-
if reference.context().is_runtime() {
269+
if reference.in_runtime_context() {
272270
Some(quote_annotation(
273271
reference.expression_id()?,
274272
checker.semantic(),

crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) ->
499499
.flat_map(|ImportBinding { binding, .. }| {
500500
binding.references.iter().filter_map(|reference_id| {
501501
let reference = checker.semantic().reference(*reference_id);
502-
if reference.context().is_runtime() {
502+
if reference.in_runtime_context() {
503503
Some(quote_annotation(
504504
reference.expression_id()?,
505505
checker.semantic(),

crates/ruff_linter/src/rules/pylint/rules/non_ascii_name.rs

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ pub(crate) fn non_ascii_name(binding: &Binding, locator: &Locator) -> Option<Dia
6767
| BindingKind::FromImport(_)
6868
| BindingKind::SubmoduleImport(_)
6969
| BindingKind::Deletion
70+
| BindingKind::ConditionalDeletion(_)
7071
| BindingKind::UnboundException(_) => {
7172
return None;
7273
}

crates/ruff_python_semantic/src/binding.rs

+30-4
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ impl<'a> Binding<'a> {
7575
self.flags.intersects(BindingFlags::GLOBAL)
7676
}
7777

78+
/// Return `true` if this [`Binding`] was deleted.
79+
pub const fn is_deleted(&self) -> bool {
80+
self.flags.intersects(BindingFlags::DELETED)
81+
}
82+
7883
/// Return `true` if this [`Binding`] represents an assignment to `__all__` with an invalid
7984
/// value (e.g., `__all__ = "Foo"`).
8085
pub const fn is_invalid_all_format(&self) -> bool {
@@ -165,6 +170,7 @@ impl<'a> Binding<'a> {
165170
// Deletions, annotations, `__future__` imports, and builtins are never considered
166171
// redefinitions.
167172
BindingKind::Deletion
173+
| BindingKind::ConditionalDeletion(_)
168174
| BindingKind::Annotation
169175
| BindingKind::FutureImport
170176
| BindingKind::Builtin => {
@@ -265,14 +271,27 @@ bitflags! {
265271
/// ```
266272
const GLOBAL = 1 << 4;
267273

274+
/// The binding was deleted (i.e., the target of a `del` statement).
275+
///
276+
/// For example, the binding could be `x` in:
277+
/// ```python
278+
/// del x
279+
/// ```
280+
///
281+
/// The semantic model will typically shadow a deleted binding via an additional binding
282+
/// with [`BindingKind::Deletion`]; however, conditional deletions (e.g.,
283+
/// `if condition: del x`) do _not_ generate a shadow binding. This flag is thus used to
284+
/// detect whether a binding was _ever_ deleted, even conditionally.
285+
const DELETED = 1 << 5;
286+
268287
/// The binding represents an export via `__all__`, but the assigned value uses an invalid
269288
/// expression (i.e., a non-container type).
270289
///
271290
/// For example:
272291
/// ```python
273292
/// __all__ = 1
274293
/// ```
275-
const INVALID_ALL_FORMAT = 1 << 5;
294+
const INVALID_ALL_FORMAT = 1 << 6;
276295

277296
/// The binding represents an export via `__all__`, but the assigned value contains an
278297
/// invalid member (i.e., a non-string).
@@ -281,23 +300,23 @@ bitflags! {
281300
/// ```python
282301
/// __all__ = [1]
283302
/// ```
284-
const INVALID_ALL_OBJECT = 1 << 6;
303+
const INVALID_ALL_OBJECT = 1 << 7;
285304

286305
/// The binding represents a private declaration.
287306
///
288307
/// For example, the binding could be `_T` in:
289308
/// ```python
290309
/// _T = "This is a private variable"
291310
/// ```
292-
const PRIVATE_DECLARATION = 1 << 7;
311+
const PRIVATE_DECLARATION = 1 << 8;
293312

294313
/// The binding represents an unpacked assignment.
295314
///
296315
/// For example, the binding could be `x` in:
297316
/// ```python
298317
/// (x, y) = 1, 2
299318
/// ```
300-
const UNPACKED_ASSIGNMENT = 1 << 8;
319+
const UNPACKED_ASSIGNMENT = 1 << 9;
301320
}
302321
}
303322

@@ -512,6 +531,13 @@ pub enum BindingKind<'a> {
512531
/// ```
513532
Deletion,
514533

534+
/// A binding for a deletion, like `x` in:
535+
/// ```python
536+
/// if x > 0:
537+
/// del x
538+
/// ```
539+
ConditionalDeletion(BindingId),
540+
515541
/// A binding to bind an exception to a local variable, like `x` in:
516542
/// ```python
517543
/// try:

crates/ruff_python_semantic/src/model.rs

+38-14
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use rustc_hash::FxHashMap;
55

66
use ruff_python_ast::helpers::from_relative_import;
77
use ruff_python_ast::name::{QualifiedName, UnqualifiedName};
8-
use ruff_python_ast::{self as ast, Expr, Operator, Stmt};
8+
use ruff_python_ast::{self as ast, Expr, ExprContext, Operator, Stmt};
99
use ruff_python_stdlib::path::is_python_stub_file;
1010
use ruff_text_size::{Ranged, TextRange, TextSize};
1111

@@ -271,7 +271,7 @@ impl<'a> SemanticModel<'a> {
271271
.get(symbol)
272272
.map_or(true, |binding_id| {
273273
// Treat the deletion of a name as a reference to that name.
274-
self.add_local_reference(binding_id, range);
274+
self.add_local_reference(binding_id, ExprContext::Del, range);
275275
self.bindings[binding_id].is_unbound()
276276
});
277277

@@ -296,8 +296,9 @@ impl<'a> SemanticModel<'a> {
296296
let reference_id = self.resolved_references.push(
297297
ScopeId::global(),
298298
self.node_id,
299-
name.range,
299+
ExprContext::Load,
300300
self.flags,
301+
name.range,
301302
);
302303
self.bindings[binding_id].references.push(reference_id);
303304

@@ -308,8 +309,9 @@ impl<'a> SemanticModel<'a> {
308309
let reference_id = self.resolved_references.push(
309310
ScopeId::global(),
310311
self.node_id,
311-
name.range,
312+
ExprContext::Load,
312313
self.flags,
314+
name.range,
313315
);
314316
self.bindings[binding_id].references.push(reference_id);
315317
}
@@ -365,8 +367,9 @@ impl<'a> SemanticModel<'a> {
365367
let reference_id = self.resolved_references.push(
366368
self.scope_id,
367369
self.node_id,
368-
name.range,
370+
ExprContext::Load,
369371
self.flags,
372+
name.range,
370373
);
371374
self.bindings[binding_id].references.push(reference_id);
372375

@@ -377,8 +380,9 @@ impl<'a> SemanticModel<'a> {
377380
let reference_id = self.resolved_references.push(
378381
self.scope_id,
379382
self.node_id,
380-
name.range,
383+
ExprContext::Load,
381384
self.flags,
385+
name.range,
382386
);
383387
self.bindings[binding_id].references.push(reference_id);
384388
}
@@ -426,6 +430,15 @@ impl<'a> SemanticModel<'a> {
426430
return ReadResult::UnboundLocal(binding_id);
427431
}
428432

433+
BindingKind::ConditionalDeletion(binding_id) => {
434+
self.unresolved_references.push(
435+
name.range,
436+
self.exceptions(),
437+
UnresolvedReferenceFlags::empty(),
438+
);
439+
return ReadResult::UnboundLocal(binding_id);
440+
}
441+
429442
// If we hit an unbound exception that shadowed a bound name, resole to the
430443
// bound name. For example, given:
431444
//
@@ -446,8 +459,9 @@ impl<'a> SemanticModel<'a> {
446459
let reference_id = self.resolved_references.push(
447460
self.scope_id,
448461
self.node_id,
449-
name.range,
462+
ExprContext::Load,
450463
self.flags,
464+
name.range,
451465
);
452466
self.bindings[binding_id].references.push(reference_id);
453467

@@ -458,8 +472,9 @@ impl<'a> SemanticModel<'a> {
458472
let reference_id = self.resolved_references.push(
459473
self.scope_id,
460474
self.node_id,
461-
name.range,
475+
ExprContext::Load,
462476
self.flags,
477+
name.range,
463478
);
464479
self.bindings[binding_id].references.push(reference_id);
465480
}
@@ -548,6 +563,7 @@ impl<'a> SemanticModel<'a> {
548563
match self.bindings[binding_id].kind {
549564
BindingKind::Annotation => continue,
550565
BindingKind::Deletion | BindingKind::UnboundException(None) => return None,
566+
BindingKind::ConditionalDeletion(binding_id) => return Some(binding_id),
551567
BindingKind::UnboundException(Some(binding_id)) => return Some(binding_id),
552568
_ => return Some(binding_id),
553569
}
@@ -1315,18 +1331,28 @@ impl<'a> SemanticModel<'a> {
13151331
}
13161332

13171333
/// Add a reference to the given [`BindingId`] in the local scope.
1318-
pub fn add_local_reference(&mut self, binding_id: BindingId, range: TextRange) {
1334+
pub fn add_local_reference(
1335+
&mut self,
1336+
binding_id: BindingId,
1337+
ctx: ExprContext,
1338+
range: TextRange,
1339+
) {
13191340
let reference_id =
13201341
self.resolved_references
1321-
.push(self.scope_id, self.node_id, range, self.flags);
1342+
.push(self.scope_id, self.node_id, ctx, self.flags, range);
13221343
self.bindings[binding_id].references.push(reference_id);
13231344
}
13241345

13251346
/// Add a reference to the given [`BindingId`] in the global scope.
1326-
pub fn add_global_reference(&mut self, binding_id: BindingId, range: TextRange) {
1347+
pub fn add_global_reference(
1348+
&mut self,
1349+
binding_id: BindingId,
1350+
ctx: ExprContext,
1351+
range: TextRange,
1352+
) {
13271353
let reference_id =
13281354
self.resolved_references
1329-
.push(ScopeId::global(), self.node_id, range, self.flags);
1355+
.push(ScopeId::global(), self.node_id, ctx, self.flags, range);
13301356
self.bindings[binding_id].references.push(reference_id);
13311357
}
13321358

@@ -1700,7 +1726,6 @@ bitflags! {
17001726
/// only required by the Python interpreter, but by runtime type checkers too.
17011727
const RUNTIME_REQUIRED_ANNOTATION = 1 << 2;
17021728

1703-
17041729
/// The model is in a type definition.
17051730
///
17061731
/// For example, the model could be visiting `int` in:
@@ -1886,7 +1911,6 @@ bitflags! {
18861911
/// ```
18871912
const COMPREHENSION_ASSIGNMENT = 1 << 19;
18881913

1889-
18901914
/// The model is in a module / class / function docstring.
18911915
///
18921916
/// For example, the model could be visiting either the module, class,

0 commit comments

Comments
 (0)