Skip to content

[pyflakes] Treat function-scope bare annotations as locals per PEP 526 (F821)#21540

Merged
ntBre merged 3 commits into
astral-sh:mainfrom
Ruchir28:Issue-21494
May 26, 2026
Merged

[pyflakes] Treat function-scope bare annotations as locals per PEP 526 (F821)#21540
ntBre merged 3 commits into
astral-sh:mainfrom
Ruchir28:Issue-21494

Conversation

@Ruchir28

Copy link
Copy Markdown
Contributor

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

@astral-sh-bot

astral-sh-bot Bot commented Nov 20, 2025

Copy link
Copy Markdown

ruff-ecosystem results

Linter (stable)

ℹ️ 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 --no-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

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

Comment on lines +6 to +8
def demonstrate_bare_local_annotation():
x: int
print(x)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MichaReiser MichaReiser requested a review from ntBre January 15, 2026 14:25
@ntBre ntBre added bug Something isn't working rule Implementing or modifying a lint rule labels Feb 24, 2026

@ntBre ntBre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/ruff_python_semantic/src/model.rs Outdated
@ntBre ntBre changed the title [pyflakes] Treat function-scope bare annotations as locals per PEP 526 (F821) [pyflakes] Treat function-scope bare annotations as locals per PEP 526 (F821) May 26, 2026
@ntBre ntBre merged commit 7c2e9fb into astral-sh:main May 26, 2026
45 checks passed
anishgirianish pushed a commit to anishgirianish/ruff that referenced this pull request May 28, 2026
…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`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

F821: Declared only variables

2 participants