Skip to content

Commit 132a224

Browse files
authored
[Python] Type annotation and Option handling improvement (#4328)
1 parent d9b5386 commit 132a224

12 files changed

Lines changed: 225 additions & 57 deletions

File tree

pyrightconfig.ci.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"**/node_modules/**",
66
"temp/fable-library-py/fable_library/list.py",
77
"temp/tests/Python/test_applicative.py",
8-
"temp/tests/Python/test_map.py",
98
"temp/tests/Python/test_misc.py",
109
"temp/tests/Python/test_type.py",
1110
"temp/tests/Python/fable_modules/thoth_json_python/encode.py"

src/Fable.Build/Test/Python.fs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ let handle (args: string list) =
6767
else
6868

6969
// Test against .NET
70-
Command.Run("dotnet", "test -c Release", workingDirectory = sourceDir)
70+
if compileOnly then
71+
printfn "Skipping .NET test execution (--compile-only specified)"
72+
else
73+
Command.Run("dotnet", "test -c Release", workingDirectory = sourceDir)
7174

7275
// Test against Python
7376
Command.Fable(fableArgs, workingDirectory = buildDir)
@@ -80,7 +83,7 @@ let handle (args: string list) =
8083

8184
// Run pytest
8285
if compileOnly then
83-
printfn "Skipping test execution (--compile-only specified)"
86+
printfn "Skipping Python test execution (--compile-only specified)"
8487
else
8588
Command.Run("uv", $"run pytest {buildDir} -x")
8689

src/Fable.Transforms/FableTransforms.fs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,17 @@ let noSideEffectBeforeIdent identName expr =
228228

229229
findIdentOrSideEffect expr && not sideEffect
230230

231-
let canInlineArg com identName value body =
231+
let canInlineArg (com: Compiler) identName value body =
232232
match value with
233233
| Value((Null _ | UnitConstant | TypeInfo _ | BoolConstant _ | NumberConstant _ | CharConstant _), _) -> true
234-
| Value(StringConstant s, _) -> s.Length < 100
234+
| Value(StringConstant s, _) ->
235+
match com.Options.Language with
236+
| Python ->
237+
// Only inline short strings if they're referenced at most once,
238+
// to avoid duplicating the literal in generated code (which can cause
239+
// issues like property access on string literals in Python)
240+
s.Length < 100 && countReferencesUntil 2 identName body <= 1
241+
| _ -> s.Length < 100
235242
| _ ->
236243
let refCount = countReferencesUntil 2 identName body
237244

src/Fable.Transforms/FableTransforms.fsi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ open Fable.AST.Fable
55

66
val isIdentCaptured: identName: string -> expr: Expr -> bool
77
val isTailRecursive: identName: string -> expr: Expr -> bool * bool
8+
val countReferencesUntil: limit: int -> identName: string -> body: Expr -> int
89
val replaceValues: replacements: Map<string, Expr> -> expr: Expr -> Expr
910
val uncurryType: typ: Type -> Type
1011

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ let makeEntityTypeAnnotation com ctx (entRef: Fable.EntityRef) genArgs repeatedG
543543
| Types.iobservableGeneric, _ ->
544544
let resolved, stmts = resolveGenerics com ctx genArgs repeatedGenerics
545545
fableModuleAnnotation com ctx "observable" "IObservable" resolved, stmts
546-
| Types.idictionary, _ -> stdlibModuleTypeHint com ctx "collections.abc" "MutableMapping" genArgs repeatedGenerics
546+
| Types.idictionary, _ -> stdlibModuleTypeHint com ctx "collections.abc" "Mapping" genArgs repeatedGenerics
547547
| Types.ievent2, _ ->
548548
// IEvent<'Delegate, 'Args> - only use Args (second param) since Delegate is phantom in Python
549549
let argsType = genArgs |> List.tryItem 1 |> Option.defaultValue Fable.Any
@@ -559,7 +559,7 @@ let makeEntityTypeAnnotation com ctx (entRef: Fable.EntityRef) genArgs repeatedG
559559
| "Fable.Core.Py.Set`1", _ ->
560560
let resolved, stmts = resolveGenerics com ctx genArgs repeatedGenerics
561561
fableModuleAnnotation com ctx "protocols" "ISet_1" resolved, stmts
562-
| "Fable.Core.Py.Map`2", _ ->
562+
| "Py.Mapping.IMapping`2", _ ->
563563
let resolved, stmts = resolveGenerics com ctx genArgs repeatedGenerics
564564
fableModuleAnnotation com ctx "protocols" "IMap" resolved, stmts
565565
| "Fable.Core.Py.Callable", _ ->
@@ -581,7 +581,16 @@ let makeEntityTypeAnnotation com ctx (entRef: Fable.EntityRef) genArgs repeatedG
581581
let isErased =
582582
ent.Attributes |> Seq.exists (fun att -> att.Entity.FullName = Atts.erase)
583583

584-
if ent.IsInterface && not isErased then
584+
// Check for [<Global>] attribute - use the global name directly as the type annotation
585+
match com, ent.Attributes with
586+
| FSharp2Fable.Util.GlobalAtt(Some customName) ->
587+
// Use the custom global name (e.g., "list" for [<Global("list")>])
588+
makeGenericTypeAnnotation com ctx customName genArgs repeatedGenerics, []
589+
| FSharp2Fable.Util.GlobalAtt None ->
590+
// Use the entity's display name
591+
let name = Helpers.removeNamespace ent.FullName
592+
makeGenericTypeAnnotation com ctx name genArgs repeatedGenerics, []
593+
| _ when ent.IsInterface && not isErased ->
585594
let name = Helpers.removeNamespace ent.FullName
586595

587596
// If the interface is imported then it's erased and we need to add the actual imports
@@ -597,10 +606,10 @@ let makeEntityTypeAnnotation com ctx (entRef: Fable.EntityRef) genArgs repeatedG
597606
| _ -> ()
598607

599608
makeGenericTypeAnnotation com ctx name genArgs repeatedGenerics, []
600-
elif isErased then
609+
| _ when isErased ->
601610
// Erased types should use Any for type annotations
602611
stdlibModuleTypeHint com ctx "typing" "Any" [] repeatedGenerics
603-
else
612+
| _ ->
604613
match tryPyConstructor com ctx ent with
605614
| Some(entRef, stmts) ->
606615
match entRef with

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

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,7 @@ let getMemberArgsAndBody (com: IPythonCompiler) ctx kind hasSpread (args: Fable.
6969
let args, body, returnType, _typeParams =
7070
Annotation.transformFunctionWithAnnotations com ctx funcName args body
7171

72-
let args =
73-
let len = args.Args.Length
74-
75-
if not hasSpread || len = 0 then
76-
args
77-
else
78-
{ args with
79-
VarArg = Some { args.Args[len - 1] with Annotation = None }
80-
Args = args.Args[.. len - 2]
81-
}
72+
let args = adjustArgsForSpread hasSpread args
8273

8374
args, body, returnType
8475

@@ -740,6 +731,16 @@ let transformCallArgs
740731
let hasSpread =
741732
paramsInfo |> Option.map (fun i -> i.HasSpread) |> Option.defaultValue false
742733

734+
// Helper to transform an arg and wrap with widen() if needed
735+
let transformArgWithWiden (sigType: Fable.Type option) (argExpr: Fable.Expr) =
736+
let expr, stmts = com.TransformAsExpr(ctx, argExpr)
737+
738+
if needsOptionWidenForArg sigType argExpr then
739+
let widen = com.TransformImport(ctx, "widen", getLibPath com "option")
740+
Expression.call (widen, [ expr ]), stmts
741+
else
742+
expr, stmts
743+
743744
let args, stmts' =
744745
match args with
745746
| [] -> [], []
@@ -759,7 +760,14 @@ let transformCallArgs
759760

760761
let expr, stmts' = com.TransformAsExpr(ctx, last)
761762
rest @ [ Expression.starred expr ], stmts @ stmts'
762-
| args -> List.map (fun e -> com.TransformAsExpr(ctx, e)) args |> Helpers.unzipArgs
763+
| args ->
764+
// Transform args with widen() where needed based on signature types
765+
args
766+
|> List.mapi (fun i e ->
767+
let sigType = List.tryItem i callInfo.SignatureArgTypes
768+
transformArgWithWiden sigType e
769+
)
770+
|> Helpers.unzipArgs
763771

764772
match objArg with
765773
| None -> args, [], stmts @ stmts'
@@ -1515,7 +1523,12 @@ let getDecisionTargetAndBoundValues (com: IPythonCompiler) (ctx: Context) target
15151523
let bindings, replacements =
15161524
(([], Map.empty), identsAndValues)
15171525
||> List.fold (fun (bindings, replacements) (ident, expr) ->
1518-
if canHaveSideEffects com expr then
1526+
// Only inline if the expression has no side effects AND is referenced at most once.
1527+
// If referenced multiple times, we should bind to a variable to avoid duplicating
1528+
// the expression (which can cause issues like accessing properties on literals).
1529+
let refCount = FableTransforms.countReferencesUntil 2 ident.Name target
1530+
1531+
if canHaveSideEffects com expr || refCount > 1 then
15191532
(ident, expr) :: bindings, replacements
15201533
else
15211534
bindings, Map.add ident.Name expr replacements
@@ -4129,23 +4142,42 @@ let transformInterface (com: IPythonCompiler) ctx (classEnt: Fable.Entity) (_cla
41294142
// Make protocol method parameters positional-only (using /) to avoid
41304143
// parameter name mismatch errors when subclasses use different names
41314144
// (e.g., value_1 instead of value due to closure captures)
4145+
let allParams =
4146+
memb.CurriedParameterGroups
4147+
|> Seq.indexed
4148+
|> Seq.collect (fun (n, parameterGroup) ->
4149+
parameterGroup |> Seq.indexed |> Seq.map (fun (m, pg) -> (n + m, pg))
4150+
)
4151+
|> Seq.toList
4152+
4153+
// Split regular params from vararg param using shared helper
4154+
let regularParams, varArgParam = splitVarArg memb.HasSpread allParams
4155+
41324156
let posOnlyArgs =
41334157
[
41344158
if memb.IsInstance then
41354159
Arg.arg "self"
41364160

4137-
for n, parameterGroup in memb.CurriedParameterGroups |> Seq.indexed do
4138-
for m, pg in parameterGroup |> Seq.indexed do
4139-
// Uncurry function types to match class implementations
4140-
// F# interface uses curried types (LambdaType) but class methods
4141-
// use uncurried types (DelegateType) at runtime
4142-
let paramType = FableTransforms.uncurryType pg.Type
4143-
let ta, _ = Annotation.typeAnnotation com ctx None paramType
4161+
for idx, pg in regularParams do
4162+
// Uncurry function types to match class implementations
4163+
// F# interface uses curried types (LambdaType) but class methods
4164+
// use uncurried types (DelegateType) at runtime
4165+
let paramType = FableTransforms.uncurryType pg.Type
4166+
let ta, _ = Annotation.typeAnnotation com ctx None paramType
41444167

4145-
Arg.arg (pg.Name |> Option.defaultValue $"__arg%d{n + m}", annotation = ta)
4168+
Arg.arg (pg.Name |> Option.defaultValue $"__arg%d{idx}", annotation = ta)
41464169
]
41474170

4148-
Arguments.arguments (posonlyargs = posOnlyArgs)
4171+
// For vararg parameter, extract element type from array type for annotation
4172+
let vararg =
4173+
varArgParam
4174+
|> Option.map (fun (_idx, pg) ->
4175+
let elementType = getVarArgElementType pg.Type
4176+
let ta, _ = Annotation.typeAnnotation com ctx None elementType
4177+
Arg.arg (pg.Name |> Option.defaultValue "rest", annotation = ta)
4178+
)
4179+
4180+
Arguments.arguments (posonlyargs = posOnlyArgs, ?vararg = vararg)
41494181

41504182
// Also uncurry return type for consistency with class implementations
41514183
let uncurriedReturnType = FableTransforms.uncurryType memb.ReturnParameter.Type

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,36 @@ module Util =
3939
| [] -> [ Statement.Pass ]
4040
| _ -> stmts
4141

42+
/// Extract the element type from an array type for vararg annotations.
43+
/// For Array<T>, returns T. For other types, returns Any as fallback.
44+
let getVarArgElementType (paramType: Fable.Type) : Fable.Type =
45+
match paramType with
46+
| Fable.Array(elemType, _) -> elemType
47+
| _ -> Fable.Any // Fallback if not array type
48+
49+
/// Splits a list of items into regular items and an optional vararg item.
50+
/// When hasSpread is true and items is non-empty, the last item becomes the vararg.
51+
let splitVarArg<'T> (hasSpread: bool) (items: 'T list) : 'T list * 'T option =
52+
if hasSpread && not items.IsEmpty then
53+
let regular = items |> List.take (items.Length - 1)
54+
let vararg = items |> List.last
55+
regular, Some vararg
56+
else
57+
items, None
58+
59+
/// Adjusts Arguments to move the last arg to vararg when hasSpread is true.
60+
/// Removes the annotation from vararg since Python infers it from *args.
61+
let adjustArgsForSpread (hasSpread: bool) (args: Arguments) : Arguments =
62+
let len = args.Args.Length
63+
64+
if not hasSpread || len = 0 then
65+
args
66+
else
67+
{ args with
68+
VarArg = Some { args.Args[len - 1] with Annotation = None }
69+
Args = args.Args[.. len - 2]
70+
}
71+
4272
let hasAttribute fullName (atts: Fable.Attribute seq) =
4373
atts |> Seq.exists (fun att -> att.Entity.FullName = fullName)
4474

@@ -79,6 +109,55 @@ module Util =
79109
| Fable.Call _ -> needsOptionEraseForCall expectedReturnType
80110
| _ -> false
81111

112+
/// Recursively check if a type contains Option with generic parameter that requires wrapping.
113+
/// This checks return types of lambdas for Option[GenericParam].
114+
let rec private hasWrappedOptionInReturnType (typ: Fable.Type) =
115+
match typ with
116+
| Fable.LambdaType(_, returnType) ->
117+
// Check if return type is Option[GenericParam] or recurse into nested lambdas
118+
match returnType with
119+
| Fable.Option(inner, _) -> mustWrapOption inner
120+
| Fable.LambdaType _ -> hasWrappedOptionInReturnType returnType
121+
| _ -> false
122+
| _ -> false
123+
124+
/// Check if a type would have its Option return type erased (T | None instead of Option[T]).
125+
/// Returns true when the return type is Option[ConcreteType] which gets erased.
126+
let rec private hasErasedOptionReturnType (typ: Fable.Type) =
127+
match typ with
128+
| Fable.LambdaType(_, returnType) ->
129+
match returnType with
130+
| Fable.Option(inner, _) -> not (mustWrapOption inner) // Erased when NOT wrapped
131+
| Fable.LambdaType _ -> hasErasedOptionReturnType returnType
132+
| _ -> false
133+
| _ -> false
134+
135+
/// Check if a callback argument needs widen() to convert erased Option callback
136+
/// to wrapped Option form for type checker compatibility.
137+
/// Returns true when:
138+
/// - Expected type is Callable with Option[GenericParam] in return position
139+
/// - Actual arg is a lambda/function that would have erased Option return type
140+
let needsOptionWidenForArg (expectedType: Fable.Type option) (argExpr: Fable.Expr) =
141+
match expectedType with
142+
| Some sigType when hasWrappedOptionInReturnType sigType ->
143+
// Check if argument is a callable with ERASED Option return type
144+
// If arg already has wrapped Option return, don't apply widen()
145+
match argExpr with
146+
| Fable.Lambda _
147+
| Fable.Delegate _ ->
148+
// Check if the lambda's return type would be erased
149+
hasErasedOptionReturnType argExpr.Type
150+
| Fable.IdentExpr ident ->
151+
// Check if the identifier's type has erased Option return
152+
hasErasedOptionReturnType ident.Type
153+
| Fable.Get(_, Fable.FieldGet fieldInfo, _, _) ->
154+
// Field access to a function
155+
match fieldInfo.FieldType with
156+
| Some typ -> hasErasedOptionReturnType typ
157+
| None -> false
158+
| _ -> false
159+
| _ -> false
160+
82161
/// Wraps None values in cast(type, None) for type safety.
83162
/// Skips if type annotation is also None (unit type).
84163
let wrapNoneInCast (com: IPythonCompiler) ctx (value: Expression) (typeAnnotation: Expression) : Expression =

src/Fable.Transforms/Python/PythonPrinter.fs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,13 @@ module PrinterExtensions =
143143
if i < args.Length - 1 then
144144
printer.Print(", ")
145145

146-
match arguments.Args, arguments.VarArg with
147-
| [], Some vararg ->
146+
match arguments.PosOnlyArgs, arguments.Args, arguments.VarArg with
147+
| [], [], Some vararg ->
148+
// No positional args at all, just print *vararg
148149
printer.Print("*")
149150
printer.Print(vararg)
150-
| _, Some vararg ->
151+
| _, _, Some vararg ->
152+
// Has positional-only or regular args, need comma before *vararg
151153
printer.Print(", *")
152154
printer.Print(vararg)
153155
| _ -> ()

src/Fable.Transforms/Python/Replacements.fs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2590,12 +2590,21 @@ let dictionaries (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Exp
25902590
| "get_Item", _ ->
25912591
Helper.LibCall(com, "map_util", "getItemFromDict", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r)
25922592
|> Some
2593-
| ReplaceName [ "set_Item", "set"
2594-
"get_Keys", "keys"
2595-
"get_Values", "values"
2596-
"ContainsKey", "has"
2597-
"Clear", "clear" ] methName,
2598-
Some c -> Helper.InstanceCall(c, methName, t, args, i.SignatureArgTypes, ?loc = r) |> Some
2593+
| "get_Keys", Some c ->
2594+
// Wrap .keys() with to_enumerable since KeysView doesn't implement IEnumerable_1
2595+
let keysCall =
2596+
Helper.InstanceCall(c, "keys", t, args, i.SignatureArgTypes, ?loc = r)
2597+
2598+
Helper.LibCall(com, "util", "to_enumerable", t, [ keysCall ], ?loc = r) |> Some
2599+
| "get_Values", Some c ->
2600+
// Wrap .values() with to_enumerable since ValuesView doesn't implement IEnumerable_1
2601+
let valuesCall =
2602+
Helper.InstanceCall(c, "values", t, args, i.SignatureArgTypes, ?loc = r)
2603+
2604+
Helper.LibCall(com, "util", "to_enumerable", t, [ valuesCall ], ?loc = r)
2605+
|> Some
2606+
| ReplaceName [ "set_Item", "set"; "ContainsKey", "has"; "Clear", "clear" ] methName, Some c ->
2607+
Helper.InstanceCall(c, methName, t, args, i.SignatureArgTypes, ?loc = r) |> Some
25992608
| _ -> None
26002609

26012610
let hashSets (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) =

0 commit comments

Comments
 (0)