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.Build/Quicktest/Python.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
69 changes: 55 additions & 14 deletions src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fable.Ident * Fable.Expr>, 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,
Expand Down
79 changes: 79 additions & 0 deletions src/Fable.Transforms/Python/Fable2Python.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/Fable.Transforms/Python/PythonPrinter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/Python/TestMisc.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,55 @@

f () |> equal 7

// Custom exception for testing specific exception type handling
exception MyCustomException of string

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

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

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

[<Fact>]
let ``test use doesn't return on finally clause`` () = // See #211
let foo() =
Expand Down Expand Up @@ -1088,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 / 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.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 (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 (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

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
| _ -> x <- "?"
equal "3" x

Expand Down
Loading