Skip to content

Commit e4d4878

Browse files
authored
[Python] Fix async await in branches and cast in closures (#4341)
1 parent b5c32e6 commit e4d4878

9 files changed

Lines changed: 163 additions & 36 deletions

File tree

pyrightconfig.ci.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"**/.venv/**",
55
"**/node_modules/**",
66
"temp/tests/Python/test_applicative.py",
7-
"temp/tests/Python/test_misc.py",
87
"temp/tests/Python/fable_modules/thoth_json_python/encode.py"
98
]
109
}

src/Fable.Cli/CHANGELOG.md

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

88
## Unreleased
99

10+
### Fixed
11+
12+
* [Python] Fix `Task<T>` pass-through returns not being awaited in if/else and try/with branches (by @dbrattli)
13+
* [Python] Fix `:? T as x` type test pattern in closures causing `UnboundLocalError` due to `cast()` shadowing outer variable (by @dbrattli)
14+
1015
## 5.0.0-alpha.23 - 2026-02-03
1116

1217
### Changed

src/Fable.Compiler/CHANGELOG.md

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

88
## Unreleased
99

10+
### Fixed
11+
12+
* [Python] Fix `Task<T>` pass-through returns not being awaited in if/else and try/with branches (by @dbrattli)
13+
* [Python] Fix `:? T as x` type test pattern in closures causing `UnboundLocalError` due to `cast()` shadowing outer variable (by @dbrattli)
14+
1015
## 5.0.0-alpha.22 - 2026-02-03
1116

1217
### Changed

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ let generatePythonProtocolDunders (com: IPythonCompiler) ctx (classEnt: Fable.En
149149

150150
// Generate IMapping dunders: __getitem__, __contains__, __len__, __iter__
151151
// Note: Method names use Python naming convention (lowercase with underscores)
152+
// __iter__ always yields keys following Python's Mapping convention.
153+
// F# iteration (for KeyValue(k,v) in map) compiles to GetEnumerator()/MoveNext()/Current
154+
// and never uses __iter__, so this is purely for Python interop.
152155
let mappingDunders =
153156
if hasIMapping || hasIMutableMapping then
154157
[
@@ -170,9 +173,7 @@ let generatePythonProtocolDunders (com: IPythonCompiler) ctx (classEnt: Fable.En
170173
Arguments.arguments [ Arg.arg "self" ],
171174
body = [ Statement.return' (Expression.attribute (self, Identifier "Count", Load)) ]
172175
)
173-
// def __iter__(self):
174-
// for kv in to_iterator(self.GetEnumerator()):
175-
// yield kv[0] # kv is a tuple (key, value)
176+
// def __iter__(self): yields keys only (Python Mapping convention)
176177
let toIterator = com.GetImportExpr(ctx, "fable_library.util", "to_iterator")
177178
let kvVar = Expression.name "kv"
178179

@@ -184,7 +185,7 @@ let generatePythonProtocolDunders (com: IPythonCompiler) ctx (classEnt: Fable.En
184185
Statement.for' (
185186
kvVar,
186187
Expression.call (toIterator, [ selfCall "GetEnumerator" [] ]),
187-
// Access kv[0] since the enumerator yields tuples (key, value)
188+
// Yield kv[0] (key) since GetEnumerator yields (key, value) tuples
188189
[
189190
Statement.expr (
190191
Yield(Some(Expression.subscript (kvVar, Expression.intConstant 0, Load)))

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

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,35 +1305,12 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: option<F
13051305
)
13061306
]
13071307

1308-
/// Helper function to generate a cast statement for type narrowing
1309-
let makeCastStatement (com: IPythonCompiler) ctx (ident: Fable.Ident) (typ: Fable.Type) =
1310-
// Only add cast for generic types where type checker needs help
1311-
let hasGenerics =
1312-
match typ with
1313-
| Fable.DeclaredType(_, genArgs) when not (List.isEmpty genArgs) -> true
1314-
| Fable.Array _ -> true
1315-
| Fable.List _ -> true
1316-
| _ -> false
1317-
1318-
// Check if the original type already has the same generic arguments
1319-
// If so, Pyright can infer the narrowed type and the cast is unnecessary
1320-
let originalGenArgs = Annotation.getGenericArgs ident.Type
1321-
let targetGenArgs = Annotation.getGenericArgs typ
1322-
1323-
let sameGenericArgs =
1324-
not (List.isEmpty originalGenArgs)
1325-
&& not (List.isEmpty targetGenArgs)
1326-
&& originalGenArgs = targetGenArgs
1327-
1328-
if hasGenerics && not sameGenericArgs then
1329-
let cast = com.GetImportExpr(ctx, "typing", "cast")
1330-
let varExpr = identAsExpr com ctx ident
1331-
let typeAnnotation, importStmts = Annotation.typeAnnotation com ctx None typ
1332-
let castExpr = Expression.call (cast, [ typeAnnotation; varExpr ])
1333-
let castStmt = Statement.assign ([ varExpr ], castExpr)
1334-
importStmts @ [ castStmt ]
1335-
else
1336-
[]
1308+
/// Helper function to generate a cast statement for type narrowing.
1309+
/// Disabled: The isinstance() check in the if-guard already provides Pyright with
1310+
/// sufficient type narrowing. Generating `value = cast(T, value)` causes Python
1311+
/// UnboundLocalError when inside closures (e.g., task CE bodies), because the
1312+
/// assignment makes Python treat the variable as local throughout the function.
1313+
let makeCastStatement (_com: IPythonCompiler) _ctx (_ident: Fable.Ident) (_typ: Fable.Type) = []
13371314

13381315
let rec transformIfStatement (com: IPythonCompiler) ctx r ret guardExpr thenStmnt elseStmnt =
13391316
// Create refined context for then/else branches based on guard type

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,12 +1125,73 @@ module Helpers =
11251125
let callInfo = Fable.CallInfo.Create(args = [ e ])
11261126
makeIdentExpr "str" |> makeCall None Fable.String callInfo
11271127

1128-
/// Transform return statements to wrap their values with await
1129-
let wrapReturnWithAwait (body: Statement list) : Statement list =
1128+
/// Transform return statements to wrap their values with await.
1129+
/// Recursively traverses nested control flow (if/else, match, try, for, while, with)
1130+
/// to ensure ALL return statements in async functions get awaited.
1131+
let rec wrapReturnWithAwait (body: Statement list) : Statement list =
11301132
body
11311133
|> List.map (fun stmt ->
11321134
match stmt with
1135+
| Statement.Return { Value = Some(Await _) } -> stmt // Already awaited
11331136
| Statement.Return { Value = Some value } -> Statement.return' (Await value)
1137+
| Statement.If ifStmt ->
1138+
Statement.if' (
1139+
ifStmt.Test,
1140+
wrapReturnWithAwait ifStmt.Body,
1141+
wrapReturnWithAwait ifStmt.Else,
1142+
?loc = ifStmt.Loc
1143+
)
1144+
| Statement.Match matchStmt ->
1145+
let cases =
1146+
matchStmt.Cases
1147+
|> List.map (fun case ->
1148+
MatchCase.matchCase (case.Pattern, wrapReturnWithAwait case.Body, ?guard = case.Guard)
1149+
)
1150+
1151+
Statement.match' (matchStmt.Subject, cases, ?loc = matchStmt.Loc)
1152+
| Statement.Try tryStmt ->
1153+
let handlers =
1154+
tryStmt.Handlers
1155+
|> List.map (fun h -> { h with Body = wrapReturnWithAwait h.Body })
1156+
1157+
Statement.try' (
1158+
wrapReturnWithAwait tryStmt.Body,
1159+
handlers = handlers,
1160+
orElse = wrapReturnWithAwait tryStmt.OrElse,
1161+
finalBody = wrapReturnWithAwait tryStmt.FinalBody,
1162+
?loc = tryStmt.Loc
1163+
)
1164+
| Statement.For forStmt ->
1165+
Statement.for' (
1166+
forStmt.Target,
1167+
forStmt.Iterator,
1168+
body = wrapReturnWithAwait forStmt.Body,
1169+
orelse = wrapReturnWithAwait forStmt.Else,
1170+
?typeComment = forStmt.TypeComment
1171+
)
1172+
| Statement.AsyncFor asyncForStmt ->
1173+
AsyncFor(
1174+
AsyncFor.asyncFor (
1175+
asyncForStmt.Target,
1176+
asyncForStmt.Iterator,
1177+
wrapReturnWithAwait asyncForStmt.Body,
1178+
orelse = wrapReturnWithAwait asyncForStmt.Else,
1179+
?typeComment = asyncForStmt.TypeComment
1180+
)
1181+
)
1182+
| Statement.While whileStmt ->
1183+
Statement.while' (
1184+
whileStmt.Test,
1185+
wrapReturnWithAwait whileStmt.Body,
1186+
orelse = wrapReturnWithAwait whileStmt.Else,
1187+
?loc = whileStmt.Loc
1188+
)
1189+
| Statement.With withStmt ->
1190+
Statement.with' (
1191+
withStmt.Items,
1192+
body = wrapReturnWithAwait withStmt.Body,
1193+
?typeComment = withStmt.TypeComment
1194+
)
11341195
| other -> other
11351196
)
11361197

tests/Python/TestMap.fs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,21 @@ let ``test Map works with keys with custom comparison`` () =
333333
|> Map.add { Bar = "a"; Foo = 10 } 2
334334
|> Map.count
335335
|> equal 1
336+
337+
// Note: This compiles to GetEnumerator()/MoveNext()/Current, not __iter__
338+
[<Fact>]
339+
let ``test Map iteration with KeyValue yields key-value pairs`` () =
340+
let myMap = Map [ "foo", 1; "bar", 2; "baz", 3 ]
341+
let mutable keys = []
342+
let mutable values = []
343+
for KeyValue(key, value) in myMap do
344+
keys <- key :: keys
345+
values <- value :: values
346+
keys |> List.sort |> equal ["bar"; "baz"; "foo"]
347+
values |> List.sort |> equal [1; 2; 3]
348+
349+
[<Fact>]
350+
let ``test Map keys and values work correctly`` () =
351+
let myMap = Map [ "a", 10; "b", 20 ] :> IDictionary<_,_>
352+
myMap.Keys |> Seq.toList |> equal ["a"; "b"]
353+
myMap.Values |> Seq.toList |> equal [10; 20]

tests/Python/TestNonRegression.fs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,19 @@ let ``test named arguments are converted to snake_case`` () =
284284
let runner2 = NamedArgsSnakeCase.TestRunner(testCase = "test2", configArgs = [| "arg3" |])
285285
equal "test2" runner2.TestCase
286286
equal [| "arg3" |] runner2.ConfigArgs
287+
288+
// Regression: type test pattern (:? T as x) inside closure should not cause
289+
// UnboundLocalError by reassigning the tested variable
290+
[<Fact>]
291+
let ``test type test pattern in closure does not shadow outer variable`` () =
292+
let mutable result = ""
293+
let processValue (value: obj) =
294+
let inner () =
295+
match value with
296+
| :? string as s -> result <- s
297+
| _ -> result <- "not a string"
298+
inner ()
299+
processValue (box "hello")
300+
equal "hello" result
301+
processValue (box 42)
302+
equal "not a string" result

tests/Python/TestTask.fs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,48 @@ let ``test TaskCompletionSource is executed correctly`` () =
154154
x |> fun tsk -> tsk.GetAwaiter().GetResult()
155155

156156
equal 42 result
157+
158+
// Regression: async functions returning Task from if/else branches must await both branches
159+
let skipPipeline () : Task<int option> = Task.FromResult None
160+
161+
let httpVerb (validate: string -> bool) =
162+
fun (value: string) ->
163+
if validate value then
164+
Task.FromResult(Some 42)
165+
else
166+
skipPipeline ()
167+
168+
[<Fact>]
169+
let ``test async pass-through returns are awaited in if branches`` () =
170+
let result =
171+
httpVerb (fun s -> s = "GET") "GET"
172+
|> fun tsk -> tsk.GetAwaiter().GetResult()
173+
equal (Some 42) result
174+
175+
let result2 =
176+
httpVerb (fun s -> s = "GET") "POST"
177+
|> fun tsk -> tsk.GetAwaiter().GetResult()
178+
equal None result2
179+
180+
// Regression: await in nested try/with inside async
181+
[<Fact>]
182+
let ``test async returns in try-with are awaited`` () =
183+
let getResult (fail: bool) : Task<int> =
184+
if fail then
185+
failwith "error"
186+
else
187+
Task.FromResult 99
188+
189+
let wrapper (fail: bool) =
190+
task {
191+
try
192+
return! getResult fail
193+
with
194+
| _ -> return -1
195+
}
196+
197+
let result = wrapper false |> fun tsk -> tsk.GetAwaiter().GetResult()
198+
equal 99 result
199+
200+
let result2 = wrapper true |> fun tsk -> tsk.GetAwaiter().GetResult()
201+
equal -1 result2

0 commit comments

Comments
 (0)