Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 29 additions & 6 deletions src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ], []

Expand All @@ -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
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions tests/Python/TestMisc.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,7 +1137,7 @@
| 2 -> x <- "2"
| 3 | 4 -> x <- "3" // Multiple cases are allowed
// | 5 | 6 as j -> x <- string j // This prevents the optimization
| 4 -> x <- "4" // Unreachable cases are removed

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / analyzers (src/Fable.Compiler/Fable.Compiler.fsproj)

This rule will never be matched

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / build-python (ubuntu-latest, 3.12)

This rule will never be matched

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / build-python (ubuntu-latest, 3.13)

This rule will never be matched

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / build-python (ubuntu-latest, 3.14)

This rule will never be matched

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / build-python (windows-latest, 3.12)

This rule will never be matched

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / build-python (windows-latest, 3.13)

This rule will never be matched

Check warning on line 1140 in tests/Python/TestMisc.fs

View workflow job for this annotation

GitHub Actions / build-python (windows-latest, 3.14)

This rule will never be matched
| _ -> x <- "?"
equal "3" x

Expand Down Expand Up @@ -1501,3 +1501,28 @@

// Test read-only explicit property
StaticPropertiesTestClass.ReadOnlyProperty |> equal "ReadOnlyValue"

[<Fact>]
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"

[<Fact>]
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"
Loading