Skip to content

Commit 2033315

Browse files
authored
[Python] Fix exception variable captured in deferred closures (NameError) (#4382)
1 parent 6be17f5 commit 2033315

4 files changed

Lines changed: 56 additions & 6 deletions

File tree

src/Fable.Cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
* [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli)
13+
* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli)
1314

1415
## 5.0.0-rc.2 - 2026-03-03
1516

src/Fable.Compiler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
* [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli)
13+
* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli)
1314

1415
## 5.0.0-rc.2 - 2026-03-03
1516

src/Fable.Transforms/Python/Fable2Python.Transforms.fs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,9 +1210,29 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I
12101210
// try .. catch statements cannot be tail call optimized
12111211
let ctx = { ctx with TailCallOpportunity = None }
12121212

1213-
let makeHandler exnType handlerBody identifier =
1214-
let transformedBody = transformBlock com ctx returnStrategy handlerBody
1215-
ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody)
1213+
let makeHandler exnType handlerBody (param: Fable.Ident) identifier =
1214+
let (Identifier identName) = identifier
1215+
1216+
// Python's `except E as name:` deletes `name` at the end of the except block.
1217+
// If the exception variable is used (e.g., captured in a deferred closure), copy
1218+
// it to a safe local with a unique name that Python won't delete, and rename all
1219+
// body references to use the safe copy.
1220+
if isIdentUsed param.Name handlerBody then
1221+
let safeIdentName = getUniqueNameInDeclarationScope ctx (identName + "_")
1222+
let safeParam = { param with Name = safeIdentName }
1223+
1224+
let renamedBody =
1225+
FableTransforms.replaceValues (Map [ param.Name, Fable.IdentExpr safeParam ]) handlerBody
1226+
1227+
let transformedBody = transformBlock com ctx returnStrategy renamedBody
1228+
// Annotated assignment: ex_: ExceptionType = ex
1229+
let saveStmt =
1230+
Statement.assign (identAsExpr com ctx safeParam, exnType, value = Expression.name identName)
1231+
1232+
ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = saveStmt :: transformedBody)
1233+
else
1234+
let transformedBody = transformBlock com ctx returnStrategy handlerBody
1235+
ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody)
12161236

12171237
let handlers, handlerStmts =
12181238
match catch with
@@ -1228,7 +1248,8 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I
12281248
// No type tests found, use Exception to match F#/.NET semantics.
12291249
// Users can explicitly catch KeyboardInterrupt, SystemExit, GeneratorExit
12301250
// using type tests if needed.
1231-
let handler = makeHandler (Expression.identifier "Exception") catchBody identifier
1251+
let handler =
1252+
makeHandler (Expression.identifier "Exception") catchBody param identifier
12321253

12331254
Some [ handler ], []
12341255

@@ -1239,7 +1260,7 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I
12391260
|> List.choose (fun (typ, handlerBody) ->
12401261
getExceptionTypeExpr com ctx typ
12411262
|> Option.map (fun (exnTypeExpr, stmts) ->
1242-
makeHandler exnTypeExpr handlerBody identifier, stmts
1263+
makeHandler exnTypeExpr handlerBody param identifier, stmts
12431264
)
12441265
)
12451266
|> List.unzip
@@ -1249,7 +1270,9 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I
12491270
let fallbackHandlers =
12501271
match fallback with
12511272
| Some fallbackExpr when not (ExceptionHandling.isReraise fallbackExpr) ->
1252-
[ makeHandler (Expression.identifier "Exception") fallbackExpr identifier ]
1273+
[
1274+
makeHandler (Expression.identifier "Exception") fallbackExpr param identifier
1275+
]
12531276
| _ -> []
12541277

12551278
Some(handlers @ fallbackHandlers), List.concat stmts

tests/Python/TestMisc.fs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,3 +1501,28 @@ let ``test static properties work correctly`` () =
15011501

15021502
// Test read-only explicit property
15031503
StaticPropertiesTestClass.ReadOnlyProperty |> equal "ReadOnlyValue"
1504+
1505+
[<Fact>]
1506+
let ``test exception variable captured in deferred zero-arg closure`` () =
1507+
// Regression: Python deletes the `except ... as name` variable at the end of the
1508+
// except block. A closure that captures it and is called *after* the block would
1509+
// get a NameError. Fix: copy to `name_` inside the block and rename references.
1510+
let getMsg =
1511+
try
1512+
failwith "boom"
1513+
fun () -> "no error"
1514+
with ex ->
1515+
fun () -> ex.Message // captured, called after block exits
1516+
getMsg () |> equal "boom"
1517+
1518+
[<Fact>]
1519+
let ``test exception variable captured in deferred single-arg closure`` () =
1520+
// Same fix, but the closure takes an argument (mirrors the AsyncRx `defer` pattern:
1521+
// `with ex -> ofAsyncWorker (fun obv _ -> obv.OnErrorAsync ex)`).
1522+
let sub =
1523+
try
1524+
failwith "boom"
1525+
(fun (_: int) -> "no error")
1526+
with ex ->
1527+
(fun (_: int) -> ex.Message) // lambda-with-arg captures ex
1528+
sub 42 |> equal "boom"

0 commit comments

Comments
 (0)