Skip to content

Commit 1b7ce96

Browse files
authored
[Python] Generate idiomatic except clauses for typed exception patterns (#4302)
* [Python] Generate idiomatic except clauses for typed exception patterns * Added changelog entries
1 parent fba5acb commit 1b7ce96

File tree

7 files changed

+195
-15
lines changed

7 files changed

+195
-15
lines changed

src/Fable.Build/Quicktest/Python.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let handle (args: string list) =
1414
// This ensures quicktest uses the locally built version, not PyPI
1515
if not (args |> List.contains "--skip-fable-library") then
1616
BuildFableLibraryPython().Run(false)
17+
// Install fable-library in editable mode
1718
Command.Run("uv", $"pip install -e {fableLibraryBuildDir}")
1819

1920
genericQuicktest

src/Fable.Cli/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
* [Python] Support catching Python `BaseException` subclasses (`KeyboardInterrupt`, `SystemExit`, `GeneratorExit`) for Python interop (by @dbrattli)
13+
1014
### Changed
1115

1216
* [Python] F# `task { }` expressions now generate Python `async def` functions (by @dbrattli)
17+
* [Python] Generate idiomatic `except` clauses for typed exception patterns (by @dbrattli)
1318

1419
### Fixed
1520

src/Fable.Compiler/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
* [Python] Support catching Python `BaseException` subclasses (`KeyboardInterrupt`, `SystemExit`, `GeneratorExit`) for Python interop (by @dbrattli)
13+
1014
### Changed
1115

1216
* [Python] F# `task { }` expressions now generate Python `async def` functions (by @dbrattli)
17+
* [Python] Generate idiomatic `except` clauses for typed exception patterns (by @dbrattli)
1318

1419
### Fixed
1520

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

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -980,34 +980,75 @@ let transformBlock (com: IPythonCompiler) ctx ret (expr: Fable.Expr) : Statement
980980
| [] -> [ Pass ]
981981
| _ -> block |> transformBody com ctx ret
982982

983+
/// Get the Python expression for an exception type
984+
let private getExceptionTypeExpr (com: IPythonCompiler) ctx =
985+
function
986+
| Fable.DeclaredType(entRef, _) -> Annotation.tryPyConstructor com ctx (com.GetEntity(entRef))
987+
| _ -> None
988+
983989
let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: option<Fable.Ident * Fable.Expr>, finalizer) =
984990
// try .. catch statements cannot be tail call optimized
985991
let ctx = { ctx with TailCallOpportunity = None }
986992

987-
let handlers =
988-
catch
989-
|> Option.map (fun (param, body) ->
990-
let body = transformBlock com ctx returnStrategy body
991-
let exn = Expression.identifier "Exception" |> Some
993+
let makeHandler exnType handlerBody identifier =
994+
let transformedBody = transformBlock com ctx returnStrategy handlerBody
995+
ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody)
996+
997+
let handlers, handlerStmts =
998+
match catch with
999+
| None -> None, []
1000+
| Some(param, catchBody) ->
9921001
let identifier = ident com ctx param
9931002

994-
[ ExceptHandler.exceptHandler (``type`` = exn, name = identifier, body = body) ]
995-
)
1003+
let extractedHandlers, fallback =
1004+
ExceptionHandling.extractExceptionHandlers catchBody
1005+
1006+
match extractedHandlers with
1007+
| [] ->
1008+
// No type tests found, use BaseException to catch all exceptions including
1009+
// KeyboardInterrupt, SystemExit, GeneratorExit which don't inherit from Exception
1010+
let handler =
1011+
makeHandler (Expression.identifier "BaseException") catchBody identifier
1012+
1013+
Some [ handler ], []
1014+
1015+
| _ ->
1016+
// Generate separate except clauses for each exception type
1017+
let handlers, stmts =
1018+
extractedHandlers
1019+
|> List.choose (fun (typ, handlerBody) ->
1020+
getExceptionTypeExpr com ctx typ
1021+
|> Option.map (fun (exnTypeExpr, stmts) ->
1022+
makeHandler exnTypeExpr handlerBody identifier, stmts
1023+
)
1024+
)
1025+
|> List.unzip
9961026

997-
let finalizer, stmts =
998-
match finalizer with
999-
| Some finalizer ->
1000-
finalizer
1001-
|> transformBlock com ctx None
1027+
// Add fallback handler if fallback is not just a reraise
1028+
// Use BaseException to catch all exceptions including KeyboardInterrupt etc.
1029+
let fallbackHandlers =
1030+
match fallback with
1031+
| Some fallbackExpr when not (ExceptionHandling.isReraise fallbackExpr) ->
1032+
[ makeHandler (Expression.identifier "BaseException") fallbackExpr identifier ]
1033+
| _ -> []
1034+
1035+
Some(handlers @ fallbackHandlers), List.concat stmts
1036+
1037+
let finalizer, finalizerStmts =
1038+
finalizer
1039+
|> Option.map (fun fin ->
1040+
transformBlock com ctx None fin
10021041
|> List.partition (
10031042
function
10041043
| Statement.NonLocal _
10051044
| Statement.Global _ -> false
10061045
| _ -> true
10071046
)
1008-
| None -> [], []
1047+
)
1048+
|> Option.defaultValue ([], [])
10091049

1010-
stmts
1050+
handlerStmts
1051+
@ finalizerStmts
10111052
@ [
10121053
Statement.try' (
10131054
transformBlock com ctx returnStrategy body,

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,3 +933,82 @@ module Expression =
933933
|> List.map f
934934
|> List.unzip
935935
|> fun (results, stmtLists) -> results, combine stmtLists
936+
937+
/// Utilities for extracting exception handler patterns from F# AST.
938+
/// F# compiles try-with expressions differently depending on the number of patterns:
939+
/// - 1-2 patterns: nested IfThenElse with TypeTest
940+
/// - 3+ patterns: DecisionTree with targets
941+
module ExceptionHandling =
942+
943+
/// Check if an expression is a reraise of the caught exception.
944+
/// Note: F# may rename the catch variable internally, so we check for any Throw(IdentExpr).
945+
let isReraise =
946+
function
947+
| Fable.Extended(Fable.Throw(Some(Fable.IdentExpr _), _), _) -> true
948+
| _ -> false
949+
950+
/// Extract exception handlers from a catch body that uses type tests.
951+
/// Returns: (handlers as (type * body) list, fallbackExpr option)
952+
///
953+
/// Handles three patterns:
954+
/// - Simple: `| :? ExceptionType -> body`
955+
/// - Binding: `| :? ExceptionType as ex -> body`
956+
/// - DecisionTree: F# compiles 3+ exception patterns as a decision tree
957+
let rec extractExceptionHandlers (expr: Fable.Expr) : (Fable.Type * Fable.Expr) list * Fable.Expr option =
958+
match expr with
959+
// Binding pattern: | :? ExceptionType as ex -> body (check first as it's more specific)
960+
// F# compiles this as: IfThenElse(Test(param, TypeTest), Let(ex, TypeCast(param, typ), body), else)
961+
| Fable.IfThenElse(Fable.Test(Fable.IdentExpr _, Fable.TypeTest typ, _),
962+
(Fable.Let(_, Fable.TypeCast(Fable.IdentExpr _, _), _) as thenExpr),
963+
elseExpr,
964+
_) ->
965+
let restHandlers, fallback = extractExceptionHandlers elseExpr
966+
(typ, thenExpr) :: restHandlers, fallback
967+
968+
// Simple pattern: | :? ExceptionType -> body
969+
| Fable.IfThenElse(Fable.Test(Fable.IdentExpr _, Fable.TypeTest typ, _), thenExpr, elseExpr, _) ->
970+
let restHandlers, fallback = extractExceptionHandlers elseExpr
971+
(typ, thenExpr) :: restHandlers, fallback
972+
973+
// DecisionTree pattern: F# compiles 3+ exception patterns as a decision tree
974+
| Fable.DecisionTree(decisionExpr, targets) ->
975+
// Extract type -> targetIndex mappings from the decision expression
976+
let rec extractFromDecision expr =
977+
match expr with
978+
| Fable.IfThenElse(Fable.Test(Fable.IdentExpr _, Fable.TypeTest typ, _),
979+
Fable.DecisionTreeSuccess(targetIdx, boundValues, _),
980+
elseExpr,
981+
_) -> (typ, targetIdx, boundValues) :: extractFromDecision elseExpr
982+
| Fable.DecisionTreeSuccess(targetIdx, boundValues, _) ->
983+
// Wildcard/default case
984+
[ (Fable.Any, targetIdx, boundValues) ]
985+
| _ -> []
986+
987+
let typeToTarget = extractFromDecision decisionExpr
988+
989+
// Map each type test to its handler body from targets
990+
// If there are bound values (from `as ex` pattern), wrap the body in Let bindings
991+
let handlers =
992+
typeToTarget
993+
|> List.choose (fun (typ, targetIdx, boundValues) ->
994+
if targetIdx < List.length targets then
995+
let (idents, body) = targets.[targetIdx]
996+
// Wrap body with Let bindings for each bound value
997+
let wrappedBody =
998+
List.zip idents boundValues
999+
|> List.fold (fun acc (ident, value) -> Fable.Let(ident, value, acc)) body
1000+
1001+
Some(typ, wrappedBody)
1002+
else
1003+
None
1004+
)
1005+
1006+
// Separate the wildcard (Any) from specific type handlers
1007+
let specificHandlers = handlers |> List.filter (fun (typ, _) -> typ <> Fable.Any)
1008+
1009+
let wildcardHandler =
1010+
handlers |> List.tryFind (fun (typ, _) -> typ = Fable.Any) |> Option.map snd
1011+
1012+
specificHandlers, wildcardHandler
1013+
1014+
| _ -> [], Some expr

src/Fable.Transforms/Python/PythonPrinter.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ module PrinterExtensions =
5555
| Continue -> printer.Print("continue")
5656

5757
member printer.Print(node: Try) =
58-
printer.Print("try: ", ?loc = node.Loc)
58+
printer.Print("try:", ?loc = node.Loc)
5959
printer.PrintBlock(node.Body)
6060

6161
for handler in node.Handlers do

tests/Python/TestMisc.fs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,55 @@ let ``test Type of try-with expression is correctly determined when exception ha
10171017

10181018
f () |> equal 7
10191019

1020+
// Custom exception for testing specific exception type handling
1021+
exception MyCustomException of string
1022+
1023+
[<Fact>]
1024+
let ``test try-with catches specific exception type`` () =
1025+
let f () =
1026+
try
1027+
raise (MyCustomException "test error")
1028+
0
1029+
with
1030+
| :? MyCustomException as ex ->
1031+
match ex.Data0 with
1032+
| "test error" -> 42
1033+
| _ -> -1
1034+
1035+
f () |> equal 42
1036+
1037+
[<Fact>]
1038+
let ``test try-with catches multiple specific exception types`` () =
1039+
let f exnType =
1040+
try
1041+
match exnType with
1042+
| 1 -> raise (MyCustomException "custom")
1043+
| 2 -> raise (ArgumentException "arg")
1044+
| _ -> failwith "generic"
1045+
0
1046+
with
1047+
| :? MyCustomException -> 1
1048+
| :? ArgumentException -> 2
1049+
| _ -> 3
1050+
1051+
f 1 |> equal 1
1052+
f 2 |> equal 2
1053+
f 3 |> equal 3
1054+
1055+
[<Fact>]
1056+
let ``test try-with with unmatched exception type reraises`` () =
1057+
let mutable caught = ""
1058+
try
1059+
try
1060+
raise (ArgumentException "inner")
1061+
with
1062+
| :? MyCustomException -> caught <- "custom"
1063+
with
1064+
| :? ArgumentException -> caught <- "arg"
1065+
| _ -> caught <- "other"
1066+
1067+
caught |> equal "arg"
1068+
10201069
[<Fact>]
10211070
let ``test use doesn't return on finally clause`` () = // See #211
10221071
let foo() =

0 commit comments

Comments
 (0)