From be66ed7958f9bac8b62d8b7972303ac4efcee2fe Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 20 Aug 2026 12:40:14 -0500 Subject: [PATCH 1/2] Bind assignments in unreachable code so they still create locals. CPython treats any assignment in a function as making the name local, even after a return. Skipping those statements left later-assigned names looking global and hid UnboundLocalError. Fixes #11449 --- .../pyright-internal/src/analyzer/binder.ts | 120 ++++++++++++++++++ .../src/tests/samples/unbound7.py | 25 ++++ .../src/tests/typeEvaluator2.test.ts | 6 + 3 files changed, 151 insertions(+) create mode 100644 packages/pyright-internal/src/tests/samples/unbound7.py diff --git a/packages/pyright-internal/src/analyzer/binder.ts b/packages/pyright-internal/src/analyzer/binder.ts index 49951f2590fb..9cb3ea07d464 100644 --- a/packages/pyright-internal/src/analyzer/binder.ts +++ b/packages/pyright-internal/src/analyzer/binder.ts @@ -48,6 +48,7 @@ import { GlobalNode, IfNode, ImportAsNode, + ImportFromAsNode, ImportFromNode, ImportNode, IndexNode, @@ -3071,6 +3072,10 @@ export class Binder extends ParseTreeWalker { if (!this._moduleSymbolOnly) { const dummyScopeGenerator = new DummyScopeGenerator(this._currentScope, this._nodeInfo); dummyScopeGenerator.walk(statement); + + // Assignments in unreachable code still make names local, matching + // CPython. Bind those names without type-checking the dead code. + this._bindNamesInUnreachableCode(statement); } } } @@ -3078,6 +3083,17 @@ export class Binder extends ParseTreeWalker { return false; } + private _bindNamesInUnreachableCode(node: ParseNode) { + const bindTarget = (target: ExpressionNode) => { + this._bindPossibleTupleNamedTarget(target); + }; + const bindName = (name: NameNode) => { + this._bindNameToScope(this._currentScope, name); + }; + + new UnreachableNameBinder(bindTarget, bindName).walk(node); + } + private _createStartFlowNode() { const flowNode: FlowNode = { flags: FlowFlags.Start, @@ -4865,6 +4881,110 @@ export class ReturnFinder extends ParseTreeWalker { } } +// Binds assignment targets in unreachable code so they still create local +// symbols, matching CPython (an assignment after `return` makes the name +// local for the entire function). Nested functions and classes are skipped +// because DummyScopeGenerator already created their scopes. +class UnreachableNameBinder extends ParseTreeWalker { + constructor( + private readonly _bindTarget: (target: ExpressionNode) => void, + private readonly _bindName: (name: NameNode) => void + ) { + super(); + } + + override visitAssignment(node: AssignmentNode): boolean { + this._bindTarget(node.d.leftExpr); + return true; + } + + override visitAugmentedAssignment(node: AugmentedAssignmentNode): boolean { + this._bindTarget(node.d.leftExpr); + return true; + } + + override visitTypeAnnotation(node: TypeAnnotationNode): boolean { + this._bindTarget(node.d.valueExpr); + return true; + } + + override visitAssignmentExpression(node: AssignmentExpressionNode): boolean { + this._bindName(node.d.name); + return true; + } + + override visitFor(node: ForNode): boolean { + this._bindTarget(node.d.targetExpr); + return true; + } + + override visitWith(node: WithNode): boolean { + node.d.withItems.forEach((item) => { + if (item.d.target) { + this._bindTarget(item.d.target); + } + }); + return true; + } + + override visitDel(node: DelNode): boolean { + node.d.targets.forEach((target) => { + this._bindTarget(target); + }); + return true; + } + + override visitExcept(node: ExceptNode): boolean { + if (node.d.name) { + this._bindName(node.d.name); + } + return true; + } + + override visitImportAs(node: ImportAsNode): boolean { + if (node.d.alias) { + this._bindName(node.d.alias); + } else if (node.d.module.d.nameParts.length > 0) { + this._bindName(node.d.module.d.nameParts[0]); + } + return false; + } + + override visitImportFrom(node: ImportFromNode): boolean { + node.d.imports.forEach((importSymbolNode) => { + this._bindName(importSymbolNode.d.alias || importSymbolNode.d.name); + }); + return false; + } + + override visitFunction(node: FunctionNode): boolean { + this._bindName(node.d.name); + return false; + } + + override visitClass(node: ClassNode): boolean { + this._bindName(node.d.name); + return false; + } + + override visitTypeAlias(node: TypeAliasNode): boolean { + this._bindName(node.d.name); + return false; + } + + override visitPatternAs(node: PatternAsNode): boolean { + if (node.d.target) { + this._bindName(node.d.target); + } + return true; + } + + override visitPatternCapture(node: PatternCaptureNode): boolean { + this._bindName(node.d.target); + return false; + } +} + // Creates dummy scopes for classes or functions within a parse tree. // This is needed in cases where the parse tree has been determined // to be unreachable. There are code paths where the type evaluator diff --git a/packages/pyright-internal/src/tests/samples/unbound7.py b/packages/pyright-internal/src/tests/samples/unbound7.py new file mode 100644 index 000000000000..d3af49615f15 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/unbound7.py @@ -0,0 +1,25 @@ +# This sample tests that assignments in unreachable code still make +# a name local, matching CPython. + +variable = "global" + + +def example_with_local(): + # This should generate an error because the later assignment makes + # "variable" a local, so this read is unbound. + return variable + variable = "local" + + +def example_without_local(): + return variable + + +def outer(): + def inner(): + # This should not generate an error; the assignment below binds + # "variable" in outer even though it is unreachable. + nonlocal variable + + return + variable = "local" diff --git a/packages/pyright-internal/src/tests/typeEvaluator2.test.ts b/packages/pyright-internal/src/tests/typeEvaluator2.test.ts index b6db2402a2f3..79566589e5ea 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator2.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator2.test.ts @@ -396,6 +396,12 @@ test('Unbound6', () => { TestUtils.validateResults(analysisResults, 8); }); +test('Unbound7', () => { + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['unbound7.py']); + + TestUtils.validateResults(analysisResults, 1); +}); + test('LiteralForLoop1', () => { const configOptions = new ConfigOptions(Uri.empty()); configOptions.diagnosticRuleSet.reportPossiblyUnboundVariable = 'error'; From 3268d67557a7929d88a7a4859511cc712adb4fd0 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 25 Aug 2026 23:43:10 -0500 Subject: [PATCH 2/2] fix(binder): preserve unreachable binding scopes --- .../pyright-internal/src/analyzer/binder.ts | 98 ++++++++++++++++++- .../src/tests/samples/unbound7.py | 92 +++++++++++++++++ .../src/tests/typeEvaluator2.test.ts | 2 +- 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/binder.ts b/packages/pyright-internal/src/analyzer/binder.ts index 9cb3ea07d464..24058b4a7732 100644 --- a/packages/pyright-internal/src/analyzer/binder.ts +++ b/packages/pyright-internal/src/analyzer/binder.ts @@ -48,7 +48,6 @@ import { GlobalNode, IfNode, ImportAsNode, - ImportFromAsNode, ImportFromNode, ImportNode, IndexNode, @@ -3084,6 +3083,13 @@ export class Binder extends ParseTreeWalker { } private _bindNamesInUnreachableCode(node: ParseNode) { + // Directives apply to the entire scope, so process global and + // nonlocal declarations before binding any names. + new UnreachableDirectiveFinder( + (globalNode) => this.visitGlobal(globalNode), + (nonlocalNode) => this.visitNonlocal(nonlocalNode) + ).walk(node); + const bindTarget = (target: ExpressionNode) => { this._bindPossibleTupleNamedTarget(target); }; @@ -4881,10 +4887,50 @@ export class ReturnFinder extends ParseTreeWalker { } } +// Discovers and processes `global` and `nonlocal` directives in unreachable code. +// Directives must be evaluated before binding names to ensure that later +// assignments do not bind as locals in the current scope. Nested function, +// class, and lambda bodies are skipped because their directives belong to +// nested scopes. +class UnreachableDirectiveFinder extends ParseTreeWalker { + constructor( + private readonly _bindGlobal: (node: GlobalNode) => void, + private readonly _bindNonlocal: (node: NonlocalNode) => void + ) { + super(); + } + + override visitGlobal(node: GlobalNode): boolean { + this._bindGlobal(node); + return false; + } + + override visitNonlocal(node: NonlocalNode): boolean { + this._bindNonlocal(node); + return false; + } + + override visitFunction(node: FunctionNode): boolean { + return false; + } + + override visitClass(node: ClassNode): boolean { + return false; + } + + override visitLambda(node: LambdaNode): boolean { + return false; + } +} + // Binds assignment targets in unreachable code so they still create local // symbols, matching CPython (an assignment after `return` makes the name -// local for the entire function). Nested functions and classes are skipped -// because DummyScopeGenerator already created their scopes. +// local for the entire function). +// Header expressions for nested functions, classes, and lambdas (decorators, +// parameter defaults/annotations, type parameters, and class bases/arguments) +// are evaluated in the enclosing scope, so they are walked to catch assignment +// expressions (:=). Nested bodies are skipped because DummyScopeGenerator +// already created their scopes and they belong to nested scopes. class UnreachableNameBinder extends ParseTreeWalker { constructor( private readonly _bindTarget: (target: ExpressionNode) => void, @@ -4959,11 +5005,57 @@ class UnreachableNameBinder extends ParseTreeWalker { override visitFunction(node: FunctionNode): boolean { this._bindName(node.d.name); + + this.walkMultiple(node.d.decorators); + + node.d.params.forEach((param) => { + if (param.d.defaultValue) { + this.walk(param.d.defaultValue); + } + if (param.d.annotation) { + this.walk(param.d.annotation); + } + if (param.d.annotationComment) { + this.walk(param.d.annotationComment); + } + }); + + if (node.d.typeParams) { + this.walk(node.d.typeParams); + } + + if (node.d.returnAnnotation) { + this.walk(node.d.returnAnnotation); + } + + if (node.d.funcAnnotationComment) { + this.walk(node.d.funcAnnotationComment); + } + return false; } override visitClass(node: ClassNode): boolean { this._bindName(node.d.name); + + this.walkMultiple(node.d.decorators); + + if (node.d.typeParams) { + this.walk(node.d.typeParams); + } + + this.walkMultiple(node.d.arguments); + + return false; + } + + override visitLambda(node: LambdaNode): boolean { + node.d.params.forEach((param) => { + if (param.d.defaultValue) { + this.walk(param.d.defaultValue); + } + }); + return false; } diff --git a/packages/pyright-internal/src/tests/samples/unbound7.py b/packages/pyright-internal/src/tests/samples/unbound7.py index d3af49615f15..f685e298df0c 100644 --- a/packages/pyright-internal/src/tests/samples/unbound7.py +++ b/packages/pyright-internal/src/tests/samples/unbound7.py @@ -23,3 +23,95 @@ def inner(): return variable = "local" + + +g_var = "global" + + +def example_with_unreachable_global(): + # This should not generate an error; the unreachable global directive makes + # g_var global rather than local. + return g_var + global g_var + g_var = "local" + + +def example_with_unreachable_nonlocal(): + n_var = "outer" + + def inner(): + # This should not generate an error; the unreachable nonlocal directive makes + # n_var nonlocal rather than local. + return n_var + nonlocal n_var + n_var = "local" + + +lambda_body_var = "global" + + +def example_with_unreachable_lambda_body(): + # This should not generate an error; the assignment expression belongs to the + # lambda's scope rather than the enclosing function. + return lambda_body_var + _ = lambda: (lambda_body_var := "lambda") + + +lambda_default_var = "global" + + +def example_with_unreachable_lambda_default(): + # This should generate an error because the default value is evaluated in the + # enclosing scope, making lambda_default_var a local. + return lambda_default_var + _ = lambda a=(lambda_default_var := "default"): a + + +func_default_var = "global" + + +def example_with_unreachable_func_default(): + # This should generate an error because parameter defaults are evaluated in the + # enclosing scope, making func_default_var a local. + return func_default_var + + def nested_func(a=(func_default_var := "default")): + pass + + +func_decorator_var = "global" + + +def example_with_unreachable_func_decorator(): + # This should generate an error because decorators are evaluated in the enclosing + # scope, making func_decorator_var a local. + return func_decorator_var + + @(func_decorator_var := (lambda fn: fn)) + def nested_func(): + pass + + +class_base_var = "global" + + +def example_with_unreachable_class_base(): + # This should generate an error because class bases are evaluated in the enclosing + # scope, making class_base_var a local. + return class_base_var + + class NestedClass((class_base_var := object)): + pass + + +class_decorator_var = "global" + + +def example_with_unreachable_class_decorator(): + # This should generate an error because class decorators are evaluated in the + # enclosing scope, making class_decorator_var a local. + return class_decorator_var + + @(class_decorator_var := (lambda cls: cls)) + class NestedClass: + pass diff --git a/packages/pyright-internal/src/tests/typeEvaluator2.test.ts b/packages/pyright-internal/src/tests/typeEvaluator2.test.ts index 79566589e5ea..0ed0ee626f49 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator2.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator2.test.ts @@ -399,7 +399,7 @@ test('Unbound6', () => { test('Unbound7', () => { const analysisResults = TestUtils.typeAnalyzeSampleFiles(['unbound7.py']); - TestUtils.validateResults(analysisResults, 1); + TestUtils.validateResults(analysisResults, 6); }); test('LiteralForLoop1', () => {