Skip to content

Commit da80512

Browse files
authored
Merge branch 'main' into dbrattli/fable-python-more-tests
2 parents c6b437c + 7883ecc commit da80512

11 files changed

Lines changed: 186 additions & 51 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
* [Python] Fix HashSet operations (Count, Contains, Remove, UnionWith, IntersectWith, ExceptWith) to work with both native Python sets and custom MutableSet (by @dbrattli)
13+
* [Python] Fix `Array.length`, `.Length`, `Array.isEmpty`, and `ResizeArray.Count` to use `len()` instead of `.length` property for plain Python list interop (by @dbrattli)
14+
* [Python] Fix `Task<T>` pass-through returns not being awaited in if/else and try/with branches (by @dbrattli)
15+
* [Python] Fix `:? T as x` type test pattern in closures causing `UnboundLocalError` due to `cast()` shadowing outer variable (by @dbrattli)
1316

1417
## 5.0.0-alpha.23 - 2026-02-03
1518

src/Fable.Compiler/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
* [Python] Fix HashSet operations (Count, Contains, Remove, UnionWith, IntersectWith, ExceptWith) to work with both native Python sets and custom MutableSet (by @dbrattli)
13+
* [Python] Fix `Array.length`, `.Length`, `Array.isEmpty`, and `ResizeArray.Count` to use `len()` instead of `.length` property for plain Python list interop (by @dbrattli)
14+
* [Python] Fix `Task<T>` pass-through returns not being awaited in if/else and try/with branches (by @dbrattli)
15+
* [Python] Fix `:? T as x` type test pattern in closures causing `UnboundLocalError` due to `cast()` shadowing outer variable (by @dbrattli)
1316

1417
## 5.0.0-alpha.22 - 2026-02-03
1518

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

src/Fable.Transforms/Python/Replacements.fs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,12 +1760,9 @@ let resizeArrays (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (this
17601760
| "GetEnumerator", Some ar, _ -> getEnumerator com r t ar |> Some
17611761
| "get_Count", Some(MaybeCasted(ar)), _ ->
17621762
match ar.Type with
1763-
// ResizeArray is Python list - use len() wrapped in int32()
1764-
| Array(_, ResizeArray) ->
1763+
| Array _ ->
17651764
let lenExpr = Helper.GlobalCall("len", Int32.Number, [ ar ], ?loc = r)
17661765
Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some
1767-
// MutableArray/ImmutableArray are FSharpArray (Rust) with .length property returning Int32
1768-
| Array _ -> getFieldWith r t ar "length" |> Some
17691766
| _ -> Helper.LibCall(com, "util", "count", t, [ ar ], ?loc = r) |> Some
17701767
| "Clear", Some ar, _ -> Helper.LibCall(com, "Util", "clear", t, [ ar ], ?loc = r) |> Some
17711768
| "Find", Some ar, [ arg ] ->
@@ -1894,8 +1891,8 @@ let arrays (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg: E
18941891
// printfn "arrays: %A" i.CompiledName
18951892
match i.CompiledName, thisArg, args with
18961893
| "get_Length", Some arg, _ ->
1897-
// All arrays in Python are FSharpArray (Rust) which has .length property returning Int32
1898-
getFieldWith r t arg "length" |> Some
1894+
let lenExpr = Helper.GlobalCall("len", Int32.Number, [ arg ], ?loc = r)
1895+
Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some
18991896
| "get_Item", Some arg, [ idx ] -> getExpr r t arg idx |> Some
19001897
| "set_Item", Some arg, [ idx; value ] -> setExpr r arg idx value |> Some
19011898
| "Copy", None, [ _source; _sourceIndex; _target; _targetIndex; _count ] -> copyToArray com r t i args
@@ -1946,13 +1943,8 @@ let arrayModule (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (_: Ex
19461943
Helper.LibCall(com, "list", "of_array", t, args, i.SignatureArgTypes, ?loc = r)
19471944
|> Some
19481945
| ("Length" | "Count"), [ arg ] ->
1949-
match arg.Type with
1950-
// ResizeArray is Python list - use len() wrapped in int32()
1951-
| Array(_, ResizeArray) ->
1952-
let lenExpr = Helper.GlobalCall("len", Int32.Number, [ arg ], ?loc = r)
1953-
Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some
1954-
// MutableArray/ImmutableArray are FSharpArray (Rust) with .length property returning Int32
1955-
| _ -> getFieldWith r t arg "length" |> Some
1946+
let lenExpr = Helper.GlobalCall("len", Int32.Number, [ arg ], ?loc = r)
1947+
Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some
19561948
| "Item", [ idx; ar ] -> getExpr r t ar idx |> Some
19571949
| "Get", [ ar; idx ] -> getExpr r t ar idx |> Some
19581950
| "Set", [ ar; idx; value ] -> setExpr r ar idx value |> Some
@@ -1969,8 +1961,8 @@ let arrayModule (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (_: Ex
19691961
// Use library function to create empty FSharpArray (Rust) instead of raw Python list
19701962
Helper.LibCall(com, "array", "empty", t, [], ?loc = r) |> Some
19711963
| "IsEmpty", [ ar ] ->
1972-
// Use .length property (Int32) instead of len() which returns Python int
1973-
eq (getFieldWith r Int32.Number ar "length") (makeIntConst 0) |> Some
1964+
eq (Helper.GlobalCall("len", Int32.Number, [ ar ], ?loc = r)) (makeIntConst 0)
1965+
|> Some
19741966
| "Concat", [ ar1; ar2 ] -> makeBinOp r t ar1 ar2 BinaryPlus |> Some
19751967
| Patterns.DicContains nativeArrayFunctions meth, _ ->
19761968
let args, thisArg = List.splitLast args

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/TestPyInterop.fs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,4 +902,24 @@ let ``test Pydantic model_dump_json with float array`` () =
902902
json.Contains("1.5") |> equal true
903903
json.Contains("2.5") |> equal true
904904

905+
// Regression tests: Array.length/.Length/Array.isEmpty must use len() so they
906+
// work on plain Python lists (e.g. from Emit, unbox, native APIs), not just FSharpArray.
907+
908+
[<Fact>]
909+
let ``test Array.length works on plain Python list`` () =
910+
let xs: int[] = emitPyExpr () "[1, 2, 3]"
911+
Array.length xs |> equal 3
912+
913+
[<Fact>]
914+
let ``test .Length works on plain Python list`` () =
915+
let xs: int[] = emitPyExpr () "[10, 20]"
916+
xs.Length |> equal 2
917+
918+
[<Fact>]
919+
let ``test Array.isEmpty works on plain Python list`` () =
920+
let xs: int[] = emitPyExpr () "[1]"
921+
Array.isEmpty xs |> equal false
922+
let ys: int[] = emitPyExpr () "[]"
923+
Array.isEmpty ys |> equal true
924+
905925
#endif

0 commit comments

Comments
 (0)