Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

B023 rule incorrectly flags loop variables in cases where closures intentionally and correctly capture loop variables #16046

Closed
gweidart opened this issue Feb 8, 2025 · 2 comments
Labels
needs-info More information is needed from the issue author

Comments

@gweidart
Copy link

gweidart commented Feb 8, 2025

Description

Keywords Searched

  • "B023"
  • "closure in loop"
  • "function definition does not bind loop variable"
  • "loop variable capture"
  • "dynamic function creation loop"

Bug Description

Ruff's B023 rule incorrectly flags loop variables in cases where closures intentionally and correctly capture loop variables. This is a false positive as the loop variable binding works as expected in Python's scoping rules.

Minimal Reproduction

# Example 1: Dynamic callback creation (common in UI frameworks, event handlers, etc.)
def create_callbacks(items: dict) -> list:
    callbacks = []
    
    for name, data in items.items():
        def callback():  # Ruff[B023] incorrectly flags 'name' here
            print(f"Processing {name}: {data}")  # This is the flagged line
        
        callbacks.append(callback)
    
    return callbacks

# Example 2: Real-world usage with Click (common CLI pattern)
def create_cli(command_map: dict) -> click.Group:
    cli_group = click.Group()
    
    for name, command in command_map.items():
        def command_fn(**kwargs):  # Ruff[B023] incorrectly flags 'name' here
            context = CommandContext(
                command=name,  # This is the flagged line
                query=kwargs.get("query"),
                options=parse_options(kwargs),
            )
            exit_code = asyncio.run(run_command(command_map, context))
                if exit_code != 0:
                    click.get_current_context().exit(exit_code)
            except Exception as e:
                click.echo(f"Error: {e!s}", err=True)
                click.get_current_context().exit(1)
        
        cmd = click.Command(
            name=name,
            callback=command_fn,
            help=getattr(command, "help", None)
        )
        cli_group.add_command(cmd)
    
    return cli_group

Command Invoked

ruff check . --isolated
B023 Function definition does not bind loop variable `name`
    |
139 |             try:
140 |                 context = CommandContext(
141 |                     command=name,
    |                             ^^^^ B023
142 |                     query=kwargs.get("query"),
143 |                     options=parse_options(kwargs),
    |

Found 1 error.

Ruff Settings (pyproject.toml)

[tool.ruff]
select = ["E", "F", "B", "I"]
ignore = []
line-length = 88
target-version = "py39"
fix = true
unfixable = []
exclude = [
    ".git",
    ".ruff_cache",
    ".venv",
    "venv",
    "__pycache__",
    "build",
    "dist",
]

Ruff Version

$ ruff --version
ruff 0.9.5

Note

This pattern is common in many Python applications where dynamic function creation is needed:

  • Event handlers and callbacks
  • CLI command creation
  • UI framework event bindings
  • Plugin systems
  • Decorator factories
  1. The loop variable is correctly captured in the closure according to Python's scoping rules

  2. The code works correctly at runtime - each function correctly captures and uses its corresponding loop variable

  3. This is a legitimate and common Python pattern, not a bug or anti-pattern

Similar to the confirmed false positive with :
#7847 #15716

Expected Behavior

Ruff should not flag B023 for loop variables that are intentionally captured by closures, as this is a valid and common Python pattern that works correctly according to Python's scoping rules.

@InSyncWithFoo
Copy link
Contributor

If I'm not mistaken, the example boils down to this:

class A: ...

def g():
    d = {}

    for i in range(10):
        def f():
            print(i)

        a = A()
        a.f = f
        d[i] = a

    return d
# Somewhere else
d = g()
d[0].f()  # 9

...which proves that the diagnostic is a true positive. Am I missing something?

@dylwil3
Copy link
Collaborator

dylwil3 commented Feb 9, 2025

I agree with @InSyncWithFoo , even directly copying your Example 1, I get:

>>> def create_callbacks(items: dict) -> list:
...     callbacks = []
...
...     for name, data in items.items():
...         def callback():  # Ruff[B023] incorrectly flags 'name' here
...             print(f"Processing {name}: {data}")  # This is the flagged line
...
...         callbacks.append(callback)
...
...     return callbacks
...
>>> d = {"a":1,"b":2}
>>> create_callbacks(d)[0]()
Processing b: 2

which seems like not what one wants, right?

@MichaReiser MichaReiser added the needs-info More information is needed from the issue author label Feb 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
needs-info More information is needed from the issue author
Projects
None yet
Development

No branches or pull requests

4 participants