diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 39b61402a..1cccea777 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli) +* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli) ## 5.0.0-rc.2 - 2026-03-03 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index fbb481dee..ff5feb0b4 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli) +* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli) ## 5.0.0-rc.2 - 2026-03-03 diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index b4b611206..7f280d15e 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -1210,9 +1210,29 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I // try .. catch statements cannot be tail call optimized let ctx = { ctx with TailCallOpportunity = None } - let makeHandler exnType handlerBody identifier = - let transformedBody = transformBlock com ctx returnStrategy handlerBody - ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody) + let makeHandler exnType handlerBody (param: Fable.Ident) identifier = + let (Identifier identName) = identifier + + // Python's `except E as name:` deletes `name` at the end of the except block. + // If the exception variable is used (e.g., captured in a deferred closure), copy + // it to a safe local with a unique name that Python won't delete, and rename all + // body references to use the safe copy. + if isIdentUsed param.Name handlerBody then + let safeIdentName = getUniqueNameInDeclarationScope ctx (identName + "_") + let safeParam = { param with Name = safeIdentName } + + let renamedBody = + FableTransforms.replaceValues (Map [ param.Name, Fable.IdentExpr safeParam ]) handlerBody + + let transformedBody = transformBlock com ctx returnStrategy renamedBody + // Annotated assignment: ex_: ExceptionType = ex + let saveStmt = + Statement.assign (identAsExpr com ctx safeParam, exnType, value = Expression.name identName) + + ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = saveStmt :: transformedBody) + else + let transformedBody = transformBlock com ctx returnStrategy handlerBody + ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody) let handlers, handlerStmts = match catch with @@ -1228,7 +1248,8 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I // No type tests found, use Exception to match F#/.NET semantics. // Users can explicitly catch KeyboardInterrupt, SystemExit, GeneratorExit // using type tests if needed. - let handler = makeHandler (Expression.identifier "Exception") catchBody identifier + let handler = + makeHandler (Expression.identifier "Exception") catchBody param identifier Some [ handler ], [] @@ -1239,7 +1260,7 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I |> List.choose (fun (typ, handlerBody) -> getExceptionTypeExpr com ctx typ |> Option.map (fun (exnTypeExpr, stmts) -> - makeHandler exnTypeExpr handlerBody identifier, stmts + makeHandler exnTypeExpr handlerBody param identifier, stmts ) ) |> List.unzip @@ -1249,7 +1270,9 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I let fallbackHandlers = match fallback with | Some fallbackExpr when not (ExceptionHandling.isReraise fallbackExpr) -> - [ makeHandler (Expression.identifier "Exception") fallbackExpr identifier ] + [ + makeHandler (Expression.identifier "Exception") fallbackExpr param identifier + ] | _ -> [] Some(handlers @ fallbackHandlers), List.concat stmts diff --git a/tests/Python/TestMisc.fs b/tests/Python/TestMisc.fs index c4304146f..7a9081752 100644 --- a/tests/Python/TestMisc.fs +++ b/tests/Python/TestMisc.fs @@ -1501,3 +1501,28 @@ let ``test static properties work correctly`` () = // Test read-only explicit property StaticPropertiesTestClass.ReadOnlyProperty |> equal "ReadOnlyValue" + +[] +let ``test exception variable captured in deferred zero-arg closure`` () = + // Regression: Python deletes the `except ... as name` variable at the end of the + // except block. A closure that captures it and is called *after* the block would + // get a NameError. Fix: copy to `name_` inside the block and rename references. + let getMsg = + try + failwith "boom" + fun () -> "no error" + with ex -> + fun () -> ex.Message // captured, called after block exits + getMsg () |> equal "boom" + +[] +let ``test exception variable captured in deferred single-arg closure`` () = + // Same fix, but the closure takes an argument (mirrors the AsyncRx `defer` pattern: + // `with ex -> ofAsyncWorker (fun obv _ -> obv.OnErrorAsync ex)`). + let sub = + try + failwith "boom" + (fun (_: int) -> "no error") + with ex -> + (fun (_: int) -> ex.Message) // lambda-with-arg captures ex + sub 42 |> equal "boom"