diff --git a/pyproject.toml b/pyproject.toml index 449ef0e68..34b72ac5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dev = [ "dunamai>=1.23.1,<2", "pydantic>=2.11.7", "attrs>=25.3.0", - "pyright>=1.1.407", + "pyright>=1.1.408", "ty>=0.0.7", ] diff --git a/src/Fable.Build/FableLibrary/Python.fs b/src/Fable.Build/FableLibrary/Python.fs index 0d079cc6b..39d2ca775 100644 --- a/src/Fable.Build/FableLibrary/Python.fs +++ b/src/Fable.Build/FableLibrary/Python.fs @@ -22,7 +22,6 @@ type BuildFableLibraryPython(?skipCore: bool) = // Copy all Python/F# files to the build directory Directory.GetFiles(this.LibraryDir, "*") |> Shell.copyFiles this.BuildDir Directory.GetFiles(this.SourceDir, "*.py") |> Shell.copyFiles this.OutDir - Directory.GetFiles(this.SourceDir, "*.pyi") |> Shell.copyFiles this.OutDir // Python extension modules Directory.GetFiles(Path.Combine(this.SourceDir, "core"), "*") diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index c2fdaa8bf..f5fd44859 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* [Python] Changed DU representation to use separate classes for each case (by @dbrattli) * [Python] Fable will no longer auto-generate `__str__` or `__hash__` for custom types. Use the `Py.Stringable` and `Py.Hashable` marker interfaces to generate these methods (by @dbrattli) ### Added diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index ff54ef3a2..eaabd3e23 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* [Python] Changed DU representation to use separate classes for each case (by @dbrattli) * [Python] Fable will no longer auto-generate `__str__` or `__hash__` for custom types. Use the `Py.Stringable` and `Py.Hashable` marker interfaces to generate these methods (by @dbrattli) ### Added diff --git a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs index 04b0b8242..ee2c37f09 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs @@ -373,12 +373,28 @@ let makeGenericTypeAnnotation' Expression.subscript (name, Expression.tuple genArgs) +/// Creates a subscript expression for generic type parameters from a list of names. +/// For a single param, returns just the name; for multiple, returns a tuple. +/// E.g., [] -> baseExpr, [T] -> baseExpr[T], [T1, T2] -> baseExpr[T1, T2] +let makeGenericParamSubscript (genParamNames: string list) (baseExpr: Expression) = + if List.isEmpty genParamNames then + baseExpr + else + let genArgs = genParamNames |> List.map Expression.name + + let slice = + match genArgs with + | [ single ] -> single + | multiple -> Expression.tuple multiple + + Expression.subscript (baseExpr, slice) + let resolveGenerics com ctx generics repeatedGenerics : Expression list * Statement list = generics |> List.map (typeAnnotation com ctx repeatedGenerics) |> Helpers.unzipArgs -let typeAnnotation +let rec typeAnnotation (com: IPythonCompiler) ctx (repeatedGenerics: Set option) @@ -644,7 +660,36 @@ let makeEntityTypeAnnotation com ctx (entRef: Fable.EntityRef) genArgs repeatedG | "string" -> StringTypeAnnotation | _ -> AnyTypeAnnotation*) | Expression.Name { Id = Identifier id } -> - makeGenericTypeAnnotation com ctx id genArgs repeatedGenerics, stmts + // For F# union types, tryPyConstructor returns the underscore-prefixed base class + // name (e.g., "_MyUnion"). For type annotations: + // - Inside base class definition: use base class name (_MyUnion) + // - Elsewhere: use type alias (MyUnion) for public API + let isInsideThisUnionBaseClass = + match ctx.EnclosingUnionBaseClass with + | Some enclosingName -> ent.DisplayName = enclosingName + | None -> false + + let annotationName = + if + ent.IsFSharpUnion + && id.StartsWith("_", StringComparison.Ordinal) + && not isInsideThisUnionBaseClass + then + // Outside base class - use type alias (strip underscore) + id.Substring(1) + else + // Inside base class or not a union - use as-is + id + + // Import the type if it's from another file + if ent.IsFSharpUnion then + match ent.Ref.SourcePath with + | Some path when path <> com.CurrentFile -> + let importPath = Path.getRelativeFileOrDirPath false com.CurrentFile false path + com.GetImportExpr(ctx, importPath, annotationName) |> ignore + | _ -> () + + makeGenericTypeAnnotation com ctx annotationName genArgs repeatedGenerics, stmts // TODO: Resolve references to types in nested modules | _ -> stdlibModuleTypeHint com ctx "typing" "Any" [] repeatedGenerics | None -> stdlibModuleTypeHint com ctx "typing" "Any" [] repeatedGenerics @@ -684,6 +729,7 @@ let makeBuiltinTypeAnnotation com ctx typ repeatedGenerics kind = fableModuleAnnotation com ctx "result" "FSharpResult_2" resolved, stmts | Replacements.Util.FSharpChoice genArgs -> let resolved, stmts = resolveGenerics com ctx genArgs repeatedGenerics + // Use the type alias (clean name without underscore prefix) let name = $"FSharpChoice_%d{List.length genArgs}" fableModuleAnnotation com ctx "choice" name resolved, stmts | _ -> stdlibModuleTypeHint com ctx "typing" "Any" [] repeatedGenerics diff --git a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs index dd860b754..5c99228a4 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs @@ -85,7 +85,34 @@ let private transformUnionReflectionInfo com ctx r (ent: Fable.Entity) generics let py, stmts = pyConstructor com ctx ent - [ fullnameExpr; arrayExpr com ctx generics; py; cases ] + // Generate case constructors list for make_union + // Use full case class names (UnionName_CaseName) to match the generated classes, + // except for library types (Result, Choice) which use simple names + let usesSimpleNames = Util.usesSimpleCaseNames ent.FullName + + // Get the entity declaration name (with module scope) for consistent naming + let entityDeclName = FSharp2Fable.Helpers.getEntityDeclarationName com ent.Ref + + let caseConstructors = + ent.UnionCases + |> Seq.map (fun uci -> + let caseName = + match uci.CompiledName with + | Some cname -> cname + | None -> uci.Name + + let caseClassName = + if usesSimpleNames then + caseName + else + $"%s{entityDeclName}_%s{caseName}" + + com.GetIdentifierAsExpr(ctx, caseClassName) + ) + |> Seq.toList + |> Expression.list + + [ fullnameExpr; arrayExpr com ctx generics; py; cases; caseConstructors ] |> libReflectionCall com ctx None "union", stmts diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index e682d7129..66e5a0c11 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -111,6 +111,29 @@ let getUnionCaseName (uci: Fable.UnionCase) = | Some cname -> cname | None -> uci.Name +/// Gets the unique case class name by prefixing with the union type name. +/// This prevents collisions when different union types have cases with the same name. +/// Library types (Result, Choice) use simple case names without prefix. +/// The optional entityName parameter should be the compiled entity name (with module scope). +let getUnionCaseClassName + (com: IPythonCompiler) + (ent: Fable.Entity) + (uci: Fable.UnionCase) + (entityName: string option) + = + let caseName = getUnionCaseName uci + // Library types use simple names (Ok, Error, Choice1Of2, etc.) for backwards compatibility + if usesSimpleCaseNames ent.FullName then + caseName + else + // Use provided entity name or compute from entity reference + let unionName = + match entityName with + | Some name -> name + | None -> FSharp2Fable.Helpers.getEntityDeclarationName com ent.Ref + + $"%s{unionName}_%s{caseName}" + let getUnionExprTag (com: IPythonCompiler) ctx r (fableExpr: Fable.Expr) = Expression.withStmts { let! expr = com.TransformAsExpr(ctx, fableExpr) @@ -483,16 +506,42 @@ let transformValue (com: IPythonCompiler) (ctx: Context) r value : Expression * values |> List.map (fun x -> com.TransformAsExpr(ctx, x)) |> Helpers.unzipArgs List.zip (List.ofArray fieldNames) values |> makePyObject, stmts - | Fable.NewUnion(values, tag, ent, _genArgs) -> - let ent = com.GetEntity(ent) + | Fable.NewUnion(values, tag, entRef, _genArgs) -> + let ent = com.GetEntity(entRef) let values, stmts = List.map (fun x -> com.TransformAsExpr(ctx, x)) values |> Helpers.unzipArgs - let consRef, stmts' = ent |> pyConstructor com ctx - // let caseName = ent.UnionCases |> List.item tag |> getUnionCaseName |> ofString - let values = ofInt com ctx tag :: values - Expression.call (consRef, values, ?loc = r), stmts @ stmts' + // Get the union case + let uci = ent.UnionCases |> List.item tag + + // Determine the import path based on the entity type + let caseRef = + // Library types (Result, Choice) use simple case names from fable_library + if isLibraryUnionType entRef.FullName then + let caseName = getUnionCaseName uci + // Result uses "result" module, Choice uses "choice" module + let moduleName = + if entRef.FullName = Types.result then + "result" + else + "choice" + + libValue com ctx moduleName caseName + else + // User-defined union - use full case class name (UnionName_CaseName) + let caseClassName = getUnionCaseClassName com ent uci None + // Check if it's from another file + match entRef.SourcePath with + | Some path when path <> com.CurrentFile -> + // Import from another module + let importPath = Path.getRelativeFileOrDirPath false com.CurrentFile false path + com.GetImportExpr(ctx, importPath, caseClassName) + | _ -> + // Local - just get identifier + com.GetIdentifierAsExpr(ctx, caseClassName) + + Expression.call (caseRef, values, ?loc = r), stmts | _ -> failwith $"transformValue: value %A{value} not supported!" let extractBaseExprFromBaseCall (com: IPythonCompiler) (ctx: Context) (baseType: Fable.DeclaredType option) baseCall = @@ -1130,13 +1179,10 @@ 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") widenedBody identifier + // No type tests found, use Exception to match F#/.NET semantics. + // Users can explicitly catch KeyboardInterrupt, SystemExit, GeneratorExit + // using type tests if needed. + let handler = makeHandler (Expression.identifier "Exception") catchBody identifier Some [ handler ], [] @@ -1153,13 +1199,11 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: option List.unzip // Add fallback handler if fallback is not just a reraise - // Use BaseException to catch all exceptions including KeyboardInterrupt etc. - // We widen exception types to Any because BaseException is broader than Exception + // Use Exception to match F#/.NET semantics let fallbackHandlers = match fallback with | Some fallbackExpr when not (ExceptionHandling.isReraise fallbackExpr) -> - let widenedExpr = ExceptionHandling.widenExceptionTypes fallbackExpr - [ makeHandler (Expression.identifier "BaseException") widenedExpr identifier ] + [ makeHandler (Expression.identifier "Exception") fallbackExpr identifier ] | _ -> [] Some(handlers @ fallbackHandlers), List.concat stmts @@ -3825,56 +3869,17 @@ let transformAttachedMethod (com: IPythonCompiler) ctx (info: Fable.MemberFuncti ] let transformUnion (com: IPythonCompiler) ctx (ent: Fable.Entity) (entName: string) classMembers = - let fieldIds = getUnionFieldsAsIdents com ctx ent - - let args, isOptional = - let args = - fieldIds[0] - |> ident com ctx - |> (fun id -> - let ta, _ = Annotation.typeAnnotation com ctx None fieldIds[0].Type - Arg.arg (id, annotation = ta) - ) - |> List.singleton - - let varargs = - fieldIds[1] - |> ident com ctx - |> fun id -> - let gen = getGenericTypeParams [ fieldIds[1].Type ] |> Set.toList |> List.tryHead - - let ta = Expression.name (gen |> Option.defaultValue "Any") - Arg.arg (id, annotation = ta) - + // Get generic type parameters for the union + let typeParams = makeEntityTypeParams com ctx ent - let isOptional = Helpers.isOptional fieldIds - Arguments.arguments (args = args, vararg = varargs), isOptional + // Get generic parameter names for parameterizing base class in case classes + // Use uppercase and clean to match makeTypeParams behavior + let genParamNames = + ent.GenericParameters + |> List.map (fun p -> p.Name.ToUpperInvariant() |> Helpers.clean) - let body = - [ - yield callSuperAsStatement [] - yield! - fieldIds - |> Array.map (fun id -> - let left = get com ctx None thisExpr id.Name false - - let right = - match id.Type with - | Fable.Number _ -> identAsExpr com ctx id - | Fable.Array _ -> - // Convert varArg from tuple to array. TODO: we might need to do this other places as well. - let array = libValue com ctx "array_" "Array" - let type_obj = com.GetImportExpr(ctx, "typing", "Any") - let types_array = Expression.subscript (value = array, slice = type_obj, ctx = Load) - Expression.call (types_array, [ identAsExpr com ctx id ]) - | _ -> identAsExpr com ctx id - - let ta, _ = Annotation.typeAnnotation com ctx None id.Type - Statement.assign (left, ta, right) - ) - ] - - let cases = + // Generate the cases() static method for the base class + let casesMethod = let expr, stmts = ent.UnionCases |> Seq.map (getUnionCaseName >> makeStrConst) @@ -3896,10 +3901,124 @@ let transformUnion (com: IPythonCompiler) ctx (ent: Fable.Entity) (entName: stri decoratorList = decorators ) - let baseExpr = libValue com ctx "union" "Union" |> Some - let classMembers = List.append [ cases ] classMembers + // Generate the base class (e.g., MyUnion[T](Union) or MyUnion(Union)) + let baseUnionExpr = libValue com ctx "union" "Union" + // casesMethod is always present, so body is never empty + let baseClassBody = casesMethod :: classMembers + + // Base class uses leading underscore (private), type alias gets clean name (public) + let baseClassName = com.GetIdentifier(ctx, "_" + entName) + + let baseClassDef = + Statement.classDef (baseClassName, bases = [ baseUnionExpr ], body = baseClassBody, typeParams = typeParams) + + // Generate case classes with @tagged_union decorator + let caseClasses = + ent.UnionCases + |> List.mapi (fun tag uci -> + // Use full case class name (UnionName_CaseName) to avoid collisions + // Pass the entity name to ensure consistent scoping with base class + let caseClassName = getUnionCaseClassName com ent uci (Some entName) + let caseClassIdent = com.GetIdentifier(ctx, caseClassName) + + // Export the case class for library builds + if com.OutputType = OutputType.Library then + com.AddExport caseClassName |> ignore + + // Get the tagged_union decorator with the tag number (simple int literal) + let taggedUnionDecorator = + let taggedUnion = libValue com ctx "union" "tagged_union" + let tagLiteral = Expression.intConstant tag + Expression.call (taggedUnion, [ tagLiteral ]) + + // Generate field annotations for the case class (field: type syntax) + // Use a context without EnclosingUnionBaseClass so field types use type alias + let caseCtx = { ctx with EnclosingUnionBaseClass = None } + + let fieldAnnotations = + uci.UnionCaseFields + |> List.map (fun field -> + // Convert to snake_case and clean to remove invalid characters like apostrophes + // Handles: "Item" -> "item", "Item1" -> "item1", "MyField" -> "my_field" + let fieldName = field.Name |> Naming.toSnakeCase |> Helpers.clean + let ta, _ = Annotation.typeAnnotation com caseCtx None field.FieldType + let target = Expression.name (fieldName, Store) + // Use annAssign to generate: field: type (not field = type) + Statement.annAssign (target, annotation = ta, simple = true) + ) + + let caseClassBody = + match fieldAnnotations with + | [] -> [ Statement.ellipsis ] + | _ -> fieldAnnotations + + // The case class inherits from the parameterized base union class + // e.g., class Either_Left[TL, TR](_Either_2[TL, TR]): ... + let baseClassExpr = + Expression.name ("_" + entName) + |> Annotation.makeGenericParamSubscript genParamNames + + Statement.classDef ( + caseClassIdent, + bases = [ baseClassExpr ], + body = caseClassBody, + decoratorList = [ taggedUnionDecorator ], + typeParams = typeParams + ) + ) + + // Generate type alias: type MyUnion[T] = MyUnion_CaseA[T] | MyUnion_CaseB[T] | ... + // The type alias uses the clean name (public API), base class uses underscore prefix (private) + let typeAlias = + let aliasName = Expression.name entName + + // If generic, parameterize each case type + let caseTypes = + ent.UnionCases + |> List.map (fun uci -> + // Use the full case class name (UnionName_CaseName) + let caseClassName = getUnionCaseClassName com ent uci (Some entName) + + Expression.name caseClassName + |> Annotation.makeGenericParamSubscript genParamNames + ) + + let unionType = + match caseTypes with + | [] -> Expression.name "None" + | [ single ] -> single + | first :: rest -> List.fold (fun acc t -> Expression.binOp (acc, BitOr, t)) first rest + + Statement.typeAlias (aliasName, unionType, typeParams = typeParams) + + // Generate reflection declaration for the union type (same as for records/classes) + let reflectionDeclaration, reflectionStmts = + let ta = fableModuleAnnotation com ctx "reflection" "TypeInfo" [] + + let genArgs = + Array.init ent.GenericParameters.Length (fun i -> "gen" + string i |> makeIdent) + + let args = + genArgs + |> Array.mapToList (fun id -> Arg.arg (ident com ctx id, annotation = ta)) - declareType com ctx ent entName args isOptional body baseExpr classMembers None [] [] + let args = Arguments.arguments args + let generics = genArgs |> Array.mapToList (identAsExpr com ctx) + + let body, stmts = transformReflectionInfo com ctx None ent generics + let expr, stmts' = makeFunctionExpression com ctx None (args, body, [], ta) + + let name = + com.GetIdentifier(ctx, Naming.toPascalCase entName + Naming.reflectionSuffix) + + expr |> declareModuleMember com ctx ent.IsPublic name None, stmts @ stmts' + + // Return all statements: base class, case classes, type alias, reflection + reflectionStmts + @ [ baseClassDef ] + @ caseClasses + @ [ typeAlias ] + @ reflectionDeclaration let transformClassWithCompilerGeneratedConstructor (com: IPythonCompiler) @@ -4645,6 +4764,14 @@ let rec transformDeclaration (com: IPythonCompiler) ctx (decl: Fable.Declaration let ctx = { ctx with ScopedTypeParams = Set.union ctx.ScopedTypeParams classGenericParams } + // For union types, set the enclosing base class context so type annotations + // inside base class methods use the base class name instead of type alias + let ctx = + if ent.IsFSharpUnion then + { ctx with EnclosingUnionBaseClass = Some decl.Name } + else + ctx + let classMembers = decl.AttachedMembers |> List.collect (fun memb -> @@ -4815,6 +4942,7 @@ let transformFile (com: IPythonCompiler) (file: Fable.File) = ScopedTypeParams = Set.empty TypeParamsScope = 0 NarrowedTypes = Map.empty + EnclosingUnionBaseClass = None } // printfn "file: %A" file.Declarations diff --git a/src/Fable.Transforms/Python/Fable2Python.Types.fs b/src/Fable.Transforms/Python/Fable2Python.Types.fs index eed19f852..db2addc80 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Types.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Types.fs @@ -123,6 +123,9 @@ type Context = ScopedTypeParams: Set TypeParamsScope: int NarrowedTypes: Map + /// When inside a union base class definition, this holds the entity name. + /// Used to determine whether to use base class name or type alias for annotations. + EnclosingUnionBaseClass: string option } type IPythonCompiler = diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index 8b16d8bb6..203140de9 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -32,6 +32,27 @@ module Lib = module Util = open Lib + /// Returns true if this is a core library union type (Result, Choice) in user code + /// that should import case constructors from fable_library. + /// Only matches Microsoft.FSharp.Core.* (user code), not FSharp.Core.* (library source) + /// to avoid circular imports when compiling the library itself. + let isLibraryUnionType (fullName: string) = + match fullName with + | Types.result -> true + | fn when fn.StartsWith("Microsoft.FSharp.Core.FSharpChoice`", StringComparison.Ordinal) -> true + | _ -> false + + /// Returns true if this union type should use simple case names (Choice1Of2, Ok, Error) + /// rather than prefixed names (MyUnion_Case1). Used for naming case classes. + /// This matches both user code (Microsoft.FSharp.Core.*) and library source (FSharp.Core.*). + let usesSimpleCaseNames (fullName: string) = + match fullName with + | Types.result -> true + | fn when fn.StartsWith("Microsoft.FSharp.Core.FSharpChoice`", StringComparison.Ordinal) -> true + | fn when fn.StartsWith("FSharp.Core.FSharpChoice`", StringComparison.Ordinal) -> true + | "FSharp.Core.FSharpResult`2" -> true + | _ -> false + /// Ensures a statement list is non-empty by adding Pass if needed. /// Python requires at least one statement in function/class/match bodies. let ensureNonEmptyBody stmts = @@ -1204,39 +1225,6 @@ 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.", StringComparison.Ordinal) - && entRef.FullName.EndsWith("Exception", StringComparison.Ordinal) - | _ -> 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 = diff --git a/src/Fable.Transforms/Python/PYTHON-UNION.md b/src/Fable.Transforms/Python/PYTHON-UNION.md new file mode 100644 index 000000000..4aaa1ad68 --- /dev/null +++ b/src/Fable.Transforms/Python/PYTHON-UNION.md @@ -0,0 +1,437 @@ +# Python Union Type Implementation for Fable + +This document describes the implemented discriminated union design for Fable's Python output. + +## Background + +F# discriminated unions are a powerful type system feature that allows defining types with multiple named cases, each potentially carrying different data. When compiling F# to Python, we need to represent these unions in a way that: + +1. **Preserves type safety** - Python type checkers (pyright, mypy) should understand the union structure +2. **Enables pattern matching** - Python 3.10+ `match` statements should work naturally with case extraction +3. **Provides good IDE support** - Autocomplete, go-to-definition, and hover documentation should work +4. **Maintains backwards compatibility** - Existing Fable Python code using `.tag` and `.fields` should continue to work +5. **Follows Python conventions** - The generated code should look and feel like idiomatic Python + +The original Fable implementation used a single class with factory functions, which worked but provided poor type checker and IDE support. This new design uses separate dataclasses for each case, connected by a type alias, giving us the best of both worlds: full type safety and Pythonic ergonomics. + +## F# Source + +```fsharp +type MyUnion = + | CaseA of int + | CaseB of string + | CaseC of x: float * y: float +``` + +## Generated Python Output + +```python +from fable_library.union import Union, tagged_union + +# Base class with underscore prefix (private/internal) +class _MyUnion(Union): + """Base class inheriting from Union for compatibility.""" + + @staticmethod + def cases() -> list[str]: + return ["CaseA", "CaseB", "CaseC"] + + +@tagged_union(0) +class MyUnion_CaseA(_MyUnion): + item: int + + +@tagged_union(1) +class MyUnion_CaseB(_MyUnion): + item: str + + +@tagged_union(2) +class MyUnion_CaseC(_MyUnion): + x: float + y: float + + +# Type alias - THE public union type for annotations +type MyUnion = (MyUnion_CaseA | MyUnion_CaseB) | MyUnion_CaseC +``` + +## Key Design Decisions + +### Naming Convention: Base Class vs Type Alias + +The naming convention uses underscore prefix for the base class (private) and clean name for the type alias (public API): + +| Element | Name | Purpose | +| ------------ | --------------- | ------------------------------------------------------------ | +| Base class | `_MyUnion` | Internal, has `cases()` method, used for `isinstance` checks | +| Case classes | `MyUnion_CaseA` | Prefixed with scoped union name to avoid collisions | +| Type alias | `MyUnion` | Public API, used for type annotations | + +This design follows Python conventions where leading underscore indicates private/internal usage. + +### Why This Naming? + +1. **Base class is private (`_MyUnion`)**: Users don't instantiate the base class directly - they use case constructors +2. **Type alias is public (`MyUnion`)**: This is what users see in type annotations and IDE hints +3. **Case classes use full scoped prefix (`MyUnion_CaseA`)**: Prevents collisions when multiple unions have same case names, includes module scope + +### Case Class Naming: `{ScopedUnionName}_{CaseName}` + +Case classes are prefixed with the **scoped union name** (including module prefix) to prevent naming collisions. This ensures consistency between the base class and case classes: + +```fsharp +// F# - unions in different modules +module ModuleA = + type Result = | Ok of int | Error of string + +module ModuleB = + type Result = | Ok of string | Error of int +``` + +```python +# Python - module-scoped names prevent collision +class _ModuleA_Result(Union): ... + +@tagged_union(0) +class ModuleA_Result_Ok(_ModuleA_Result): + item: int + +@tagged_union(1) +class ModuleA_Result_Error(_ModuleA_Result): + item: str + +class _ModuleB_Result(Union): ... + +@tagged_union(0) +class ModuleB_Result_Ok(_ModuleB_Result): + item: str + +@tagged_union(1) +class ModuleB_Result_Error(_ModuleB_Result): + item: int +``` + +The scoped name is derived from `FSharp2Fable.Helpers.getEntityDeclarationName`, which includes the module path to ensure unique names across the entire compilation. + +### Library Types Keep Simple Names + +F# core library types (`Result`, `FSharpChoice`) use simple case names without prefix for cleaner interop: + +```python +# Result type - base class is private +class _FSharpResult_2[T, TERROR](Union): + @staticmethod + def cases() -> list[str]: + return ["Ok", "Error"] + +@tagged_union(0) +class Ok[T, TERROR](_FSharpResult_2[T, TERROR]): + result_value: T + +@tagged_union(1) +class Error[T, TERROR](_FSharpResult_2[T, TERROR]): + error_value: TERROR + +# Type alias is the public API +type FSharpResult_2[T, TERROR] = Ok[T, TERROR] | Error[T, TERROR] +``` + +### Type Annotation Context + +Type annotations use different names depending on context: + +| Context | Name Used | Reason | +| -------------------------------- | ----------------------- | ---------------------- | +| Function parameters/returns | `MyUnion` (type alias) | Public API | +| `self` inside base class methods | `_MyUnion` (base class) | Actual type of `self` | +| Case class field types | `MyUnion` (type alias) | Public API | +| Reflection `construct` parameter | `_MyUnion` (base class) | Needs `cases()` method | + +### The `@tagged_union` Decorator + +Located in [src/fable-library-py/fable_library/union.py](src/fable-library-py/fable_library/union.py): + +```python +from dataclasses import dataclass, fields as dataclass_fields +from typing import Any, dataclass_transform + +from .array_ import Array + +@dataclass_transform() +def tagged_union(tag: int): + """Decorator for union case classes. + + Uses @dataclass_transform() so type checkers understand: + - Field annotations become constructor parameters + - __match_args__ is generated + - __eq__, __repr__, __hash__ are generated + + Additionally sets: + - cls.tag = tag (numeric case discriminator) + - cls.fields property (Array of field values for backwards compat) + """ + def decorator[T](cls: type[T]) -> type[T]: + # Apply dataclass internally + dc_cls: Any = dataclass(cls) + + # Set the tag + dc_cls.tag = tag + + # Generate fields property from dataclass fields + field_names = [f.name for f in dataclass_fields(dc_cls)] + + @property + def fields(self) -> Array[Any]: + return Array[Any]([getattr(self, name) for name in field_names]) + + dc_cls.fields = fields + return dc_cls + + return decorator +``` + +### Reflection Support + +The `CaseInfo` class in [reflection.py](src/fable-library-py/fable_library/reflection.py) includes a `case_constructor` field for dynamic union construction: + +```python +@dataclass +class CaseInfo: + declaringType: TypeInfo + tag: int + name: str + fields: list[FieldInfo] + case_constructor: type[Any] | None = None +``` + +The `union_type` function accepts the **base class** (with underscore prefix) as the `construct` parameter: + +```python +def union_type( + fullname: str, + generics: Array[TypeInfo], + construct: type[Union], # Must be the base class, e.g., _MyUnion + cases: Callable[[], list[list[FieldInfo]]], + case_constructors: list[type[Any]] | None = None, +) -> TypeInfo: +``` + +And `make_union` uses them: + +```python +def make_union(uci: CaseInfo, values: Array[Any]) -> Any: + if uci.case_constructor is not None: + return uci.case_constructor(*values) + return uci.declaringType.construct(uci.tag, *values) +``` + +## Usage Examples + +```python +# Construction - use case classes directly +u = MyUnion_CaseA(42) +u = MyUnion_CaseC(1.0, 2.0) + +# Field access - direct attributes with F# names +print(u.item) # For CaseA/CaseB +print(u.x, u.y) # For CaseC + +# Pattern matching - __match_args__ automatic from dataclass +match u: + case MyUnion_CaseA(item=value): + print(f"CaseA: {value}") + case MyUnion_CaseC(x=x, y=y): + print(f"CaseC: {x}, {y}") + +# isinstance works with base class (underscore-prefixed) +isinstance(u, _MyUnion) # True for all cases +isinstance(u, Union) # True - inherits from Union +isinstance(u, MyUnion_CaseA) # True for CaseA only + +# Tag still available for compatibility +print(u.tag) # 0, 1, or 2 +print(u.fields) # Array([42]) or Array([1.0, 2.0]) + +# cases() method on base class +print(_MyUnion.cases()) # ["CaseA", "CaseB", "CaseC"] +``` + +## Type Annotations + +Use the type alias (clean name) for public API annotations: + +```python +def process(value: MyUnion) -> str: + match value: + case MyUnion_CaseA(item=i): return f"int: {i}" + case MyUnion_CaseB(item=s): return f"str: {s}" + case MyUnion_CaseC(x=x, y=y): return f"point: ({x}, {y})" + +def create() -> MyUnion: + return MyUnion_CaseA(42) +``` + +Inside base class methods, use the base class name: + +```python +class _MyUnion(Union): + def GetHashCode(self) -> int: + x: _MyUnion = self # Use base class for self + return safe_hash(x) +``` + +## What This Provides + +- `isinstance(u, _MyUnion)` works for all cases (base class) +- `isinstance(u, Union)` works (inherits from Union) +- `tag` field for numeric case discrimination (backwards compatible) +- `fields` property returning `Array[Any]` for indexed access (backwards compatible) +- `cases()` method on base class (backwards compatible) +- `__match_args__` automatic from `@dataclass` (via decorator) +- `__eq__`, `__repr__`, `__hash__` from `@dataclass` (via decorator) +- Type alias `MyUnion` for precise type annotations (public API) +- `@dataclass_transform()` makes type checkers understand the class structure +- Prefixed case names prevent naming collisions between unions +- Library types use simple names for cleaner interop +- Private base class follows Python underscore convention + +## Compiler Implementation Details + +### Context Tracking + +The compiler tracks `EnclosingUnionBaseClass` in the context to determine when we're inside a base class definition: + +```fsharp +type Context = { + // ... other fields + /// When inside a union base class definition, this holds the entity name. + /// Used to determine whether to use base class name or type alias for annotations. + EnclosingUnionBaseClass: string option +} +``` + +### Type Annotation Logic + +In `Fable2Python.Annotation.fs`, the `makeEntityTypeAnnotation` function: + +1. If inside the same union base class (`ctx.EnclosingUnionBaseClass = Some name`): use base class name with underscore +2. Otherwise: use type alias (strip underscore prefix) + +### Reflection Constructor + +In `Replacements.fs`, `tryConstructor` adds underscore prefix for union types so reflection gets the base class: + +```fsharp +| Some(IdentExpr ident) when ent.IsFSharpUnion -> + Some(IdentExpr { ident with Name = "_" + ident.Name }) +``` + +## Files Modified + +1. [src/fable-library-py/fable_library/union.py](src/fable-library-py/fable_library/union.py) - `@tagged_union` decorator +2. [src/fable-library-py/fable_library/reflection.py](src/fable-library-py/fable_library/reflection.py) - `case_constructor` in `CaseInfo`, `union_type` and `make_union` +3. [src/Fable.Transforms/Python/Fable2Python.Types.fs](src/Fable.Transforms/Python/Fable2Python.Types.fs) - Added `EnclosingUnionBaseClass` to `Context` +4. [src/Fable.Transforms/Python/Fable2Python.Transforms.fs](src/Fable.Transforms/Python/Fable2Python.Transforms.fs) - `transformUnion`, context tracking +5. [src/Fable.Transforms/Python/Fable2Python.Annotation.fs](src/Fable.Transforms/Python/Fable2Python.Annotation.fs) - Type annotation logic for union types +6. [src/Fable.Transforms/Python/Fable2Python.Reflection.fs](src/Fable.Transforms/Python/Fable2Python.Reflection.fs) - Case constructor generation +7. [src/Fable.Transforms/Python/Replacements.fs](src/Fable.Transforms/Python/Replacements.fs) - `tryConstructor` adds underscore for unions + +## Comparison with Original Fable Design + +The original Fable Python union implementation used a single class with a factory method pattern: + +### Original Design + +```python +class MyUnion(Union): + def __init__(self, tag: int, *fields: Any) -> None: + super().__init__() + self.tag = tag + self.fields = fields + + @staticmethod + def cases() -> list[str]: + return ["CaseA", "CaseB", "CaseC"] + +# Construction via factory methods +def MyUnion_CaseA(item: int) -> MyUnion: + return MyUnion(0, item) + +def MyUnion_CaseB(item: str) -> MyUnion: + return MyUnion(1, item) + +def MyUnion_CaseC(x: float, y: float) -> MyUnion: + return MyUnion(2, x, y) +``` + +### Problems with Original Design + +| Issue | Description | +| ---------------------------- | -------------------------------------------------------------------------------------- | +| **No type discrimination** | All cases have the same type `MyUnion`, so `isinstance(x, MyUnion_CaseA)` doesn't work | +| **Field access via index** | Must use `u.fields[0]` instead of `u.item` or `u.x` | +| **Poor pattern matching** | Python's `match` statement can't distinguish cases by type | +| **No IDE support** | Type checkers can't infer field types or provide autocomplete | +| **No `@dataclass` benefits** | No automatic `__eq__`, `__repr__`, `__hash__`, `__match_args__` | + +### New Design Benefits + +| Aspect | Original | New | +| ---------------- | --------------------------------- | ---------------------------------------- | +| Case types | Single class, all cases same type | Separate class per case | +| Type checking | `isinstance(u, MyUnion)` only | `isinstance(u, MyUnion_CaseA)` works | +| Field access | `u.fields[0]`, `u.fields[1]` | `u.item`, `u.x`, `u.y` | +| Pattern matching | Match on `.tag` only | Match on case type with field extraction | +| IDE support | Minimal | Full autocomplete and type inference | +| Type annotations | `MyUnion` (opaque) | `MyUnion` (union of case types) | + +### Pattern Matching Comparison + +```python +# Original - must match on tag +match u.tag: + case 0: print(f"CaseA: {u.fields[0]}") + case 1: print(f"CaseB: {u.fields[0]}") + case 2: print(f"CaseC: {u.fields[0]}, {u.fields[1]}") + +# New - match on type with field extraction +match u: + case MyUnion_CaseA(item=i): print(f"CaseA: {i}") + case MyUnion_CaseB(item=s): print(f"CaseB: {s}") + case MyUnion_CaseC(x=x, y=y): print(f"CaseC: {x}, {y}") +``` + +### Backwards Compatibility + +The new design maintains backwards compatibility: + +- `u.tag` still works (set by `@tagged_union` decorator) +- `u.fields` still works (generated property returning `Array[Any]`) +- `MyUnion.cases()` still works (on base class `_MyUnion`) +- `isinstance(u, Union)` still works (case classes inherit from `Union` via base class) + +### Type Annotation Improvement + +```python +# Original - return type is opaque +def process(value: MyUnion) -> str: ... # No way to know what cases exist + +# New - type alias shows all cases +type MyUnion = MyUnion_CaseA | MyUnion_CaseB | MyUnion_CaseC +def process(value: MyUnion) -> str: ... # IDE shows the union of cases +``` + +## Verification + +```bash +# Generate output +./build.sh quicktest python + +# Check generated code +cat src/quicktest-py/quicktest.py + +# Run tests +./build.sh test python +``` diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index c112a2436..6251895ac 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -817,9 +817,12 @@ let tryEntityIdent (com: Compiler) entFullName = | BuiltinDefinition BclDateTimeOffset -> makeIdentExpr "Date" |> Some | BuiltinDefinition BclTimer -> makeImportLib com Any "default" "Timer" |> Some | BuiltinDefinition(FSharpReference _) -> makeImportLib com Any "FSharpRef" "Types" |> Some - | BuiltinDefinition(FSharpResult _) -> makeImportLib com Any "FSharpResult_2" "Result" |> Some + | BuiltinDefinition(FSharpResult _) -> + // Import the underscore-prefixed base class (has cases() method), not the type alias + makeImportLib com Any "_FSharpResult_2" "Result" |> Some | BuiltinDefinition(FSharpChoice genArgs) -> - let membName = $"FSharpChoice_{List.length genArgs}" + // Import the underscore-prefixed base class (has cases() method), not the type alias + let membName = $"_FSharpChoice_%d{List.length genArgs}" makeImportLib com Any membName "Choice" |> Some // | BuiltinDefinition BclGuid -> jsTypeof "string" expr // | BuiltinDefinition BclTimeSpan -> jsTypeof "number" expr @@ -840,7 +843,13 @@ let tryConstructor com (ent: Entity) = if FSharp2Fable.Util.isReplacementCandidate ent.Ref then tryEntityIdent com (ent.FullName |> Naming.toPythonNaming) else - FSharp2Fable.Util.tryEntityIdentMaybeGlobalOrImported com ent + match FSharp2Fable.Util.tryEntityIdentMaybeGlobalOrImported com ent with + | Some(IdentExpr ident) when ent.IsFSharpUnion -> + // For F# union types, the base class is prefixed with underscore (_UnionName) + // This is needed for both reflection (base class has cases() method) and + // type annotations (self inside base class methods) + Some(IdentExpr { ident with Name = "_" + ident.Name }) + | other -> other let constructor com ent = match tryConstructor com ent with diff --git a/src/fable-library-py/fable_library/choice.pyi b/src/fable-library-py/fable_library/choice.pyi index 10e26eec3..91c5024a8 100644 --- a/src/fable-library-py/fable_library/choice.pyi +++ b/src/fable-library-py/fable_library/choice.pyi @@ -1,71 +1,249 @@ from collections.abc import Callable -from typing import Any, overload +from typing import Any +from .option import Option from .reflection import TypeInfo from .union import Union +# FSharpChoice_2 class FSharpChoice_2[T1, T2](Union): - def __init__(self, tag: int, *fields: Any) -> None: ... @staticmethod def cases() -> list[str]: ... +class Choice1Of2[T1, T2](FSharpChoice_2[T1, T2]): + item: T1 + def __init__(self, item: T1) -> None: ... + +class Choice2Of2[T1, T2](FSharpChoice_2[T1, T2]): + item: T2 + def __init__(self, item: T2) -> None: ... + +type FSharpChoice_2_[T1, T2] = Choice1Of2[T1, T2] | Choice2Of2[T1, T2] + FSharpChoice_2_reflection: Callable[[TypeInfo, TypeInfo], TypeInfo] +# FSharpChoice_3 class FSharpChoice_3[T1, T2, T3](Union): - def __init__(self, tag: int, *fields: Any) -> None: ... @staticmethod def cases() -> list[str]: ... +class Choice1Of3[T1, T2, T3](FSharpChoice_3[T1, T2, T3]): + item: T1 + def __init__(self, item: T1) -> None: ... + +class Choice2Of3[T1, T2, T3](FSharpChoice_3[T1, T2, T3]): + item: T2 + def __init__(self, item: T2) -> None: ... + +class Choice3Of3[T1, T2, T3](FSharpChoice_3[T1, T2, T3]): + item: T3 + def __init__(self, item: T3) -> None: ... + +type FSharpChoice_3_[T1, T2, T3] = Choice1Of3[T1, T2, T3] | Choice2Of3[T1, T2, T3] | Choice3Of3[T1, T2, T3] + FSharpChoice_3_reflection: Callable[[TypeInfo, TypeInfo, TypeInfo], TypeInfo] +# FSharpChoice_4 class FSharpChoice_4[T1, T2, T3, T4](Union): - def __init__(self, tag: int, *fields: Any) -> None: ... @staticmethod def cases() -> list[str]: ... +class Choice1Of4[T1, T2, T3, T4](FSharpChoice_4[T1, T2, T3, T4]): + item: T1 + def __init__(self, item: T1) -> None: ... + +class Choice2Of4[T1, T2, T3, T4](FSharpChoice_4[T1, T2, T3, T4]): + item: T2 + def __init__(self, item: T2) -> None: ... + +class Choice3Of4[T1, T2, T3, T4](FSharpChoice_4[T1, T2, T3, T4]): + item: T3 + def __init__(self, item: T3) -> None: ... + +class Choice4Of4[T1, T2, T3, T4](FSharpChoice_4[T1, T2, T3, T4]): + item: T4 + def __init__(self, item: T4) -> None: ... + +type FSharpChoice_4_[T1, T2, T3, T4] = ( + Choice1Of4[T1, T2, T3, T4] | Choice2Of4[T1, T2, T3, T4] | Choice3Of4[T1, T2, T3, T4] | Choice4Of4[T1, T2, T3, T4] +) + FSharpChoice_4_reflection: Callable[[TypeInfo, TypeInfo, TypeInfo, TypeInfo], TypeInfo] +# FSharpChoice_5 class FSharpChoice_5[T1, T2, T3, T4, T5](Union): - def __init__(self, tag: int, *fields: Any) -> None: ... @staticmethod def cases() -> list[str]: ... +class Choice1Of5[T1, T2, T3, T4, T5](FSharpChoice_5[T1, T2, T3, T4, T5]): + item: T1 + def __init__(self, item: T1) -> None: ... + +class Choice2Of5[T1, T2, T3, T4, T5](FSharpChoice_5[T1, T2, T3, T4, T5]): + item: T2 + def __init__(self, item: T2) -> None: ... + +class Choice3Of5[T1, T2, T3, T4, T5](FSharpChoice_5[T1, T2, T3, T4, T5]): + item: T3 + def __init__(self, item: T3) -> None: ... + +class Choice4Of5[T1, T2, T3, T4, T5](FSharpChoice_5[T1, T2, T3, T4, T5]): + item: T4 + def __init__(self, item: T4) -> None: ... + +class Choice5Of5[T1, T2, T3, T4, T5](FSharpChoice_5[T1, T2, T3, T4, T5]): + item: T5 + def __init__(self, item: T5) -> None: ... + +type FSharpChoice_5_[T1, T2, T3, T4, T5] = ( + Choice1Of5[T1, T2, T3, T4, T5] + | Choice2Of5[T1, T2, T3, T4, T5] + | Choice3Of5[T1, T2, T3, T4, T5] + | Choice4Of5[T1, T2, T3, T4, T5] + | Choice5Of5[T1, T2, T3, T4, T5] +) + FSharpChoice_5_reflection: Callable[[TypeInfo, TypeInfo, TypeInfo, TypeInfo, TypeInfo], TypeInfo] -class FSharpChoice_6[T1, T2, T3, T4, T5, T6](Union): - def __init__(self, tag: int, *fields: Any) -> None: ... +# FSharpChoice_6 +class _FSharpChoice_6[T1, T2, T3, T4, T5, T6](Union): @staticmethod def cases() -> list[str]: ... +class Choice1Of6[T1, T2, T3, T4, T5, T6](_FSharpChoice_6[T1, T2, T3, T4, T5, T6]): + item: T1 + def __init__(self, item: T1) -> None: ... + +class Choice2Of6[T1, T2, T3, T4, T5, T6](_FSharpChoice_6[T1, T2, T3, T4, T5, T6]): + item: T2 + def __init__(self, item: T2) -> None: ... + +class Choice3Of6[T1, T2, T3, T4, T5, T6](_FSharpChoice_6[T1, T2, T3, T4, T5, T6]): + item: T3 + def __init__(self, item: T3) -> None: ... + +class Choice4Of6[T1, T2, T3, T4, T5, T6](_FSharpChoice_6[T1, T2, T3, T4, T5, T6]): + item: T4 + def __init__(self, item: T4) -> None: ... + +class Choice5Of6[T1, T2, T3, T4, T5, T6](_FSharpChoice_6[T1, T2, T3, T4, T5, T6]): + item: T5 + def __init__(self, item: T5) -> None: ... + +class Choice6Of6[T1, T2, T3, T4, T5, T6](_FSharpChoice_6[T1, T2, T3, T4, T5, T6]): + item: T6 + def __init__(self, item: T6) -> None: ... + +type FSharpChoice_6[T1, T2, T3, T4, T5, T6] = ( + Choice1Of6[T1, T2, T3, T4, T5, T6] + | Choice2Of6[T1, T2, T3, T4, T5, T6] + | Choice3Of6[T1, T2, T3, T4, T5, T6] + | Choice4Of6[T1, T2, T3, T4, T5, T6] + | Choice5Of6[T1, T2, T3, T4, T5, T6] + | Choice6Of6[T1, T2, T3, T4, T5, T6] +) + FSharpChoice_6_reflection: Callable[[TypeInfo, TypeInfo, TypeInfo, TypeInfo, TypeInfo, TypeInfo], TypeInfo] -class FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7](Union): - def __init__(self, tag: int, *fields: Any) -> None: ... +# FSharpChoice_7 +class _FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7](Union): @staticmethod def cases() -> list[str]: ... +class Choice1Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T1 + def __init__(self, item: T1) -> None: ... + +class Choice2Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T2 + def __init__(self, item: T2) -> None: ... + +class Choice3Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T3 + def __init__(self, item: T3) -> None: ... + +class Choice4Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T4 + def __init__(self, item: T4) -> None: ... + +class Choice5Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T5 + def __init__(self, item: T5) -> None: ... + +class Choice6Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T6 + def __init__(self, item: T6) -> None: ... + +class Choice7Of7[T1, T2, T3, T4, T5, T6, T7](_FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7]): + item: T7 + def __init__(self, item: T7) -> None: ... + +type FSharpChoice_7[T1, T2, T3, T4, T5, T6, T7] = ( + Choice1Of7[T1, T2, T3, T4, T5, T6, T7] + | Choice2Of7[T1, T2, T3, T4, T5, T6, T7] + | Choice3Of7[T1, T2, T3, T4, T5, T6, T7] + | Choice4Of7[T1, T2, T3, T4, T5, T6, T7] + | Choice5Of7[T1, T2, T3, T4, T5, T6, T7] + | Choice6Of7[T1, T2, T3, T4, T5, T6, T7] + | Choice7Of7[T1, T2, T3, T4, T5, T6, T7] +) + FSharpChoice_7_reflection: Callable[[TypeInfo, TypeInfo, TypeInfo, TypeInfo, TypeInfo, TypeInfo, TypeInfo], TypeInfo] -@overload -def Choice_makeChoice1Of2() -> FSharpChoice_2[None, Any]: ... -@overload -def Choice_makeChoice1Of2[T1](x: T1) -> FSharpChoice_2[T1, Any]: ... -@overload -def Choice_makeChoice2Of2() -> FSharpChoice_2[Any, None]: ... -@overload -def Choice_makeChoice2Of2[T2](x: T2) -> FSharpChoice_2[Any, T2]: ... -def Choice_tryValueIfChoice1Of2[T1](x: FSharpChoice_2[T1, Any]) -> T1 | None: ... -def Choice_tryValueIfChoice2Of2[T2](x: FSharpChoice_2[Any, T2]) -> T2 | None: ... +# Helper functions +def Choice_makeChoice1Of2[T1](x: T1 = ...) -> FSharpChoice_2[T1, Any]: ... +def Choice_makeChoice2Of2[T2](x: T2 = ...) -> FSharpChoice_2[Any, T2]: ... +def Choice_tryValueIfChoice1Of2[T1](x: FSharpChoice_2[T1, Any]) -> Option[T1]: ... +def Choice_tryValueIfChoice2Of2[T2](x: FSharpChoice_2[Any, T2]) -> Option[T2]: ... __all__ = [ + "Choice1Of2", + "Choice1Of3", + "Choice1Of4", + "Choice1Of5", + "Choice1Of6", + "Choice1Of7", + "Choice2Of2", + "Choice2Of3", + "Choice2Of4", + "Choice2Of5", + "Choice2Of6", + "Choice2Of7", + "Choice3Of3", + "Choice3Of4", + "Choice3Of5", + "Choice3Of6", + "Choice3Of7", + "Choice4Of4", + "Choice4Of5", + "Choice4Of6", + "Choice4Of7", + "Choice5Of5", + "Choice5Of6", + "Choice5Of7", + "Choice6Of6", + "Choice6Of7", + "Choice7Of7", "Choice_makeChoice1Of2", "Choice_makeChoice2Of2", "Choice_tryValueIfChoice1Of2", "Choice_tryValueIfChoice2Of2", + "FSharpChoice_2", + "FSharpChoice_2_", "FSharpChoice_2_reflection", + "FSharpChoice_3", + "FSharpChoice_3_", "FSharpChoice_3_reflection", + "FSharpChoice_4", + "FSharpChoice_4_", "FSharpChoice_4_reflection", + "FSharpChoice_5", + "FSharpChoice_5_", "FSharpChoice_5_reflection", + "FSharpChoice_6", + "FSharpChoice_6_", "FSharpChoice_6_reflection", + "FSharpChoice_7", + "FSharpChoice_7_", "FSharpChoice_7_reflection", ] diff --git a/src/fable-library-py/fable_library/reflection.py b/src/fable-library-py/fable_library/reflection.py index 0f0165ca2..73317a5d4 100644 --- a/src/fable-library-py/fable_library/reflection.py +++ b/src/fable-library-py/fable_library/reflection.py @@ -28,6 +28,7 @@ class CaseInfo: tag: int name: str fields: list[FieldInfo] + case_constructor: type[Any] | None = None @dataclass @@ -73,12 +74,14 @@ def union_type( generics: Array[TypeInfo], construct: type[FsUnion], cases: Callable[[], list[list[FieldInfo]]], + case_constructors: list[type[Any]] | None = None, ) -> TypeInfo: def fn() -> list[CaseInfo]: caseNames: list[str] = construct.cases() def mapper(i: int, fields: list[FieldInfo]) -> CaseInfo: - return CaseInfo(t, i, caseNames[i], fields) + case_ctor = case_constructors[i] if case_constructors else None + return CaseInfo(t, i, caseNames[i], fields, case_ctor) return [mapper(i, x) for i, x in enumerate(cases())] @@ -424,6 +427,11 @@ def make_union(uci: CaseInfo, values: Array[Any]) -> Any: if len(values) != expectedLength: raise ValueError(f"Expected an array of length {expectedLength} but got {len(values)}") + # Use case constructor if available (new tagged_union pattern) + if uci.case_constructor is not None: + return uci.case_constructor(*values) + + # Fallback to old pattern via base class construct return uci.declaringType.construct(uci.tag, *values) if uci.declaringType.construct else {} diff --git a/src/fable-library-py/fable_library/union.py b/src/fable-library-py/fable_library/union.py index bd76916e8..192f26458 100644 --- a/src/fable-library-py/fable_library/union.py +++ b/src/fable-library-py/fable_library/union.py @@ -3,7 +3,9 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any +from dataclasses import dataclass +from dataclasses import fields as dataclass_fields +from typing import Any, dataclass_transform from .array_ import Array from .bases import ComparableBase, EquatableBase, HashableBase, StringableBase @@ -99,6 +101,38 @@ def CompareTo(self, other: Any) -> int: return -1 if self.tag < other.tag else 1 -__all__ = [ - "Union", -] +@dataclass_transform() +def tagged_union(tag: int): + """Decorator for union case classes. + + Uses @dataclass_transform() so type checkers understand: + - Field annotations become constructor parameters + - __match_args__ is generated + - __eq__, __repr__, __hash__ are generated + + Additionally sets: + - cls.tag = tag (numeric case discriminator) + - cls.fields property (list of field values for backwards compat) + """ + + def decorator[T](cls: type[T]) -> type[T]: + # Apply dataclass internally + dc_cls: Any = dataclass(cls) + + # Set the tag + dc_cls.tag = tag + + # Generate fields property from dataclass fields + field_names = [f.name for f in dataclass_fields(dc_cls)] + + @property + def fields(self) -> Array[Any]: + return Array[Any]([getattr(self, name) for name in field_names]) + + dc_cls.fields = fields + return dc_cls + + return decorator + + +__all__ = ["Union", "tagged_union"] diff --git a/uv.lock b/uv.lock index 2cf532f90..443425af9 100644 --- a/uv.lock +++ b/uv.lock @@ -70,7 +70,7 @@ dev = [ { name = "dunamai", specifier = ">=1.23.1,<2" }, { name = "maturin", specifier = ">=1.8.3,<2" }, { name = "pydantic", specifier = ">=2.11.7" }, - { name = "pyright", specifier = ">=1.1.407" }, + { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=8.3.5,<9" }, { name = "ruff", specifier = ">=0.11.6,<0.12" }, { name = "ty", specifier = ">=0.0.7" }, @@ -226,15 +226,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.407" +version = "1.1.408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] [[package]]