Skip to content

Commit ecf61d4

Browse files
Restore existing bindings when unbinding caught exceptions (#5256)
## Summary In the latest release, we made some improvements to the semantic model, but our modifications to exception-unbinding are causing some false-positives. For example: ```py try: v = 3 except ImportError as v: print(v) else: print(v) ``` In the latest release, we started unbinding `v` after the `except` handler. (We used to restore the existing binding, the `v = 3`, but this was quite complicated.) Because we don't have full branch analysis, we can't then know that `v` is still bound in the `else` branch. The solution here modifies `resolve_read` to skip-lookup when hitting unbound exceptions. So when store the "unbind" for `except ImportError as v`, we save the binding that it shadowed `v = 3`, and skip to that. Closes #5249. Closes #5250.
1 parent d99b3bf commit ecf61d4

13 files changed

+429
-25
lines changed

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

+11-9
Original file line numberDiff line numberDiff line change
@@ -3852,6 +3852,9 @@ where
38523852
);
38533853
}
38543854

3855+
// Store the existing binding, if any.
3856+
let existing_id = self.semantic.lookup(name);
3857+
38553858
// Add the bound exception name to the scope.
38563859
let binding_id = self.add_binding(
38573860
name,
@@ -3862,14 +3865,6 @@ where
38623865

38633866
walk_except_handler(self, except_handler);
38643867

3865-
// Remove it from the scope immediately after.
3866-
self.add_binding(
3867-
name,
3868-
range,
3869-
BindingKind::UnboundException,
3870-
BindingFlags::empty(),
3871-
);
3872-
38733868
// If the exception name wasn't used in the scope, emit a diagnostic.
38743869
if !self.semantic.is_used(binding_id) {
38753870
if self.enabled(Rule::UnusedVariable) {
@@ -3889,6 +3884,13 @@ where
38893884
self.diagnostics.push(diagnostic);
38903885
}
38913886
}
3887+
3888+
self.add_binding(
3889+
name,
3890+
range,
3891+
BindingKind::UnboundException(existing_id),
3892+
BindingFlags::empty(),
3893+
);
38923894
}
38933895
None => walk_except_handler(self, except_handler),
38943896
}
@@ -4236,7 +4238,7 @@ impl<'a> Checker<'a> {
42364238
let shadowed = &self.semantic.bindings[shadowed_id];
42374239
if !matches!(
42384240
shadowed.kind,
4239-
BindingKind::Builtin | BindingKind::Deletion | BindingKind::UnboundException,
4241+
BindingKind::Builtin | BindingKind::Deletion | BindingKind::UnboundException(_),
42404242
) {
42414243
let references = shadowed.references.clone();
42424244
let is_global = shadowed.is_global();

crates/ruff/src/renamer.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ impl Renamer {
251251
| BindingKind::ClassDefinition
252252
| BindingKind::FunctionDefinition
253253
| BindingKind::Deletion
254-
| BindingKind::UnboundException => {
254+
| BindingKind::UnboundException(_) => {
255255
Some(Edit::range_replacement(target.to_string(), binding.range))
256256
}
257257
}

crates/ruff/src/rules/pyflakes/mod.rs

+124-1
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,59 @@ mod tests {
353353
except Exception as x:
354354
pass
355355
356+
# No error here, though it should arguably be an F821 error. `x` will
357+
# be unbound after the `except` block (assuming an exception is raised
358+
# and caught).
356359
print(x)
357360
"#,
358-
"print_after_shadowing_except"
361+
"print_in_body_after_shadowing_except"
362+
)]
363+
#[test_case(
364+
r#"
365+
def f():
366+
x = 1
367+
368+
try:
369+
1 / 0
370+
except ValueError as x:
371+
pass
372+
except ImportError as x:
373+
pass
374+
375+
# No error here, though it should arguably be an F821 error. `x` will
376+
# be unbound after the `except` block (assuming an exception is raised
377+
# and caught).
378+
print(x)
379+
"#,
380+
"print_in_body_after_double_shadowing_except"
381+
)]
382+
#[test_case(
383+
r#"
384+
def f():
385+
try:
386+
x = 3
387+
except ImportError as x:
388+
print(x)
389+
else:
390+
print(x)
391+
"#,
392+
"print_in_try_else_after_shadowing_except"
393+
)]
394+
#[test_case(
395+
r#"
396+
def f():
397+
list = [1, 2, 3]
398+
399+
for e in list:
400+
if e % 2 == 0:
401+
try:
402+
pass
403+
except Exception as e:
404+
print(e)
405+
else:
406+
print(e)
407+
"#,
408+
"print_in_if_else_after_shadowing_except"
359409
)]
360410
#[test_case(
361411
r#"
@@ -366,6 +416,79 @@ mod tests {
366416
"#,
367417
"double_del"
368418
)]
419+
#[test_case(
420+
r#"
421+
x = 1
422+
423+
def f():
424+
try:
425+
pass
426+
except ValueError as x:
427+
pass
428+
429+
# This should resolve to the `x` in `x = 1`.
430+
print(x)
431+
"#,
432+
"load_after_unbind_from_module_scope"
433+
)]
434+
#[test_case(
435+
r#"
436+
x = 1
437+
438+
def f():
439+
try:
440+
pass
441+
except ValueError as x:
442+
pass
443+
444+
try:
445+
pass
446+
except ValueError as x:
447+
pass
448+
449+
# This should resolve to the `x` in `x = 1`.
450+
print(x)
451+
"#,
452+
"load_after_multiple_unbinds_from_module_scope"
453+
)]
454+
#[test_case(
455+
r#"
456+
x = 1
457+
458+
def f():
459+
try:
460+
pass
461+
except ValueError as x:
462+
pass
463+
464+
def g():
465+
try:
466+
pass
467+
except ValueError as x:
468+
pass
469+
470+
# This should resolve to the `x` in `x = 1`.
471+
print(x)
472+
"#,
473+
"load_after_unbind_from_nested_module_scope"
474+
)]
475+
#[test_case(
476+
r#"
477+
class C:
478+
x = 1
479+
480+
def f():
481+
try:
482+
pass
483+
except ValueError as x:
484+
pass
485+
486+
# This should raise an F821 error, rather than resolving to the
487+
# `x` in `x = 1`.
488+
print(x)
489+
"#,
490+
"load_after_unbind_from_class_scope"
491+
)]
369492
fn contents(contents: &str, snapshot: &str) {
370493
let diagnostics = test_snippet(contents, &Settings::for_rules(&Linter::Pyflakes));
371494
assert_messages!(snapshot, diagnostics);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
source: crates/ruff/src/rules/pyflakes/mod.rs
3+
---
4+
<filename>:7:26: F841 [*] Local variable `x` is assigned to but never used
5+
|
6+
5 | try:
7+
6 | pass
8+
7 | except ValueError as x:
9+
| ^ F841
10+
8 | pass
11+
|
12+
= help: Remove assignment to unused variable `x`
13+
14+
ℹ Fix
15+
4 4 | def f():
16+
5 5 | try:
17+
6 6 | pass
18+
7 |- except ValueError as x:
19+
7 |+ except ValueError:
20+
8 8 | pass
21+
9 9 |
22+
10 10 | try:
23+
24+
<filename>:12:26: F841 [*] Local variable `x` is assigned to but never used
25+
|
26+
10 | try:
27+
11 | pass
28+
12 | except ValueError as x:
29+
| ^ F841
30+
13 | pass
31+
|
32+
= help: Remove assignment to unused variable `x`
33+
34+
ℹ Fix
35+
9 9 |
36+
10 10 | try:
37+
11 11 | pass
38+
12 |- except ValueError as x:
39+
12 |+ except ValueError:
40+
13 13 | pass
41+
14 14 |
42+
15 15 | # This should resolve to the `x` in `x = 1`.
43+
44+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
source: crates/ruff/src/rules/pyflakes/mod.rs
3+
---
4+
<filename>:8:30: F841 [*] Local variable `x` is assigned to but never used
5+
|
6+
6 | try:
7+
7 | pass
8+
8 | except ValueError as x:
9+
| ^ F841
10+
9 | pass
11+
|
12+
= help: Remove assignment to unused variable `x`
13+
14+
ℹ Fix
15+
5 5 | def f():
16+
6 6 | try:
17+
7 7 | pass
18+
8 |- except ValueError as x:
19+
8 |+ except ValueError:
20+
9 9 | pass
21+
10 10 |
22+
11 11 | # This should raise an F821 error, rather than resolving to the
23+
24+
<filename>:13:15: F821 Undefined name `x`
25+
|
26+
11 | # This should raise an F821 error, rather than resolving to the
27+
12 | # `x` in `x = 1`.
28+
13 | print(x)
29+
| ^ F821
30+
|
31+
32+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
source: crates/ruff/src/rules/pyflakes/mod.rs
3+
---
4+
<filename>:7:26: F841 [*] Local variable `x` is assigned to but never used
5+
|
6+
5 | try:
7+
6 | pass
8+
7 | except ValueError as x:
9+
| ^ F841
10+
8 | pass
11+
|
12+
= help: Remove assignment to unused variable `x`
13+
14+
ℹ Fix
15+
4 4 | def f():
16+
5 5 | try:
17+
6 6 | pass
18+
7 |- except ValueError as x:
19+
7 |+ except ValueError:
20+
8 8 | pass
21+
9 9 |
22+
10 10 | # This should resolve to the `x` in `x = 1`.
23+
24+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
source: crates/ruff/src/rules/pyflakes/mod.rs
3+
---
4+
<filename>:7:26: F841 [*] Local variable `x` is assigned to but never used
5+
|
6+
5 | try:
7+
6 | pass
8+
7 | except ValueError as x:
9+
| ^ F841
10+
8 | pass
11+
|
12+
= help: Remove assignment to unused variable `x`
13+
14+
ℹ Fix
15+
4 4 | def f():
16+
5 5 | try:
17+
6 6 | pass
18+
7 |- except ValueError as x:
19+
7 |+ except ValueError:
20+
8 8 | pass
21+
9 9 |
22+
10 10 | def g():
23+
24+
<filename>:13:30: F841 [*] Local variable `x` is assigned to but never used
25+
|
26+
11 | try:
27+
12 | pass
28+
13 | except ValueError as x:
29+
| ^ F841
30+
14 | pass
31+
|
32+
= help: Remove assignment to unused variable `x`
33+
34+
ℹ Fix
35+
10 10 | def g():
36+
11 11 | try:
37+
12 12 | pass
38+
13 |- except ValueError as x:
39+
13 |+ except ValueError:
40+
14 14 | pass
41+
15 15 |
42+
16 16 | # This should resolve to the `x` in `x = 1`.
43+
44+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
source: crates/ruff/src/rules/pyflakes/mod.rs
3+
---
4+
<filename>:7:26: F841 [*] Local variable `x` is assigned to but never used
5+
|
6+
5 | try:
7+
6 | 1 / 0
8+
7 | except ValueError as x:
9+
| ^ F841
10+
8 | pass
11+
9 | except ImportError as x:
12+
|
13+
= help: Remove assignment to unused variable `x`
14+
15+
ℹ Fix
16+
4 4 |
17+
5 5 | try:
18+
6 6 | 1 / 0
19+
7 |- except ValueError as x:
20+
7 |+ except ValueError:
21+
8 8 | pass
22+
9 9 | except ImportError as x:
23+
10 10 | pass
24+
25+
<filename>:9:27: F841 [*] Local variable `x` is assigned to but never used
26+
|
27+
7 | except ValueError as x:
28+
8 | pass
29+
9 | except ImportError as x:
30+
| ^ F841
31+
10 | pass
32+
|
33+
= help: Remove assignment to unused variable `x`
34+
35+
ℹ Fix
36+
6 6 | 1 / 0
37+
7 7 | except ValueError as x:
38+
8 8 | pass
39+
9 |- except ImportError as x:
40+
9 |+ except ImportError:
41+
10 10 | pass
42+
11 11 |
43+
12 12 | # No error here, though it should arguably be an F821 error. `x` will
44+
45+

0 commit comments

Comments
 (0)