diff --git a/src/Fable.Build/Quicktest/Python.fs b/src/Fable.Build/Quicktest/Python.fs index fa35b649d..dad00b8a5 100644 --- a/src/Fable.Build/Quicktest/Python.fs +++ b/src/Fable.Build/Quicktest/Python.fs @@ -14,6 +14,7 @@ let handle (args: string list) = // This ensures quicktest uses the locally built version, not PyPI if not (args |> List.contains "--skip-fable-library") then BuildFableLibraryPython().Run(false) + // Install fable-library in editable mode Command.Run("uv", $"pip install -e {fableLibraryBuildDir}") genericQuicktest diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 4bf0a1333..14ca2995b 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +* [Python] Support catching Python `BaseException` subclasses (`KeyboardInterrupt`, `SystemExit`, `GeneratorExit`) for Python interop (by @dbrattli) + ### Changed * [Python] F# `task { }` expressions now generate Python `async def` functions (by @dbrattli) +* [Python] Generate idiomatic `except` clauses for typed exception patterns (by @dbrattli) ### Fixed diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 93b490077..e58e64f41 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +* [Python] Support catching Python `BaseException` subclasses (`KeyboardInterrupt`, `SystemExit`, `GeneratorExit`) for Python interop (by @dbrattli) + ### Changed * [Python] F# `task { }` expressions now generate Python `async def` functions (by @dbrattli) +* [Python] Generate idiomatic `except` clauses for typed exception patterns (by @dbrattli) ### Fixed diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index b26b205f2..e28175c88 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -980,34 +980,75 @@ let transformBlock (com: IPythonCompiler) ctx ret (expr: Fable.Expr) : Statement | [] -> [ Pass ] | _ -> block |> transformBody com ctx ret +/// Get the Python expression for an exception type +let private getExceptionTypeExpr (com: IPythonCompiler) ctx = + function + | Fable.DeclaredType(entRef, _) -> Annotation.tryPyConstructor com ctx (com.GetEntity(entRef)) + | _ -> None + let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: option, finalizer) = // try .. catch statements cannot be tail call optimized let ctx = { ctx with TailCallOpportunity = None } - let handlers = - catch - |> Option.map (fun (param, body) -> - let body = transformBlock com ctx returnStrategy body - let exn = Expression.identifier "Exception" |> Some + let makeHandler exnType handlerBody identifier = + let transformedBody = transformBlock com ctx returnStrategy handlerBody + ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody) + + let handlers, handlerStmts = + match catch with + | None -> None, [] + | Some(param, catchBody) -> let identifier = ident com ctx param - [ ExceptHandler.exceptHandler (``type`` = exn, name = identifier, body = body) ] - ) + let extractedHandlers, fallback = + ExceptionHandling.extractExceptionHandlers catchBody + + match extractedHandlers with + | [] -> + // No type tests found, use BaseException to catch all exceptions including + // KeyboardInterrupt, SystemExit, GeneratorExit which don't inherit from Exception + let handler = + makeHandler (Expression.identifier "BaseException") catchBody identifier + + Some [ handler ], [] + + | _ -> + // Generate separate except clauses for each exception type + let handlers, stmts = + extractedHandlers + |> List.choose (fun (typ, handlerBody) -> + getExceptionTypeExpr com ctx typ + |> Option.map (fun (exnTypeExpr, stmts) -> + makeHandler exnTypeExpr handlerBody identifier, stmts + ) + ) + |> List.unzip - let finalizer, stmts = - match finalizer with - | Some finalizer -> - finalizer - |> transformBlock com ctx None + // Add fallback handler if fallback is not just a reraise + // Use BaseException to catch all exceptions including KeyboardInterrupt etc. + let fallbackHandlers = + match fallback with + | Some fallbackExpr when not (ExceptionHandling.isReraise fallbackExpr) -> + [ makeHandler (Expression.identifier "BaseException") fallbackExpr identifier ] + | _ -> [] + + Some(handlers @ fallbackHandlers), List.concat stmts + + let finalizer, finalizerStmts = + finalizer + |> Option.map (fun fin -> + transformBlock com ctx None fin |> List.partition ( function | Statement.NonLocal _ | Statement.Global _ -> false | _ -> true ) - | None -> [], [] + ) + |> Option.defaultValue ([], []) - stmts + handlerStmts + @ finalizerStmts @ [ Statement.try' ( transformBlock com ctx returnStrategy body, diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index 2ede467c7..1b83895c3 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -933,3 +933,82 @@ module Expression = |> List.map f |> List.unzip |> fun (results, stmtLists) -> results, combine stmtLists + +/// Utilities for extracting exception handler patterns from F# AST. +/// F# compiles try-with expressions differently depending on the number of patterns: +/// - 1-2 patterns: nested IfThenElse with TypeTest +/// - 3+ patterns: DecisionTree with targets +module ExceptionHandling = + + /// Check if an expression is a reraise of the caught exception. + /// Note: F# may rename the catch variable internally, so we check for any Throw(IdentExpr). + let isReraise = + function + | Fable.Extended(Fable.Throw(Some(Fable.IdentExpr _), _), _) -> true + | _ -> false + + /// Extract exception handlers from a catch body that uses type tests. + /// Returns: (handlers as (type * body) list, fallbackExpr option) + /// + /// Handles three patterns: + /// - Simple: `| :? ExceptionType -> body` + /// - Binding: `| :? ExceptionType as ex -> body` + /// - DecisionTree: F# compiles 3+ exception patterns as a decision tree + let rec extractExceptionHandlers (expr: Fable.Expr) : (Fable.Type * Fable.Expr) list * Fable.Expr option = + match expr with + // Binding pattern: | :? ExceptionType as ex -> body (check first as it's more specific) + // F# compiles this as: IfThenElse(Test(param, TypeTest), Let(ex, TypeCast(param, typ), body), else) + | Fable.IfThenElse(Fable.Test(Fable.IdentExpr _, Fable.TypeTest typ, _), + (Fable.Let(_, Fable.TypeCast(Fable.IdentExpr _, _), _) as thenExpr), + elseExpr, + _) -> + let restHandlers, fallback = extractExceptionHandlers elseExpr + (typ, thenExpr) :: restHandlers, fallback + + // Simple pattern: | :? ExceptionType -> body + | Fable.IfThenElse(Fable.Test(Fable.IdentExpr _, Fable.TypeTest typ, _), thenExpr, elseExpr, _) -> + let restHandlers, fallback = extractExceptionHandlers elseExpr + (typ, thenExpr) :: restHandlers, fallback + + // DecisionTree pattern: F# compiles 3+ exception patterns as a decision tree + | Fable.DecisionTree(decisionExpr, targets) -> + // Extract type -> targetIndex mappings from the decision expression + let rec extractFromDecision expr = + match expr with + | Fable.IfThenElse(Fable.Test(Fable.IdentExpr _, Fable.TypeTest typ, _), + Fable.DecisionTreeSuccess(targetIdx, boundValues, _), + elseExpr, + _) -> (typ, targetIdx, boundValues) :: extractFromDecision elseExpr + | Fable.DecisionTreeSuccess(targetIdx, boundValues, _) -> + // Wildcard/default case + [ (Fable.Any, targetIdx, boundValues) ] + | _ -> [] + + let typeToTarget = extractFromDecision decisionExpr + + // Map each type test to its handler body from targets + // If there are bound values (from `as ex` pattern), wrap the body in Let bindings + let handlers = + typeToTarget + |> List.choose (fun (typ, targetIdx, boundValues) -> + if targetIdx < List.length targets then + let (idents, body) = targets.[targetIdx] + // Wrap body with Let bindings for each bound value + let wrappedBody = + List.zip idents boundValues + |> List.fold (fun acc (ident, value) -> Fable.Let(ident, value, acc)) body + + Some(typ, wrappedBody) + else + None + ) + + // Separate the wildcard (Any) from specific type handlers + let specificHandlers = handlers |> List.filter (fun (typ, _) -> typ <> Fable.Any) + + let wildcardHandler = + handlers |> List.tryFind (fun (typ, _) -> typ = Fable.Any) |> Option.map snd + + specificHandlers, wildcardHandler + + | _ -> [], Some expr diff --git a/src/Fable.Transforms/Python/PythonPrinter.fs b/src/Fable.Transforms/Python/PythonPrinter.fs index 749ea7193..377f147d3 100644 --- a/src/Fable.Transforms/Python/PythonPrinter.fs +++ b/src/Fable.Transforms/Python/PythonPrinter.fs @@ -55,7 +55,7 @@ module PrinterExtensions = | Continue -> printer.Print("continue") member printer.Print(node: Try) = - printer.Print("try: ", ?loc = node.Loc) + printer.Print("try:", ?loc = node.Loc) printer.PrintBlock(node.Body) for handler in node.Handlers do diff --git a/tests/Python/TestMisc.fs b/tests/Python/TestMisc.fs index 00bc3bcd2..c4304146f 100644 --- a/tests/Python/TestMisc.fs +++ b/tests/Python/TestMisc.fs @@ -1017,6 +1017,55 @@ let ``test Type of try-with expression is correctly determined when exception ha f () |> equal 7 +// Custom exception for testing specific exception type handling +exception MyCustomException of string + +[] +let ``test try-with catches specific exception type`` () = + let f () = + try + raise (MyCustomException "test error") + 0 + with + | :? MyCustomException as ex -> + match ex.Data0 with + | "test error" -> 42 + | _ -> -1 + + f () |> equal 42 + +[] +let ``test try-with catches multiple specific exception types`` () = + let f exnType = + try + match exnType with + | 1 -> raise (MyCustomException "custom") + | 2 -> raise (ArgumentException "arg") + | _ -> failwith "generic" + 0 + with + | :? MyCustomException -> 1 + | :? ArgumentException -> 2 + | _ -> 3 + + f 1 |> equal 1 + f 2 |> equal 2 + f 3 |> equal 3 + +[] +let ``test try-with with unmatched exception type reraises`` () = + let mutable caught = "" + try + try + raise (ArgumentException "inner") + with + | :? MyCustomException -> caught <- "custom" + with + | :? ArgumentException -> caught <- "arg" + | _ -> caught <- "other" + + caught |> equal "arg" + [] let ``test use doesn't return on finally clause`` () = // See #211 let foo() =