From 99997537a1e15fd3288449e1def42583038b054f Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 8 Jan 2026 00:54:58 +0100 Subject: [PATCH 1/3] [Python] Type annotation fixes --- pyrightconfig.ci.json | 2 - .../Python/Fable2Python.Annotation.fs | 11 +- .../Python/Fable2Python.Bases.fs | 40 +++++ .../Python/Fable2Python.Transforms.fs | 140 +++++++++++------- .../Python/Fable2Python.Util.fs | 56 +++++++ 5 files changed, 193 insertions(+), 56 deletions(-) diff --git a/pyrightconfig.ci.json b/pyrightconfig.ci.json index 7ac204ff4..e612c0828 100644 --- a/pyrightconfig.ci.json +++ b/pyrightconfig.ci.json @@ -3,10 +3,8 @@ "exclude": [ "**/.venv/**", "**/node_modules/**", - "temp/fable-library-py/fable_library/list.py", "temp/tests/Python/test_applicative.py", "temp/tests/Python/test_misc.py", - "temp/tests/Python/test_type.py", "temp/tests/Python/fable_modules/thoth_json_python/encode.py" ] } diff --git a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs index 80029675d..49d2af915 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs @@ -472,6 +472,8 @@ let makeImportTypeAnnotation com ctx genArgs moduleName typeName = let makeEntityTypeAnnotation com ctx (entRef: Fable.EntityRef) genArgs repeatedGenerics = // printfn "DeclaredType: %A" entRef.FullName match entRef.FullName, genArgs with + // Python's BaseException - used for catch-all exception handlers + | "BaseException", _ -> Expression.name "BaseException", [] | Types.result, _ -> let resolved, stmts = resolveGenerics com ctx genArgs repeatedGenerics fableModuleAnnotation com ctx "result" "FSharpResult_2" resolved, stmts @@ -630,8 +632,13 @@ let makeBuiltinTypeAnnotation com ctx typ repeatedGenerics kind = match kind with | Replacements.Util.BclGuid -> stdlibModuleTypeHint com ctx "uuid" "UUID" [] repeatedGenerics | Replacements.Util.FSharpReference genArg -> - let resolved, stmts = resolveGenerics com ctx [ genArg ] repeatedGenerics - fableModuleAnnotation com ctx "core" "FSharpRef" resolved, stmts + // In F#, struct instance method's `this` parameter is represented as inref, + // but in Python the struct is passed directly, not wrapped in FSharpRef. + if isInRefOrAnyType com typ then + typeAnnotation com ctx repeatedGenerics genArg + else + let resolved, stmts = resolveGenerics com ctx [ genArg ] repeatedGenerics + fableModuleAnnotation com ctx "core" "FSharpRef" resolved, stmts (* | Replacements.Util.BclTimeSpan -> NumberTypeAnnotation | Replacements.Util.BclDateTime -> makeSimpleTypeAnnotation com ctx "Date" diff --git a/src/Fable.Transforms/Python/Fable2Python.Bases.fs b/src/Fable.Transforms/Python/Fable2Python.Bases.fs index 4dda0713c..88de2824b 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Bases.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Bases.fs @@ -278,6 +278,46 @@ let generatePythonProtocolDunders (com: IPythonCompiler) ctx (classEnt: Fable.En // a collection is either a mapping or a set, not both mappingDunders @ mutableMappingDunders @ setDunders @ mutableSetDunders +/// Known core interfaces and their method members. +/// These interfaces are injected by the compiler and their entities may not be available. +/// Maps interface full name -> set of method member names. +let knownInterfaceMethods = + Map + [ + "Fable.Core.IGenericAdder`1", set [ "GetZero"; "Add" ] + "Fable.Core.IGenericAverager`1", set [ "GetZero"; "Add"; "DivideByInt" ] + "System.Collections.Generic.IComparer`1", set [ "Compare" ] + "System.Collections.Generic.IEqualityComparer`1", set [ "Equals"; "GetHashCode" ] + ] + +/// All known method names from core interfaces (for untyped object expressions). +let knownInterfaceMethodNames = + knownInterfaceMethods |> Map.values |> Seq.collect id |> Set.ofSeq + +/// Check if the interface member is a method (vs property). +/// Methods have parameters; properties (even returning functions) don't. +/// Used in object expression code generation to determine whether to emit +/// a method or a @property decorator. +let isInterfaceMethod (com: Compiler) (typ: Fable.Type) (memberName: string) : bool = + match typ with + | Fable.DeclaredType(entRef, _) -> + // Check known interfaces first (handles compiler-injected interfaces) + match knownInterfaceMethods.TryFind entRef.FullName with + | Some methods -> methods.Contains memberName + | None -> + // Not a known interface, try entity lookup for user-defined interfaces + match com.TryGetEntity entRef with + | Some ent -> + ent.MembersFunctionsAndValues + |> Seq.tryFind (fun m -> m.DisplayName = memberName || m.CompiledName = memberName) + |> Option.map (fun m -> m.CurriedParameterGroups |> List.exists (not << List.isEmpty)) + |> Option.defaultValue false + | None -> false + | Fable.Any -> + // For untyped object expressions (compiler-injected), check known method names + knownInterfaceMethodNames.Contains memberName + | _ -> false + /// Utilities for interface and abstract class member naming. module InterfaceNaming = /// Computes the overload suffix for an interface/abstract class member based on parameter types. diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 7d5d855c0..59d6c4ef1 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -23,15 +23,48 @@ let wrapInOptionErase (com: IPythonCompiler) ctx (expr: Expression) = libCall com ctx None "option" "erase" [ expr ] +/// Find identifiers used in an expression that have been narrowed in the context +let findNarrowedIdentsUsedInExpr (ctx: Context) (expr: Fable.Expr) : (string * Fable.Type) list = + ctx.NarrowedTypes + |> Map.toList + |> List.filter (fun (name, _) -> isIdentUsed name expr) + /// Immediately Invoked Function Expression +/// When narrowed types are in scope, pass them as arguments to avoid closure capture issues +/// with type narrowing (Pyright can't see that a captured variable has been narrowed) let iife (com: IPythonCompiler) ctx (expr: Fable.Expr) = - let args, body, returnType, typeParams = - Annotation.transformFunctionWithAnnotations com ctx None [] expr + // Find identifiers that are both used in the expression and have narrowed types + let narrowedIdents = findNarrowedIdentsUsedInExpr ctx expr + + match narrowedIdents with + | [] -> + // No narrowed types, use the original approach + let args, body, returnType, typeParams = + Annotation.transformFunctionWithAnnotations com ctx None [] expr + + let afe, stmts = + makeArrowFunctionExpression com ctx None (Some expr.Type) args body returnType typeParams + + Expression.call (afe, []), stmts + | narrowedIdents -> + // Create Fable.Ident arguments for the narrowed identifiers using makeTypedIdent + let fableArgs = + narrowedIdents + |> List.map (fun (name, narrowedType) -> makeTypedIdent narrowedType name) + + // Transform the function with the narrowed arguments + let args, body, returnType, typeParams = + Annotation.transformFunctionWithAnnotations com ctx None fableArgs expr - let afe, stmts = - makeArrowFunctionExpression com ctx None (Some expr.Type) args body returnType typeParams + let afe, stmts = + makeArrowFunctionExpression com ctx None (Some expr.Type) args body returnType typeParams - Expression.call (afe, []), stmts + // Create call arguments from the narrowed identifier names + let callArgs = + narrowedIdents + |> List.map (fun (name, _) -> com.GetIdentifierAsExpr(ctx, Naming.toPythonNaming name)) + + Expression.call (afe, callArgs), stmts let transformImport (com: IPythonCompiler) ctx (_r: SourceLocation option) (name: string) (moduleName: string) = let name, parts = @@ -551,18 +584,6 @@ let transformObjectExpr createFunctionWithTypeParams name args body decorators returnType None typeParams false - /// Transform a callable property (delegate) into a method statement - let transformCallableProperty (memb: Fable.ObjectExprMember) (fableArgs: Fable.Ident list) (fableBody: Fable.Expr) = - // Transform the function directly without treating first arg as 'this' - let args, body, returnType, typeParams = - Annotation.transformFunctionWithAnnotations com ctx None fableArgs fableBody - - let name = com.GetIdentifier(ctx, Naming.toPythonNaming memb.Name) - let self = Arg.arg "self" - let args = { args with Args = self :: args.Args } - - createFunctionWithTypeParams name args body [] returnType None typeParams false - let interfaces, stmts = match typ with | Fable.Any -> [], [] // Don't inherit from Any @@ -588,23 +609,38 @@ let transformObjectExpr let ta, stmts = Annotation.typeAnnotation com ctx None typ [ ta ], stmts + /// Transform an interface method into a method statement (not a property) + let transformInterfaceMethod (memb: Fable.ObjectExprMember) = + let args, body = + match memb.Body with + | Fable.Delegate(args, body, _, _) -> args, body + | Fable.Lambda(arg, body, _) -> [ arg ], body + | _ -> memb.Args, memb.Body + + let args', body', returnType = + getMemberArgsAndBody com ctx (NonAttached memb.Name) false args body + + let name = com.GetIdentifier(ctx, Naming.toPythonNaming memb.Name) + let self = Arg.arg "self" + let args' = { args' with Args = self :: args'.Args } + let argTypes = args |> List.map _.Type + let typeParams = Annotation.calculateMethodTypeParams com ctx argTypes body.Type + + createFunctionWithTypeParams name args' body' [] returnType None typeParams false + let members = members |> List.collect (fun memb -> let info = com.GetMember(memb.MemberRef) if not memb.IsMangled && (info.IsGetter || info.IsValue) then - match memb.Body with - | Fable.Delegate(args, body, _, _) -> - // Transform callable property into method - [ transformCallableProperty memb args body ] - | _ -> - // Regular property + if Bases.isInterfaceMethod com typ memb.Name then + [ transformInterfaceMethod memb ] + else let decorators = [ Expression.name "property" ] [ makeMethod memb.Name false memb.Args memb.Body decorators ] elif not memb.IsMangled && info.IsSetter then let decorators = [ Expression.name $"%s{memb.Name}.setter" ] - [ makeMethod memb.Name false memb.Args memb.Body decorators ] else [ makeMethod memb.Name info.HasSpread memb.Args memb.Body [] ] @@ -1096,8 +1132,11 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: option // No type tests found, use BaseException to catch all exceptions including // KeyboardInterrupt, SystemExit, GeneratorExit which don't inherit from Exception + // We widen exception types to Any because BaseException is broader than Exception + let widenedBody = ExceptionHandling.widenExceptionTypes catchBody + let handler = - makeHandler (Expression.identifier "BaseException") catchBody identifier + makeHandler (Expression.identifier "BaseException") widenedBody identifier Some [ handler ], [] @@ -1115,10 +1154,12 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: option - [ makeHandler (Expression.identifier "BaseException") fallbackExpr identifier ] + let widenedExpr = ExceptionHandling.widenExceptionTypes fallbackExpr + [ makeHandler (Expression.identifier "BaseException") widenedExpr identifier ] | _ -> [] Some(handlers @ fallbackHandlers), List.concat stmts @@ -1178,12 +1219,8 @@ let makeCastStatement (com: IPythonCompiler) ctx (ident: Fable.Ident) (typ: Fabl [] let rec transformIfStatement (com: IPythonCompiler) ctx r ret guardExpr thenStmnt elseStmnt = - // Create refined context for then branch if guard is a type test - let thenCtx = - match guardExpr with - | Fable.Test(Fable.IdentExpr ident, Fable.TypeTest typ, _) -> - { ctx with NarrowedTypes = Map.add ident.Name typ ctx.NarrowedTypes } - | _ -> ctx + // Create refined context for then/else branches based on guard type + let thenCtx, elseCtx = getNarrowedContexts ctx guardExpr let expr, stmts = com.TransformAsExpr(ctx, guardExpr) @@ -1218,7 +1255,7 @@ let rec transformIfStatement (com: IPythonCompiler) ctx r ret guardExpr thenStmn let ifStatement, stmts'' = let block, stmts = - transformBlock com ctx ret elseStmnt + transformBlock com elseCtx ret elseStmnt |> List.partition ( function | Statement.NonLocal _ @@ -2420,22 +2457,25 @@ let rec transformAsExpr (com: IPythonCompiler) ctx (expr: Fable.Expr) : Expressi | Fable.Get(expr, kind, typ, range) -> transformGet com ctx range typ expr kind - | Fable.IfThenElse(Fable.Test(expr, Fable.TypeTest typ, r), thenExpr, TransformExpr com ctx (elseExpr, stmts''), _r) -> + // Handle TypeTest and OptionTest for type narrowing + | Fable.IfThenElse(Fable.Test(expr, testKind, r), thenExpr, elseExpr, _r) when + (match testKind with + | Fable.TypeTest _ + | Fable.OptionTest _ -> true + | _ -> false) + -> + let guardExpr = Fable.Test(expr, testKind, r) + let thenCtx, elseCtx = getNarrowedContexts ctx guardExpr + let finalExpr, stmts = Expression.withStmts { - let! guardExpr = transformTest com ctx r (Fable.TypeTest typ) expr - - // Create refined context for then branch with type assertion - let thenCtx = - match expr with - | Fable.IdentExpr ident -> { ctx with NarrowedTypes = Map.add ident.Name typ ctx.NarrowedTypes } - | _ -> ctx - + let! guardExprCompiled = transformTest com ctx r testKind expr let! thenExprCompiled = com.TransformAsExpr(thenCtx, thenExpr) - return Expression.ifExp (guardExpr, thenExprCompiled, elseExpr) + let! elseExprCompiled = com.TransformAsExpr(elseCtx, elseExpr) + return Expression.ifExp (guardExprCompiled, thenExprCompiled, elseExprCompiled) } - finalExpr, stmts @ stmts'' + finalExpr, stmts | Fable.IfThenElse(TransformExpr com ctx (guardExpr, stmts), TransformExpr com ctx (thenExpr, stmts'), @@ -2760,18 +2800,14 @@ let rec transformAsStatements (com: IPythonCompiler) ctx returnStrategy (expr: F if asStatement then transformIfStatement com ctx r returnStrategy guardExpr thenExpr elseExpr else - // Create refined context for then branch if guard is a type test - let thenCtx = - match guardExpr with - | Fable.Test(Fable.IdentExpr ident, Fable.TypeTest typ, _) -> - { ctx with NarrowedTypes = Map.add ident.Name typ ctx.NarrowedTypes } - | _ -> ctx + // Create refined context for then/else branches based on guard type + let thenCtx, elseCtx = getNarrowedContexts ctx guardExpr let expr, stmts = Expression.withStmts { let! guardExpr' = transformAsExpr com ctx guardExpr - let! thenExpr' = transformAsExpr com thenCtx thenExpr // Use refined context - let! elseExpr' = transformAsExpr com ctx elseExpr + let! thenExpr' = transformAsExpr com thenCtx thenExpr + let! elseExpr' = transformAsExpr com elseCtx elseExpr return Expression.ifExp (guardExpr', thenExpr', elseExpr', ?loc = r) } diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index ccb594519..cd8a00da9 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -109,6 +109,30 @@ module Util = | Fable.Call _ -> needsOptionEraseForCall expectedReturnType | _ -> false + /// Get narrowed contexts for then/else branches based on a guard expression + /// Returns (thenCtx, elseCtx) with appropriate type narrowing applied + let getNarrowedContexts (ctx: Context) (guardExpr: Fable.Expr) : Context * Context = + match guardExpr with + | Fable.Test(Fable.IdentExpr ident, Fable.TypeTest typ, _) -> + // TypeTest: then branch has the narrowed type + { ctx with NarrowedTypes = Map.add ident.Name typ ctx.NarrowedTypes }, ctx + | Fable.Test(Fable.IdentExpr ident, Fable.OptionTest nonEmpty, _) -> + // OptionTest: only narrow for erased options (T | None), not wrapped options (Option[T]) + // Wrapped options require value() call to unwrap, so the type doesn't change + match ident.Type with + | Fable.Option(innerType, _) when not (mustWrapOption innerType) -> + // Erased option: type narrows from T | None to T + if nonEmpty then + // v is not None: then branch has the unwrapped type + { ctx with NarrowedTypes = Map.add ident.Name innerType ctx.NarrowedTypes }, ctx + else + // v is None: else branch has the unwrapped type + ctx, { ctx with NarrowedTypes = Map.add ident.Name innerType ctx.NarrowedTypes } + | _ -> + // Wrapped option or non-option: no type narrowing + ctx, ctx + | _ -> ctx, ctx + /// Recursively check if a type contains Option with generic parameter that requires wrapping. /// This checks return types of lambdas for Option[GenericParam]. let rec private hasWrappedOptionInReturnType (typ: Fable.Type) = @@ -1133,6 +1157,38 @@ module ExceptionHandling = | _ -> [], Some expr + /// Check if a type is System.Exception or a subtype (including exn alias). + /// Used to identify exception types that need to be widened to BaseException for catch-all handlers. + let private isExceptionType (typ: Fable.Type) = + match typ with + | Fable.DeclaredType(entRef, _) -> + entRef.FullName = "System.Exception" + || entRef.FullName.StartsWith("System.") && entRef.FullName.EndsWith("Exception") + | _ -> false + + /// Create a Fable type representing Python's BaseException. + /// This maps to the built-in BaseException class in Python. + let private baseExceptionType = + let entRef: Fable.EntityRef = + { + FullName = "BaseException" + Path = Fable.CoreAssemblyName "builtins" + } + + Fable.DeclaredType(entRef, []) + + /// Rewrite exception-typed bindings in a fallback expression to use BaseException type. + /// This is needed because Python's BaseException is broader than Exception, + /// and type checkers reject `ex: Exception = `. + let rec widenExceptionTypes (expr: Fable.Expr) : Fable.Expr = + match expr with + | Fable.Let(ident, value, body) when isExceptionType ident.Type -> + // Change the ident's type to BaseException for correct Python typing + let widenedIdent = { ident with Type = baseExceptionType } + Fable.Let(widenedIdent, value, widenExceptionTypes body) + | Fable.Let(ident, value, body) -> Fable.Let(ident, value, widenExceptionTypes body) + | _ -> expr + /// Utilities for Python match statement generation (PEP 634). /// These helpers transform F# decision trees into Python 3.10+ match/case statements. module MatchStatements = From faf74a2ba1a16914bca2e62e74b80d4a2d80e48f Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 8 Jan 2026 01:06:55 +0100 Subject: [PATCH 2/3] Fix code scanning warning --- src/Fable.Transforms/Python/Fable2Python.Util.fs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index cd8a00da9..ac0e0e05f 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -1163,7 +1163,8 @@ module ExceptionHandling = match typ with | Fable.DeclaredType(entRef, _) -> entRef.FullName = "System.Exception" - || entRef.FullName.StartsWith("System.") && entRef.FullName.EndsWith("Exception") + || entRef.FullName.StartsWith("System.", StringComparison.Ordinal) + && entRef.FullName.EndsWith("Exception", StringComparison.Ordinal) | _ -> false /// Create a Fable type representing Python's BaseException. From c949b04c86394bfff6ba2948671a97a9d75f1b26 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 8 Jan 2026 22:53:45 +0100 Subject: [PATCH 3/3] Fix annotation for option inside invariant containers --- .../Python/Fable2Python.Annotation.fs | 21 ++ .../Python/Fable2Python.Transforms.fs | 72 +++-- .../Python/Fable2Python.Util.fs | 47 ++++ .../Python/TYPE-ANNOTATIONS.md | 265 ++++++++++++++++++ 4 files changed, 384 insertions(+), 21 deletions(-) create mode 100644 src/Fable.Transforms/Python/TYPE-ANNOTATIONS.md diff --git a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs index 49d2af915..04b0b8242 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs @@ -71,6 +71,27 @@ let getGenericArgs (typ: Fable.Type) : Fable.Type list = let containsGenericParams (t: Fable.Type) = FSharp2Fable.Util.getGenParamNames [ t ] |> List.isEmpty |> not +/// Check if a type contains Option nested inside a container (Array, List, Tuple). +/// When Options are inside invariant containers, we must use Option[T] form consistently +/// to match function signatures that use generic type parameters. +let rec hasOptionInContainer (t: Fable.Type) : bool = + match t with + | Fable.Array(elementType, _) -> containsOptionType elementType + | Fable.List elementType -> containsOptionType elementType + | Fable.Tuple(genArgs, _) -> genArgs |> List.exists containsOptionType + | Fable.DeclaredType(_, genArgs) -> genArgs |> List.exists containsOptionType + | _ -> false + +/// Check if a type is or contains an Option type +and containsOptionType (t: Fable.Type) : bool = + match t with + | Fable.Option _ -> true + | Fable.Array(elementType, _) -> containsOptionType elementType + | Fable.List elementType -> containsOptionType elementType + | Fable.Tuple(genArgs, _) -> genArgs |> List.exists containsOptionType + | Fable.DeclaredType(_, genArgs) -> genArgs |> List.exists containsOptionType + | _ -> false + /// Check if a type is a callable type (Lambda or Delegate) let isCallableType (t: Fable.Type) = match t with diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 59d6c4ef1..e682d7129 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -1404,31 +1404,47 @@ let transformBindingAsExpr (com: IPythonCompiler) ctx (var: Fable.Ident) (value: let transformBindingAsStatements (com: IPythonCompiler) ctx (var: Fable.Ident) (value: Fable.Expr) = let shouldTreatAsStatement = isPyStatement ctx false value let needsErase = needsOptionEraseForBinding value var.Type + // Skip type annotation to avoid Option[T] vs T | None mismatch issues with Pyright: + // 1. When extracting from invariant containers (Array, List) with Options + // 2. When assigning from a wrapped option after None check (narrowing issue) + let skipAnnotation = + valueExtractsFromInvariantContainer value var.Type + || isWrappedOptionNarrowingAssignment value if shouldTreatAsStatement then let varName, varExpr = Expression.name var.Name, identAsExpr com ctx var ctx.BoundVars.Bind(var.Name) - let ta, stmts = Annotation.typeAnnotation com ctx None var.Type - let decl = Statement.assign (varName, ta) - let body = com.TransformAsStatements(ctx, Some(Assign varExpr), value) - - stmts @ [ decl ] @ body + if skipAnnotation then + // No type annotation - let Python infer from function return type + let body = com.TransformAsStatements(ctx, Some(Assign varExpr), value) + body + else + let ta, stmts = Annotation.typeAnnotation com ctx None var.Type + let decl = Statement.assign (varName, ta) + let body = com.TransformAsStatements(ctx, Some(Assign varExpr), value) + stmts @ [ decl ] @ body else let expr, stmts = transformBindingExprBody com ctx var value let varName = com.GetIdentifierAsExpr(ctx, Naming.toPythonNaming var.Name) - let ta, stmts' = Annotation.typeAnnotation com ctx None var.Type - // Erase Option wrapper if needed (zero runtime overhead, just for type checker) - let expr' = - if needsErase then - wrapInOptionErase com ctx expr - else - expr - let value' = wrapNoneInCast com ctx expr' ta - let decl = varDeclaration ctx varName (Some ta) value' - stmts @ stmts' @ decl + if skipAnnotation then + // No type annotation - let Python infer from function return type + let decl = varDeclaration ctx varName None expr + stmts @ decl + else + let ta, stmts' = Annotation.typeAnnotation com ctx None var.Type + // Erase Option wrapper if needed (zero runtime overhead, just for type checker) + let expr' = + if needsErase then + wrapInOptionErase com ctx expr + else + expr + + let value' = wrapNoneInCast com ctx expr' ta + let decl = varDeclaration ctx varName (Some ta) value' + stmts @ stmts' @ decl let transformTest (com: IPythonCompiler) ctx range kind expr : Expression * Statement list = match kind with @@ -3126,15 +3142,29 @@ let declareDataClassType = let name = com.GetIdentifier(ctx, entName) + // Generate field annotations from entity's FSharpFields to properly uncurry function types. + // Record/dataclass fields store functions uncurried at runtime, so we need to uncurry + // lambda types in the type annotations to match. let props = - consArgs.Args - |> List.map (fun arg -> - let any _ = - stdlibModuleAnnotation com ctx "typing" "Any" [] + ent.FSharpFields + |> List.mapi (fun i field -> + // Get the argument name from consArgs (preserves the Python naming convention) + let argName = + if i < consArgs.Args.Length then + consArgs.Args.[i].Arg + else + // Fallback to field name if consArgs doesn't have enough args + com.GetIdentifier(ctx, field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean) + + // Uncurry lambda types for field annotations since fields store uncurried functions + let fieldType = + match field.FieldType with + | Fable.LambdaType _ -> FableTransforms.uncurryType field.FieldType + | _ -> field.FieldType - let annotation = arg.Annotation |> Option.defaultWith any + let annotation, _ = Annotation.typeAnnotation com ctx None fieldType - Statement.assign (Expression.name arg.Arg, annotation = annotation) + Statement.assign (Expression.name argName, annotation = annotation) ) diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index ac0e0e05f..8b16d8bb6 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -109,6 +109,53 @@ module Util = | Fable.Call _ -> needsOptionEraseForCall expectedReturnType | _ -> false + /// Recursively check if a type contains Option (wrapped or erased). + let rec private containsOption (typ: Fable.Type) = + match typ with + | Fable.Option _ -> true + | Fable.Tuple(types, _) -> types |> List.exists containsOption + | Fable.Array(elemType, _) -> containsOption elemType + | Fable.List elemType -> containsOption elemType + | _ -> false + + /// Check if a type is an invariant container (Array or List) with Options inside. + /// This detects cases where function return types use Option[T] for generics + /// but variable annotations would use T | None for concrete types, causing + /// invariance mismatch errors in Pyright. + let private isInvariantContainerWithOptions (typ: Fable.Type) = + match typ with + | Fable.Array(elemType, _) -> containsOption elemType + | Fable.List elemType -> containsOption elemType + | _ -> false + + /// Check if we should skip type annotation to avoid Option[T] vs T | None mismatch. + /// When a Call returns an invariant container (Array/List) with Options inside, + /// the function signature uses Option[T] for generics, but the variable annotation + /// would use erased T | None. Since Array/List are invariant, this causes type errors. + /// Skip the annotation and let Python infer from function return type. + let valueExtractsFromInvariantContainer (value: Fable.Expr) (varType: Fable.Type) = + match value with + | Fable.Call _ -> + // When a call returns an invariant container with Options, skip annotation + isInvariantContainerWithOptions varType + | Fable.Get(_, Fable.ListHead, _, _) -> containsOption varType + | Fable.Get(_, Fable.ListTail, _, _) -> containsOption varType + | Fable.Get(expr, Fable.ExprGet _, _, _) -> isInvariantContainerWithOptions expr.Type && containsOption varType + | _ -> false + + /// Check if a binding is assigning from a wrapped option after None check. + /// Pattern: `if x is not None: x2 = x` where x has wrapped Option type. + /// After narrowing, x is SomeWrapper[T] | T, but annotation expects T. + /// Skip annotation to avoid type mismatch. + let isWrappedOptionNarrowingAssignment (value: Fable.Expr) = + match value with + | Fable.IdentExpr ident -> + // Check if the source has a wrapped option type + match ident.Type with + | Fable.Option(innerType, _) -> mustWrapOption innerType + | _ -> false + | _ -> false + /// Get narrowed contexts for then/else branches based on a guard expression /// Returns (thenCtx, elseCtx) with appropriate type narrowing applied let getNarrowedContexts (ctx: Context) (guardExpr: Fable.Expr) : Context * Context = diff --git a/src/Fable.Transforms/Python/TYPE-ANNOTATIONS.md b/src/Fable.Transforms/Python/TYPE-ANNOTATIONS.md new file mode 100644 index 000000000..7b5cb106b --- /dev/null +++ b/src/Fable.Transforms/Python/TYPE-ANNOTATIONS.md @@ -0,0 +1,265 @@ +# Python Type Annotations in Fable + +This document explains how Fable generates type-safe Python code, the challenges involved, and the solutions implemented. + +## Overview + +Fable compiles F# to Python with full type annotations for Pyright/Pylance compatibility. This is challenging because: + +1. **F# uses curried functions** (`int -> int -> int`) while Python uses multi-argument callables (`Callable[[int, int], int]`) +2. **F# `Option` has different representations** depending on nesting and whether `T` is concrete or generic +3. **F# interfaces compile to Python Protocols** with different method signatures +4. **Python ABC classes** require specific dunder methods for collection interop + +## Key Files + +| File | Purpose | +| -------------------------------------------------------- | -------------------------------------- | +| [Fable2Python.Annotation.fs](Fable2Python.Annotation.fs) | Type annotation generation | +| [Fable2Python.Transforms.fs](Fable2Python.Transforms.fs) | AST transformations | +| [Fable2Python.Util.fs](Fable2Python.Util.fs) | Helper functions and pattern detection | +| `fable_library/option.py` | `erase()` and `widen()` functions | +| `fable_library/curry.py` | Curry/uncurry runtime functions | + +## Numeric Type Mappings + +F# numeric types map to Python types with important distinctions: + +| F# Type | Python Type | Notes | +| ---------- | ----------- | ---------------------------------------------------------------- | +| `int` | `int32` | F#'s default integer is 32-bit, not Python's arbitrary-precision | +| `int32` | `int32` | Explicit 32-bit integer | +| `int64` | `int64` | 64-bit integer | +| `nativeint`| `int` | Maps to Python's native arbitrary-precision integer | +| `float` | `float64` | F#'s default float is 64-bit | +| `float32` | `float32` | 32-bit float | +| `decimal` | `Decimal` | Python's `decimal.Decimal` | + +**Key implications:** + +1. **`int` vs `int32`**: Python's built-in `int` has arbitrary precision, but F#'s `int` is 32-bit. Fable uses `int32` (a wrapper class) to preserve F# semantics. This affects `len()` which returns Python `int`, but F# code expects `int32`. + +2. **Array length**: `Array.length` returns `int32`, not Python `int`. The compiler wraps `len()` calls: `int32(len(array))`. + +3. **Interop boundaries**: When calling Python libraries that return `int`, you may need explicit conversion to `int32` for F# code. + +## Option Type Handling + +### The Erasure Strategy + +Fable erases `Option` to `T | None` when safe, but must wrap in `SomeWrapper` when the option could be nested or the inner type is unknown: + +| Context | F# Type | Python Type | Representation | +| ------------------- | --------------------- | --------------------- | -------------------------- | +| Non-nested concrete | `Option` | `int \| None` | Bare value or `None` | +| Nested option | `Option>` | `Option[Option[int]]` | `SomeWrapper` wrapping | +| Generic parameter | `Option<'T>` | `Option[T]` | `SomeWrapper[T]` or `None` | +| Option-like inner | `Option>` | `Option[Result[...]]` | `SomeWrapper` wrapping | + +**Why wrap?** + +1. **Nested options**: `Some(None)` vs `None` - without wrapping, both would be `None` in Python +2. **Generic types**: When `'T` could itself be `Option<_>`, we can't safely erase +3. **Option-like types**: `Result`, `ValueOption`, etc. have similar ambiguity + +The `mustWrapOption` function in [Fable2Python.Util.fs](Fable2Python.Util.fs) determines when wrapping is required by checking for nested options, generic parameters, and option-like declared types. + +### The Boundary Problem + +When callbacks cross boundaries between generic library code and concrete caller code, types mismatch. A library function with signature `change: K -> (Option[V] -> Option[V]) -> Dict[K,V] -> Dict[K,V]` expects a wrapped Option callback, but callers with concrete types generate callbacks with erased signatures like `int | None -> int | None`. + +### Solution: `erase()` and `widen()` + +Two zero-cost type adapter functions (identity at runtime): + +| Function | Signature | Purpose | +| -------- | ------------------------ | -----------------------------------------------| +| `erase` | `Option[T] -> T \| None` | When crossing from generic to concrete context | +| `widen` | `T \| None -> Option[T]` | When crossing from concrete to generic context | + +The compiler automatically inserts `widen()` when passing callbacks to generic functions that expect wrapped Option types. This is handled by `needsOptionWidenForArg` in [Fable2Python.Util.fs](Fable2Python.Util.fs) and the argument transformation in [Fable2Python.Transforms.fs](Fable2Python.Transforms.fs). + +### When to Skip Type Annotations + +Some contexts require omitting annotations to let Python infer correct types: + +1. **Invariant containers with Options**: When a call returns `Array[tuple[K, Option[V]]]`, annotating the variable would cause a type mismatch due to array invariance +2. **Wrapped option narrowing**: After `if x is not None:`, assigning `x` to a new variable should let the narrowed type be inferred + +The helpers `valueExtractsFromInvariantContainer` and `isWrappedOptionNarrowingAssignment` in [Fable2Python.Util.fs](Fable2Python.Util.fs) detect these cases. + +## Currying and Uncurrying + +### The Problem + +F# functions are curried by default (`let add x y = x + y` has type `int -> int -> int`), but Python doesn't have native currying - functions take all arguments at once. + +### Runtime Curry/Uncurry + +The `fable_library/curry.py` module provides `curry` and `uncurry` functions that convert between: + +- **Curried**: Nested lambdas `x -> (y -> x + y)` +- **Uncurried**: Multi-argument function `(x, y) -> x + y` + +These use memoization via `WeakKeyDictionary` to enable roundtrip conversions without data loss. + +### Type Annotations for Curried Functions + +**Challenge:** Deeply nested `Callable[[A], Callable[[B], Callable[[C], D]]]` types confuse type checkers and are impractical to verify through curry/uncurry transformations. + +**Solution:** Smart simplification based on nesting depth: + +| Nesting Depth | F# Type | Python Annotation | +| ------------- | ------------------ | --------------------------------- | +| Depth 1 | `A -> B` | `Callable[[A], B]` | +| Depth 2 | `A -> B -> C` | `Callable[..., Callable[..., C]]` | +| Depth 3+ | `A -> B -> C -> D` | `Callable[..., Any]` | + +The key insight is to preserve concrete return types where possible (so Pyright can still catch real bugs like `Option[T]` vs `T | None` mismatches) while simplifying argument types that are impractical to track. + +This is implemented in `makeLambdaTypeAnnotation` in [Fable2Python.Annotation.fs](Fable2Python.Annotation.fs). + +### Field Type Uncurrying + +Class/record fields store **uncurried** functions at runtime, so annotations must match. A field declared as `fn: unit -> string -> int` in F# generates `fn_: Callable[[None, str], int]` (uncurried), not `Callable[..., Callable[..., int]]` (curried). + +This follows the same pattern as the TypeScript backend and is handled in `declareDataClassType` by applying `FableTransforms.uncurryType` to lambda-typed fields. + +## Python Match Statements + +Fable generates Python 3.10+ `match` statements for pattern matching when the decision tree structure allows it. + +### Supported Patterns + +- **Literal patterns**: Integer, string, char matching → `case 1:`, `case "hello":` +- **Or-patterns**: `| 1 | 2 | 3 ->` → `case 1 | 2 | 3:` +- **Union case matching**: Matching on `.tag` property +- **Guard expressions**: `when` clauses → `case n if n > 0:` +- **Tuple patterns with guards**: `| true, _, i when i > -1 ->` → `case [True, _, i] if i > -1:` + +### Guards and Walrus Operators + +For Option patterns in guards, Fable uses walrus operators for proper type narrowing. When matching `Some x :: t`, the generated code uses `(x := head(l)) is not None` which both assigns and tests, allowing Pyright to narrow `x` from `T | None` to `T`. + +This is implemented in `transformDecisionTreeAsMatchWithGuards` in [Fable2Python.Transforms.fs](Fable2Python.Transforms.fs). + +### Fallback + +When match statement conversion isn't possible (complex nested patterns, certain decision tree structures), Fable falls back to `if/elif/else` chains. + +## ABC Base Classes for Collections + +F# collection types implement Python ABC protocols for native Python interop (`in`, `len()`, `[]`, iteration). + +### The Hybrid Approach + +Following the TypeScript pattern where F# explicitly implements `JS.Map` and the compiler generates `Symbol.iterator`: + +1. **F# interface implementation** → methods become attached class methods (e.g., `get_Item`, `ContainsKey`, `Count`) +2. **Compiler generates dunders** → `__getitem__`, `__len__`, `__contains__`, `__iter__` that call the attached methods +3. **Direct ABC inheritance** → Classes inherit from `Mapping`, `Set`, etc. directly + +### Supported ABCs + +| F# Interface | Python ABC | Dunders Generated | +| --------------------------------- | --------------------- | ---------------------------------------------------- | +| `Py.Mapping.IMapping` | `Mapping[K,V]` | `__getitem__`, `__contains__`, `__len__`, `__iter__` | +| `Py.Mapping.IMutableMapping` | `MutableMapping[K,V]` | + `__setitem__`, `__delitem__` | +| `Py.Set.ISet` | `Set[T]` | `__contains__`, `__len__`, `__iter__` | +| `Py.Set.IMutableSet` | `MutableSet[T]` | + `add`, `discard` | + +### `__iter__` Special Handling + +- **For Mapping**: The enumerator yields `(key, value)` tuples, so `__iter__` extracts keys: `yield kv[0]` +- **For Set**: Yield items directly from the enumerator + +The `to_iterator` helper in `fable_library/util.py` wraps `IEnumerator` with proper `Dispose` handling via `try/finally`. + +### Dictionary vs dict vs Mapping + +There's an inherent tension between F#'s `Dictionary`, .NET's `IDictionary`, Python's built-in `dict`, and the `collections.abc.Mapping` protocol. + +**The type hierarchy:** + +| F# / .NET Type | Python Type | Relationship | +| ------------------ | ----------------------- | --------------------------------------- | +| `Dictionary` | `Dictionary[K,V]` class | Fable's mutable dictionary | +| `IDictionary` | `IDictionary` protocol | .NET interface for mutable dictionaries | +| `dict` | `dict[K,V]` | Python's built-in dictionary | +| - | `Mapping[K,V]` | Python ABC for read-only dict-like | +| - | `MutableMapping[K,V]` | Python ABC for mutable dict-like | + +**Challenges:** + +1. **`Dictionary` is not `dict`**: Fable's `Dictionary` class is a separate type from Python's `dict`. Helper functions accepting `dict[K,V]` won't accept `Dictionary[K,V]` without using `Mapping[K,V]` as the parameter type. + +2. **Dunder method signatures**: F# interface methods compile with different signatures than Python expects: + - `__len__` gets an extra `__unit=UNIT` parameter and returns `int32` (should take no params, return `int`) + - `__getitem__` and `__contains__` get default parameters `= UNIT` (should have no defaults) + - `__iter__` returns `IEnumerator[Any]` (should return `Iterator[KEY]`) + +3. **Invariance issues**: `dict` is invariant in its type parameters, so `dict[str, int]` is not compatible with `dict[str, object]`. This affects functions that want to accept "any dictionary". + +**Current approach:** + +- Helper functions like `get_item_from_dict` use `Mapping[K,V]` parameter type to accept both `dict` and `Dictionary` +- The ABC base class approach (see above) provides Python-compatible dunders that delegate to F# methods +- Some signature mismatches are accepted as limitations of the F#→Python compilation model + +## Protocol Generation + +F# interfaces compile to Python Protocols with special handling for type checker compatibility. + +### Positional-Only Parameters + +Protocol parameters are positional-only (using `/`) to avoid name mismatch errors. This allows implementations to use different parameter names (e.g., `value_1` instead of `value` due to closure capture renaming) without violating the protocol. + +### Mangled Interface Names + +When interfaces have `[]` attribute, method names include the full entity name and an overload hash suffix (e.g., `Fable_Tests_TypeTests_BarInterface_DoSomething205A44C0`). Protocols must use these mangled names to match implementations. + +### Uncurried Method Signatures + +Protocol methods use uncurried types to match runtime behavior. A method parameter `f: A -> B -> C` becomes `f: Callable[[A, B], C]`, not `Callable[[A], Callable[[B], C]]`. + +## Common Patterns and Solutions + +| Pattern | Problem | Solution | +| ------------------------- | -------------------------------------------------------------------- | -------------------------------------------------- | +| Generic→Concrete Option | Generic function returns `Option[T]`, caller expects `T \| None` | Insert `erase()` call | +| Concrete→Generic Callback | Callback with `T \| None` passed to function expecting `Option[T]` | Insert `widen()` call | +| Two-Switch Decision Tree | Pattern matching without default case | Make last case default (`else` instead of `elif`) | +| Invariant Container | Annotating `Array[Option[T]]` causes type error | Skip annotation, let type be inferred | + +## Testing and Validation + +### Running Pyright + +```bash +# Test rebuilding everything (fable-library + fable.library.core + tests) +./build.sh test python +# Test rebuilding, but skipping fable-library rebuild +./build.sh test python --skip-fable-library +# Test rebuilding tests and fable-library but skipping fable-library-core rebuild +./build.sh test python --skip-fable-library-core +``` + +```bash +# Check fable-library +uv run pyright temp/fable-library-py + +# Check generated test code +uv run pyright temp/tests/Python +``` + +### Pyright Baseline + +The project maintains a pyright error baseline. New code should not introduce regressions. The `pyrightconfig.ci.json` +file is used in CI to enforce this, and will exclude files with known errors. + +## References + +- [PYTHON-TYPING.md](../../../PYTHON-TYPING.md) - Detailed session-by-session progress +- [PYTHON-ABC.md](../../../PYTHON-ABC.md) - ABC base class implementation details +- [PYTHON-MATCH.md](../../../PYTHON-MATCH.md) - Match statement generation +- [PYTHON-OPTIONS.md](../../../PYTHON-OPTIONS.md) - Option type handling details