Skip to content

Conversation

ya7on
Copy link

@ya7on ya7on commented Jul 26, 2025

Summary

Implement the global-or-nonlocal-in-branch rule (RUF103), which detects cases where a variable
declared as global or nonlocal within a function is assigned in a separate branch of the function
without an explicit declaration along that code path. Such usage can be confusing and is often unintended.

Example:

g = 1

def f(b: bool) -> None:
    if b:
        global g
        g = 2
    else:
        g = 3

f(False)
print(g)  # 3, not 1

Closes #6470.

Test Plan

Added unit tests covering:

  • Assignments in the same branch as the declaration (no violation).
  • Assignments in a different branch without declaration (violation).
  • Cases with multiple branches and CFG paths.
  • nonlocal usage (mirrors the global behavior).

@ntBre ntBre added rule Implementing or modifying a lint rule preview Related to preview mode features labels Jul 26, 2025
Copy link
Contributor

@ntBre ntBre left a comment

Choose a reason for hiding this comment

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

Very cool! This is the first rule I've seen using the control flow graph, and it's closing quite a long-standing rule request too.

I'll leave the review to @dylwil3 since he wrote the CFG code, but I had one small suggestion about the rule code in passing.

(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
(Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA),
(Ruff, "102") => (RuleGroup::Preview, rules::ruff::rules::InvalidRuleCode),
(Ruff, "103") => (RuleGroup::Preview, rules::ruff::rules::GlobalOrNonlocalInBranch),
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not quite sure, but I think the RUF100 rules are a bit special. It looks like they're all related to noqa codes. I'd suggest pushing this up to RUF066, which is the next available code after RUF065, which is currently used in #19518.

Copy link
Author

@ya7on ya7on Jul 27, 2025

Choose a reason for hiding this comment

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

Thank you so much for reviewing my PR!
This is my first contribution to this repository, so I wasn’t familiar with the rules for choosing a code number.
I’ll update the rule to use RUF066.

@ntBre ntBre requested a review from dylwil3 July 26, 2025 18:48
Copy link
Contributor

github-actions bot commented Jul 26, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

ℹ️ ecosystem check detected linter changes. (+15 -0 violations, +0 -0 fixes in 8 projects; 47 projects unchanged)

apache/airflow (+4 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview --select ALL

+ dev/breeze/src/airflow_breeze/global_constants.py:669:9: RUF066 The variable PROVIDER_DEPENDENCIES is declared using global, but is used in other branches of this function — this may be unintuitive
+ dev/breeze/src/airflow_breeze/utils/publish_docs_to_s3.py:326:17: RUF066 The variable version_error is declared using global, but is used in other branches of this function — this may be unintuitive
+ scripts/ci/pre_commit/check_providers_subpackages_all_have_init.py:72:13: RUF066 The variable fail_pre_commit is declared using global, but is used in other branches of this function — this may be unintuitive
+ scripts/ci/pre_commit/check_providers_subpackages_all_have_init.py:73:13: RUF066 The variable fatal_error is declared using global, but is used in other branches of this function — this may be unintuitive

bokeh/bokeh (+3 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview --select ALL

+ examples/server/app/spectrogram/audio.py:59:13: RUF066 The variable _f_carrier is declared using global, but is used in other branches of this function — this may be unintuitive
+ examples/server/app/spectrogram/audio.py:60:13: RUF066 The variable _f_mod is declared using global, but is used in other branches of this function — this may be unintuitive
+ examples/server/app/spectrogram/audio.py:61:13: RUF066 The variable _ind_mod is declared using global, but is used in other branches of this function — this may be unintuitive

langchain-ai/langchain (+2 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ libs/core/tests/unit_tests/runnables/test_runnable_events_v2.py:2444:13: RUF066 The variable outer_cancelled is declared using nonlocal, but is used in other branches of this function — this may be unintuitive
+ libs/core/tests/unit_tests/runnables/test_runnable_events_v2.py:2509:13: RUF066 The variable outer_cancelled is declared using nonlocal, but is used in other branches of this function — this may be unintuitive

latchbio/latch (+1 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ src/latch/ldata/path.py:333:13: RUF066 The variable _download_idx is declared using global, but is used in other branches of this function — this may be unintuitive

milvus-io/pymilvus (+1 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ _version_helper.py:93:9: RUF066 The variable version is declared using global, but is used in other branches of this function — this may be unintuitive

reflex-dev/reflex (+1 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ reflex/utils/exec.py:191:13: RUF066 The variable frontend_process is declared using global, but is used in other branches of this function — this may be unintuitive

python-trio/trio (+1 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ src/trio/_core/_tests/test_asyncgen.py:216:17: RUF066 The variable needs_retry is declared using nonlocal, but is used in other branches of this function — this may be unintuitive

astropy/astropy (+2 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ astropy/io/fits/util.py:513:17: RUF066 The variable CHUNKED_FROMFILE is declared using global, but is used in other branches of this function — this may be unintuitive
+ astropy/io/fits/util.py:515:17: RUF066 The variable CHUNKED_FROMFILE is declared using global, but is used in other branches of this function — this may be unintuitive

Changes by rule (1 rules affected)

code total + violation - violation + fix - fix
RUF066 15 15 0 0 0

@dylwil3
Copy link
Collaborator

dylwil3 commented Jul 29, 2025

Thanks @ya7on ! Have you gotten a chance to look through the ecosystem results? It seems like there are cases where we have a violation even when the assignment is in the same branch (unless I'm missing something). Let me know if the ecosystem results look okay to you and I can take another look.

Also, work on the control flow graph got paused at some point so it's very possible there are some bugs - let me know if you run into trouble or if something doesn't seem to be working right!

@ya7on
Copy link
Author

ya7on commented Jul 29, 2025

Thanks for the review!

I only tested the rule against the unit tests so far, didn't realize I should also check the ecosystem results. I'll run that and see what comes up. If I find issues, I'll fix them — and if it looks like a CFG bug, I'll come back with questions.

Appreciate you pointing this out!

@dylwil3
Copy link
Collaborator

dylwil3 commented Jul 29, 2025

Thanks! One thing, just in case it wasn't clear:

I should also check the ecosystem results. I'll run that and see what comes up.

You don't have to run the ecosystem check yourself - you can click through the results in this comment #19569 (comment)

@ya7on
Copy link
Author

ya7on commented Jul 29, 2025

you can click through the results in this comment #19569 (comment)

Oh, i see. Thank you! I'll check those results.

@ya7on
Copy link
Author

ya7on commented Jul 31, 2025

It looks like there is indeed a bug in the control-flow graph. I tried running build_cfg on a more nested function like this

def test_func():
    a = True
    b = True
    if a:
        global GLOBAL_NAME
        GLOBAL_NAME = 1
        if b:
            return

The generated CFG contains only 2 blocks

Block 0 includes:

a = True
b = True
if a:

But block 1 is completely empty. It doesnt include the global statement, the assignment and the inner return.

What would you recommend i do in this case?
I can switch to an AST-only approach for this rule instead of relying on CFG. The algorithm would work by walking through each branch (if, while, for, try) and checking whether a declaration and an assignment appear in different branches.

UPD: after a little debugging i find out following. build_cfg currently does not create separate blocks for statements inside if bodies.
Event control-altering statements like return or nested if are embedded inside a parent StmtIf, and not surfaced into the graph

ControlFlowGraph {
    blocks: [
        Block 0:
            stmts: [
                Assign a = True
                Assign b = True
                If a: [nested body with all other function code]
            ]
            outgoing: [BlockId(1)]
        Block 1:
            stmts: [] // Empty
    ]
}

@dylwil3
Copy link
Collaborator

dylwil3 commented Aug 1, 2025

Sorry this is my bad - I did not quite remember how much of the CFG work had been merged in. It looks like we never actually merged in the work on if and match statements #17268 (so not much functionality is available then...)

I don't have an immediate answer for when work is planned to continue on that. It's up to you whether you'd like to wait a bit, or whether you'd like to try using the available methods on the SemanticModel for handling branches. Happy to review either way!

@MichaReiser
Copy link
Member

@ya7on can you help me understand what the status of this PR is? Are you planning additional work or are you waiting on input a review from us?

@ya7on
Copy link
Author

ya7on commented Aug 18, 2025

@MichaReiser Hi! I am currently not working on this PR as i am waiting for updates or fixes to the CFG. I am happy to return to this once that work is unblocked, but in the meantime i think it makes sense to close this PR for now. Thanks again for the review!

@dylwil3
Copy link
Collaborator

dylwil3 commented Aug 18, 2025

Apologies again @ya7on - I do hope to return to the CFG work at some point!

@dylwil3 dylwil3 closed this Aug 18, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Related to preview mode features rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposed rule: global or nonlocal looks skippable but applies to the whole function

4 participants