[pyflakes] Treat function-scope bare annotations as locals per PEP 526 (F821)#21540
Conversation
|
| code | total | + violation | - violation | + fix | - fix |
|---|---|---|---|---|---|
| RUF100 | 1 | 1 | 0 | 0 | 0 |
Linter (preview)
ℹ️ ecosystem check detected linter changes. (+1 -0 violations, +0 -0 fixes in 1 projects; 55 projects unchanged)
apache/airflow (+1 -0 violations, +0 -0 fixes)
ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL
+ task-sdk/src/airflow/sdk/execution_time/callback_runner.py:124:28: RUF100 [*] Unused `noqa` directive (unused: `F821`)
Changes by rule (1 rules affected)
| code | total | + violation | - violation | + fix | - fix |
|---|---|---|---|---|---|
| RUF100 | 1 | 1 | 0 | 0 | 0 |
| def demonstrate_bare_local_annotation(): | ||
| x: int | ||
| print(x) |
There was a problem hiding this comment.
I still haven't looked too closely at this, but I agree that it's unfortunate not to flag this case. It might be worth looking into how ty handles this since it seems to get the expected result in both cases:
https://play.ty.dev/c6f45910-a2bd-4745-827b-f29a148f4e63
It doesn't seem to be related to nonlocal, though, because commenting it out doesn't change ty's diagnostics.
There was a problem hiding this comment.
One option to do this is then something like
BindingKind::Annotation if !self.in_stub_file() => {
if self.scopes[self.bindings[binding_id].scope]
.kind
.is_function()
{
// If we're in a function scope, and the annotation is local to that scope,
// treat it as unbound (since it's not assigned).
// If it's an enclosing scope, we treat it as resolved (to support closures).
if index == 0 {
self.unresolved_references.push(
name.range,
self.exceptions(),
UnresolvedReferenceFlags::empty(),
);
return ReadResult::UnboundLocal(binding_id);
}
self.resolved_names.insert(name.into(), binding_id);
return ReadResult::Resolved(binding_id);
}
continue;
}
if we do this then both the cases will be handled i.e. error in this case
def demonstrate_bare_local_annotation():
x: int
print(x)
and no error in the closure cases i.e. the setter,getter example
but then again we will have edge cases like these where no error will be reported [although even ty doesn't report them]
def demo_closure():
x: int
def func():
return x
What do you think about this
There was a problem hiding this comment.
Thanks for looking into this, and sorry for the delay! I've looked at this a bit today, and yes, I think your suggestion here is a good idea that should handle both the existing test case and the new closure case from the issue. I'm okay with the false negative in demo_closure as it seems like a better trade-off.
There was a problem hiding this comment.
Hey @ntBre, I have a different approach for this, which also handles the edge case. Instead of resolving closure reads as Resolved, both same-scope and closure reads of bare function-scope annotations are treated as unresolved.
To handle the nonlocal case, unresolved references now carry an annotation_binding_id. In the F821 lint phase, we use rebinding_scopes to check if any nested scope declared nonlocal on that annotation binding — if so, we suppress F821.
F821 - UnboundLocalError at runtime
def f():
x: int
print(x)
No F821 - nonlocal rebinding exists (getter/setter pattern)
def make_closure_pair():
x: int
def get_value():
return x
def set_value(new_value):
nonlocal x
x = new_value
return get_value, set_value
F821 — no nonlocal, previously a false negative
def demo_closure():
x: int
def func():
return x
This eliminates the demo_closure false negative from the previous approach. The only heuristic is that we trust nonlocal presence without verifying an actual assignment - matching Pyflakes' behavior.
Have updated the PR.
ntBre
left a comment
There was a problem hiding this comment.
Thank you and so sorry for the long delay in review! This seems like quite an elegant fix. I just left one suggestion about updating a comment that I think is now slightly out of sync with the implementation. I'm happy to take care of that and the merge conflicts if you want, though.
pyflakes] Treat function-scope bare annotations as locals per PEP 526 (F821)
…526 (`F821`) (astral-sh#21540) ## Summary Resolves astral-sh#21494 Treat function-scope annotated variables as locals per PEP 526 , preventing F821 false positives. ## Test Plan Added tests for these cases in `crates/ruff_linter/resources/test/fixtures/pyflakes/F821_34.py`
Summary
Resolves #21494
Treat function-scope annotated variables as locals per PEP 526 , preventing F821 false positives.
Test Plan
Added tests for these cases in
crates/ruff_linter/resources/test/fixtures/pyflakes/F821_34.py