diff --git a/src/Fable.Build/FableLibrary/Python.fs b/src/Fable.Build/FableLibrary/Python.fs index e36f528206..86023f3003 100644 --- a/src/Fable.Build/FableLibrary/Python.fs +++ b/src/Fable.Build/FableLibrary/Python.fs @@ -16,6 +16,8 @@ type BuildFableLibraryPython(?skipCore: bool, ?postFableBuildStage: unit -> unit inputPatterns = [ Path.Combine("src", "fable-library-py", "**", "*.py") + // Type stubs, so edits to them invalidate the cached build + Path.Combine("src", "fable-library-py", "**", "*.pyi") Path.Combine("src", "fable-library-py", "**", "*.fs") // Rust extension sources, so edits to them trigger a maturin rebuild Path.Combine("src", "fable-library-py", "**", "*.rs") @@ -29,6 +31,8 @@ type BuildFableLibraryPython(?skipCore: bool, ?postFableBuildStage: unit -> unit override this.CopyStage() = // Copy all Python/F# files to the build directory Directory.GetFiles(this.LibraryDir, "*") |> Shell.copyFiles this.BuildDir + // Note: top-level *.pyi are deliberately not copied. They are stubs for the + // modules generated from F# (list, choice), and would shadow the real ones. Directory.GetFiles(this.SourceDir, "*.py") |> Shell.copyFiles this.OutDir // Python extension modules diff --git a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs index d8d4de8b50..9391ca8c4c 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs @@ -520,7 +520,8 @@ let makeNumberTypeAnnotation com ctx kind info = | UInt32 -> "uint32" | Int64 -> "int64" | UInt64 -> "uint64" - | Int32 -> "int32" + // Int32 is represented as a plain Python `int` + | Int32 | BigInt | Int128 | UInt128 @@ -528,7 +529,8 @@ let makeNumberTypeAnnotation com ctx kind info = | UNativeInt -> "int" | Float16 | Float32 -> "float32" - | Float64 -> "float64" + // Float64 is a plain Python `float` + | Float64 -> "float" | _ -> failwith $"Unsupported number type: %A{kind}" match name with @@ -786,6 +788,14 @@ let makeBuiltinTypeAnnotation com ctx typ repeatedGenerics kind = // Use the type alias (clean name without underscore prefix) let name = $"FSharpChoice_%d{List.length genArgs}" fableModuleAnnotation com ctx "choice" name resolved, stmts + | Replacements.Util.FSharpSet key -> + // `FSharpSet` is invariant in its element type, so a bare `Any` here leaves + // Pyright to solve the element type from the arguments alone -- and a call + // like `singleton(1, cmp)` then solves it to `Literal[1]`, which will not + // unify with the `Literal[2]` of a sibling set. Annotating the binding gives + // bidirectional inference something to push down into those calls. + let resolved, stmts = resolveGenerics com ctx [ key ] repeatedGenerics + fableModuleAnnotation com ctx "set" "FSharpSet" resolved, stmts | _ -> stdlibModuleTypeHint com ctx "typing" "Any" [] repeatedGenerics let makeArgTypeAnnotation com ctx repeatedGenerics (id: Fable.Ident) = diff --git a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs index 6a3fb2dd12..4d8d0a6254 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs @@ -368,14 +368,18 @@ let transformTypeTest (com: IPythonCompiler) ctx range expr (typ: Fable.Type) : | _, Fable.Type.Number(Int8, _) -> pyInstanceof (libValue com ctx "core" "int8") expr | _, Fable.Type.Number(Int16, _) -> pyInstanceof (libValue com ctx "core" "int16") expr | _, Fable.Type.Number(UInt16, _) -> pyInstanceof (libValue com ctx "core" "uint16") expr - | _, Fable.Type.Number(Int32, _) -> pyInstanceof (libValue com ctx "core" "int32") expr + // Int32 is a plain Python `int`. Exact-type matching is required: `bool` + // subclasses `int`, so `isinstance` would report `box true :? int` as true. + | _, Fable.Type.Number(Int32, _) -> pyTypeof "" expr | _, Fable.Type.Number(UInt32, _) -> pyInstanceof (libValue com ctx "core" "uint32") expr | _, Fable.Type.Number(NativeInt, _) | _, Fable.Type.Number(UNativeInt, _) -> pyInstanceof (Expression.name "int") expr | _, Fable.Type.Number(Int64, _) -> pyInstanceof (libValue com ctx "core" "int64") expr | _, Fable.Type.Number(UInt64, _) -> pyInstanceof (libValue com ctx "core" "uint64") expr | _, Fable.Type.Number(Float32, _) -> pyInstanceof (libValue com ctx "core" "float32") expr - | _, Fable.Type.Number(Float64, _) -> pyInstanceof (libValue com ctx "core" "float64") expr + // Float64 is a plain Python `float`. Exact-type matching keeps it distinct + // from the Float32 wrapper, which is not a `float` subclass. + | _, Fable.Type.Number(Float64, _) -> pyTypeof "" expr | _, Fable.Type.Number(Decimal, _) -> pyTypeof "" expr | _ -> pyInstanceof (Expression.name "int") expr diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 42fee25491..74e8955e47 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -424,14 +424,31 @@ let transformCast (com: IPythonCompiler) (ctx: Context) t e : Expression * State let cons = libValue com ctx "core" "float32" let value, stmts = com.TransformAsExpr(ctx, e) Expression.call (cons, [ value ], ?loc = None), stmts - | Fable.Number(Float64, _), _ -> - let cons = libValue com ctx "core" "float64" - let value, stmts = com.TransformAsExpr(ctx, e) - Expression.call (cons, [ value ], ?loc = None), stmts + // Int32 is a plain Python `int`, so casting to it is a no-op -- unless the source + // is one of the wrapper widths. `Replacements.toInt` emits a bare `TypeCast` for + // every widening conversion (`needToCast` is false), so this is where e.g. + // `int (x: sbyte)` sheds its `Int8`. Leaving the wrapper in place would keep the + // arithmetic at the source width: `Int8(100) + 100` wraps to -56. | Fable.Number(Int32, _), _ -> - let cons = libValue com ctx "core" "int32" - let value, stmts = com.TransformAsExpr(ctx, e) - Expression.call (cons, [ value ], ?loc = None), stmts + match e.Type with + // Already a plain `int` at runtime + | Fable.Number((Int32 | BigInt | NativeInt | UNativeInt), _) -> com.TransformAsExpr(ctx, e) + | Fable.Number _ -> + let value, stmts = com.TransformAsExpr(ctx, e) + libCall com ctx None "core" "int32" [ value ], stmts + | _ -> com.TransformAsExpr(ctx, e) + // Float64 is a plain Python `float`, and the same reasoning applies: + // `Replacements.toFloat` emits a bare `TypeCast` for every numeric source that is + // not bigint/decimal/int64, so `float (x: float32)` would otherwise stay a + // `Float32` and keep computing in single precision. Every wrapper implements + // `__float__`, so the builtin is enough and stays a single C-level call. + | Fable.Number(Float64, _), _ -> + match e.Type with + | Fable.Number(Float64, _) -> com.TransformAsExpr(ctx, e) + | Fable.Number _ -> + let value, stmts = com.TransformAsExpr(ctx, e) + Expression.call (Expression.name "float", [ value ], ?loc = None), stmts + | _ -> com.TransformAsExpr(ctx, e) | _ -> com.TransformAsExpr(ctx, e) let transformCurry (com: IPythonCompiler) (ctx: Context) expr arity : Expression * Statement list = @@ -467,6 +484,10 @@ let transformValue (com: IPythonCompiler) (ctx: Context) r value : Expression * let value = match value.Type with | Fable.String -> value + // Float64 is a plain Python `float`, whose `str` renders a whole + // double as "5.0" where .NET renders "5". + | Fable.Number(Float64, _) -> + Replacements.Util.Helper.LibCall(com, "exceptions", "to_string", Fable.String, [ value ]) | _ -> Helpers.toString value let acc = makeBinOp None Fable.String acc value BinaryPlus @@ -480,7 +501,8 @@ let transformValue (com: IPythonCompiler) (ctx: Context) r value : Expression * | Fable.NumberValue.UInt8 x -> makeInteger com ctx r value.Type "uint8" x | Fable.NumberValue.Int16 x -> makeInteger com ctx r value.Type "int16" x | Fable.NumberValue.UInt16 x -> makeInteger com ctx r value.Type "uint16" x - | Fable.NumberValue.Int32 x -> makeInteger com ctx r value.Type "int32" x + // Int32 is represented as a plain Python `int`, so a literal is just a literal + | Fable.NumberValue.Int32 x -> Expression.intConstant (x, ?loc = r), [] | Fable.NumberValue.UInt32 x -> makeInteger com ctx r value.Type "uint32" x | Fable.NumberValue.Int64 x -> makeInteger com ctx r value.Type "int64" x | Fable.NumberValue.UInt64 x -> makeInteger com ctx r value.Type "uint64" x @@ -490,16 +512,17 @@ let transformValue (com: IPythonCompiler) (ctx: Context) r value : Expression * | Fable.NumberValue.NativeInt x -> Expression.intConstant (x, ?loc = r), [] | Fable.NumberValue.UNativeInt x -> Expression.intConstant (x, ?loc = r), [] // TODO: special consts also need attention - | Fable.NumberValue.Float64 x when x = infinity -> libValue com ctx "double" "float64.infinity", [] - | Fable.NumberValue.Float64 x when x = -infinity -> libValue com ctx "double" "float64.negative_infinity", [] - | Fable.NumberValue.Float64 x when Double.IsNaN(x) -> libValue com ctx "double" "float64.nan", [] + | Fable.NumberValue.Float64 x when x = infinity -> com.GetImportExpr(ctx, "math", "inf"), [] + | Fable.NumberValue.Float64 x when x = -infinity -> + Expression.unaryOp (UnaryMinus, com.GetImportExpr(ctx, "math", "inf")), [] + | Fable.NumberValue.Float64 x when Double.IsNaN(x) -> com.GetImportExpr(ctx, "math", "nan"), [] | Fable.NumberValue.Float32 x when Single.IsNaN(x) -> libCall com ctx r "core" "float32" [ Expression.stringConstant "nan" ], [] | Fable.NumberValue.Float16 x when Single.IsNaN(x) -> libCall com ctx r "core" "float32" [ Expression.stringConstant "nan" ], [] | Fable.NumberValue.Float16 x -> makeFloat com ctx r value.Type "float32" (float x) | Fable.NumberValue.Float32 x -> makeFloat com ctx r value.Type "float32" (float x) - | Fable.NumberValue.Float64 x -> makeFloat com ctx r value.Type "float64" (float x) + | Fable.NumberValue.Float64 x -> Expression.floatConstant (float x, ?loc = r), [] | Fable.NumberValue.Decimal x -> Py.Replacements.makeDecimal com r value.Type x |> transformAsExpr com ctx | _ -> addErrorAndReturnNull com r $"Numeric literal is not supported: %A{v}", [] | Fable.NewArray(newKind, typ, kind) -> @@ -943,15 +966,114 @@ let resolveExpr (ctx: Context) _t strategy pyExpr : Statement list = | Some(Assign left) -> exprAsStatement ctx (assign None left pyExpr) | Some(Target left) -> exprAsStatement ctx (assign None (left |> Expression.identifier) pyExpr) -let transformOperation com ctx range opKind tags : Expression * Statement list = +let transformOperation (com: IPythonCompiler) ctx range (t: Fable.Type) opKind tags : Expression * Statement list = + // Int32 is a plain Python `int`, so results that can leave the 32-bit range are + // normalized back into it. See `Util.isInt32WrapOp` for why one wrap per + // expression tree is equivalent to one per operation. + let isInt32 = + match t with + | Fable.Number(Int32, _) -> true + | _ -> false + + // Float64 is a plain Python `float`, which IS an IEEE double, so arithmetic needs + // no adjustment. Only division by zero and remainder diverge. + let isFloat64 = + match t with + | Fable.Number(Float64, _) -> true + | _ -> false + + /// A literal divisor that is known not to be zero lets `/` stay a bare operator. + let isNonZeroLiteral (e: Fable.Expr) = + match e with + | Fable.Value(Fable.NumberConstant(v, _), _) -> + match v with + | Fable.NumberValue.Float64 x -> x <> 0.0 && not (Double.IsNaN x) + | Fable.NumberValue.Int32 x -> x <> 0 + | _ -> false + | _ -> false + match opKind with - | Fable.Unary(op, TransformExpr com ctx (expr, stmts)) -> Expression.unaryOp (op, expr, ?loc = range), stmts + | Fable.Unary(op, (operand: Fable.Expr)) -> + let expr, stmts = com.TransformAsExpr(ctx, operand) + + // Only negation can leave the range, at Int32.MinValue. `~` maps in-range to + // in-range, and `+` is the identity. + let needsWrap = isInt32 && op = UnaryMinus + + let expr = + if needsWrap then + stripInt32Wrap com ctx expr + else + expr + + let result = Expression.unaryOp (op, expr, ?loc = range) + + (if needsWrap then + wrapInt32 com ctx range result + else + result), + stmts | Fable.Binary(op, left, right: Fable.Expr) -> let typ = right.Type let left_typ = left.Type + let leftFable, rightFable = left, right + + // `<<` shifts the left operand only; the shift count is a separate value. + let wrapsResult = + isInt32 + && match op with + | BinaryPlus + | BinaryMinus + | BinaryMultiply + | BinaryShiftLeft -> true + | _ -> false + let left, stmts = com.TransformAsExpr(ctx, left) let right, stmts' = com.TransformAsExpr(ctx, right) + // Strip an operand's own wrap only when this node re-applies one, and only + // when the *Fable* operand is one of the nodes this module wraps. + let left = + if wrapsResult && isInt32WrapOp leftFable then + stripInt32Wrap com ctx left + else + left + + let right = + if wrapsResult && op <> BinaryShiftLeft && isInt32WrapOp rightFable then + stripInt32Wrap com ctx right + else + right + + // .NET masks a shift count to the width of the type, so `1 <<< 32` is 1. + // Python applies the count as given, and raises ValueError if it is negative. + let right = + let isShift = + match op with + | BinaryShiftLeft + | BinaryShiftRightSignPropagating + | BinaryShiftRightZeroFill -> true + | _ -> false + + if not (isInt32 && isShift) then + right + else + match right with + | Expression.Constant(value = IntLiteral v) -> + match v with + | :? int as i -> Expression.intConstant (i &&& 31) + | _ -> Expression.binOp (right, BitAnd, Expression.intConstant 31) + | _ -> Expression.binOp (right, BitAnd, Expression.intConstant 31) + + let binOp (op: BinaryOperator) = + let result = Expression.binOp (left, op, right, ?loc = range) + + (if wrapsResult then + wrapInt32 com ctx range result + else + result), + stmts @ stmts' + let compare op = Expression.compare (left, [ op ], [ right ], ?loc = range), stmts @ stmts' @@ -1005,6 +1127,17 @@ let transformOperation com ctx range opKind tags : Expression * Statement list = | BinaryDivide, _ -> // For integer division, we need to use the // operator match typ with + // Int32 is a plain Python `int`, whose `//` floors where .NET truncates + // toward zero: `-7 // 2` is -4 in Python and -3 in .NET. `int(a / b)` + // truncates, and is exact here -- for int32 operands the true quotient is + // never within a rounding error of an integer it should not reach. + | Fable.Number(Int32, _) when isInt32 -> + Expression.call ( + Expression.name "int", + [ Expression.binOp (left, Div, right, ?loc = range) ], + ?loc = range + ), + stmts @ stmts' | Fable.Number(Int8, _) | Fable.Number(Int16, _) | Fable.Number(Int32, _) @@ -1018,8 +1151,19 @@ let transformOperation com ctx range opKind tags : Expression * Statement list = | Fable.Number(Float32, _) | Fable.Number(Float64, _) -> Expression.binOp (left, Div, right, ?loc = range), stmts @ stmts' | _ -> Expression.binOp (left, FloorDiv, right, ?loc = range), stmts @ stmts' + // Python raises ZeroDivisionError where .NET yields +/-inf or nan + | Fable.Number(Float64, _) when isFloat64 && not (isNonZeroLiteral rightFable) -> + libCall com ctx range "core" "op_division_float64" [ left; right ], stmts @ stmts' | _ -> Expression.binOp (left, op, right, ?loc = range), stmts @ stmts' - | _ -> Expression.binOp (left, op, right, ?loc = range), stmts @ stmts' + | BinaryModulus, _ when isFloat64 -> + // Python's float `%` takes the sign of the divisor, .NET's takes the sign + // of the dividend, and Python raises on a zero divisor where .NET gives nan. + libCall com ctx range "core" "op_remainder_float64" [ left; right ], stmts @ stmts' + | BinaryModulus, _ when isInt32 -> + // Python's `%` takes the sign of the divisor, .NET's takes the sign of the + // dividend: `-5 % 3` is 1 in Python and -2 in .NET. + libCall com ctx range "core" "op_remainder_int32" [ left; right ], stmts @ stmts' + | _ -> binOp op | Fable.Logical(op, TransformExpr com ctx (left, stmts), TransformExpr com ctx (right, stmts')) -> Expression.boolOp (op, [ left; right ], ?loc = range), stmts @ stmts' @@ -2591,7 +2735,7 @@ let rec transformAsExpr (com: IPythonCompiler) ctx (expr: Fable.Expr) : Expressi | Fable.CurriedApply(callee, args, _, range) -> transformCurriedApply com ctx range callee args - | Fable.Operation(kind, tags, _, range) -> transformOperation com ctx range kind tags + | Fable.Operation(kind, tags, t, range) -> transformOperation com ctx range t kind tags | Fable.Get(expr, kind, typ, range) -> transformGet com ctx range typ expr kind @@ -2822,7 +2966,7 @@ let rec transformAsStatements (com: IPythonCompiler) ctx returnStrategy (expr: F stmts @ resolveExpr ctx t returnStrategy e | Fable.Operation(kind, tags, t, range) -> - let expr, stmts = transformOperation com ctx range kind tags + let expr, stmts = transformOperation com ctx range t kind tags stmts @ (expr |> resolveExpr ctx t returnStrategy) | Fable.Get(expr, kind, t, range) -> diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index 97cc829ab2..bc59f2ec10 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -603,9 +603,7 @@ module Util = let thisExpr = Expression.name "self" - let ofInt (com: IPythonCompiler) (ctx: Context) (i: int) = - //Expression.intConstant (int i) - libCall com ctx None "core" "int32" [ Expression.intConstant (int i) ] + let ofInt (_com: IPythonCompiler) (_ctx: Context) (i: int) = Expression.intConstant (int i) let ofString (s: string) = Expression.stringConstant s @@ -658,12 +656,14 @@ module Util = | _, Fable.Type.Number(Int8, _) -> Some "sbyte" | _, Fable.Type.Number(Int16, _) -> Some "int16" | _, Fable.Type.Number(UInt16, _) -> Some "uint16" - | _, Fable.Type.Number(Int32, _) -> Some "int32" + // Int32 is a plain Python `int` + | _, Fable.Type.Number(Int32, _) -> Some "int" | _, Fable.Type.Number(UInt32, _) -> Some "uint32" | _, Fable.Type.Number(Int64, _) -> Some "int64" | _, Fable.Type.Number(UInt64, _) -> Some "uint64" | _, Fable.Type.Number(Float32, _) -> Some "float32" - | _, Fable.Type.Number(Float64, _) -> Some "float64" + // Float64 is a plain Python `float` + | _, Fable.Type.Number(Float64, _) -> Some "float" | _ -> Some "Any" // printfn "Array type: %A" array_type @@ -673,10 +673,11 @@ module Util = let array = libValue com ctx "array_" "Array" let type_obj = - if l = "Any" then - com.GetImportExpr(ctx, "typing", "Any") - else - libValue com ctx "core" l + match l with + | "Any" -> com.GetImportExpr(ctx, "typing", "Any") + | "int" + | "float" -> Expression.name l + | _ -> libValue com ctx "core" l let types_array = Expression.subscript (value = array, slice = type_obj, ctx = Load) Expression.call (types_array, [ expr ]) @@ -794,7 +795,7 @@ module Util = | UInt8 -> makeInteger com ctx None t "uint8" (0uy :> obj) |> fst | Int16 -> makeInteger com ctx None t "int16" (0s :> obj) |> fst | UInt16 -> makeInteger com ctx None t "uint16" (0us :> obj) |> fst - | Int32 -> makeInteger com ctx None t "int32" (0 :> obj) |> fst + | Int32 -> Expression.intConstant 0 | UInt32 -> makeInteger com ctx None t "uint32" (0u :> obj) |> fst | Int64 -> makeInteger com ctx None t "int64" (0L :> obj) |> fst | UInt64 -> makeInteger com ctx None t "uint64" (0UL :> obj) |> fst @@ -805,8 +806,8 @@ module Util = | UNativeInt -> Expression.intConstant 0 | Float16 -> makeFloat com ctx None t "float32" 0.0 |> fst | Float32 -> makeFloat com ctx None t "float32" 0.0 |> fst - | Float64 -> makeFloat com ctx None t "float64" 0.0 |> fst - | Decimal -> makeFloat com ctx None t "float64" 0.0 |> fst + | Float64 -> Expression.floatConstant 0.0 + | Decimal -> Expression.floatConstant 0.0 | Fable.Char -> Expression.stringConstant "\u0000" | Fable.String -> Expression.stringConstant "" | Fable.DeclaredType(entRef, _) -> @@ -929,23 +930,10 @@ module Util = let cons = libValue com ctx "core" intName let value = Expression.intConstant (x, ?loc = r) - // Added support for a few selected literals for performance reasons + // Added support for a few selected literals for performance reasons. + // There are no System.Int32 arms: Int32 is represented as a plain Python + // `int`, so its literals never reach here. match intName, x with - | _, (:? int as i) when i = 0 -> makeFieldGet cons "ZERO" - | _, (:? int as i) when i = 1 -> makeFieldGet cons "ONE" - | _, (:? int as i) when i = -1 -> makeFieldGet cons "NEG_ONE" - | _, (:? int as i) when i = 2 -> makeFieldGet cons "TWO" - | _, (:? int as i) when i = 3 -> makeFieldGet cons "THREE" - | _, (:? int as i) when i = 4 -> makeFieldGet cons "FOUR" - | _, (:? int as i) when i = 5 -> makeFieldGet cons "FIVE" - | _, (:? int as i) when i = 6 -> makeFieldGet cons "SIX" - | _, (:? int as i) when i = 7 -> makeFieldGet cons "SEVEN" - | _, (:? int as i) when i = 8 -> makeFieldGet cons "EIGHT" - | _, (:? int as i) when i = 9 -> makeFieldGet cons "NINE" - | _, (:? int as i) when i = 10 -> makeFieldGet cons "TEN" - | _, (:? int as i) when i = 16 -> makeFieldGet cons "SIXTEEN" - | _, (:? int as i) when i = 32 -> makeFieldGet cons "THIRTY_TWO" - | _, (:? int as i) when i = 64 -> makeFieldGet cons "SIXTY_FOUR" | _, (:? int8 as i) when i = 0y -> makeFieldGet cons "ZERO" | _, (:? int8 as i) when i = 1y -> makeFieldGet cons "ONE" | _, (:? int8 as i) when i = -1y -> makeFieldGet cons "NEG_ONE" @@ -1054,6 +1042,51 @@ module Util = let value = Expression.floatConstant (x, ?loc = r) Expression.call (cons, [ value ], ?loc = r), [] + // --------------------------------------------------------------------------- + // Int32 normalization + // + // Int32 is a plain Python `int`, which is arbitrary precision, so operations + // that can leave the 32-bit range are normalized back into it by the compiler. + // + // Normalization commutes with `+ - * << & | ^`, so normalizing once at the root + // of an arithmetic tree is equivalent to normalizing after every operation -- + // `int32(a * b + c)` and `int32(int32(a * b) + c)` always agree. Only `+ - * <<` + // and unary `-` can leave the range in the first place; `& | ^ ~ >> / %`, + // comparisons and literals map in-range operands to in-range results and need + // no normalization at all. + // --------------------------------------------------------------------------- + + /// Operations emitted with an `int32(...)` wrap around them, and therefore the + /// ones whose wrap a parent in the same set may strip and re-apply at its own root. + let isInt32WrapOp (e: Fable.Expr) = + match e with + | Fable.Operation(Fable.Binary((BinaryPlus | BinaryMinus | BinaryMultiply | BinaryShiftLeft), _, _), + _, + Fable.Number(Int32, _), + _) -> true + | Fable.Operation(Fable.Unary(UnaryMinus, _), _, Fable.Number(Int32, _), _) -> true + | _ -> false + + /// Removes the `int32(...)` wrap this module put around an operand. + /// + /// Only ever applied when `isInt32WrapOp` held for the operand's *Fable* node, so + /// it cannot strip a meaningful conversion: `int32(someFloat)` truncates, and + /// truncation does not commute with arithmetic. If the operand was hoisted to a + /// temporary this is a no-op and the result is one redundant -- but idempotent -- + /// wrap, never a wrong value. + let stripInt32Wrap (com: IPythonCompiler) ctx (e: Expression) = + match e with + // `libValue` registers the import as a side effect, which is why it is only + // reached once the shape is known to be a one-argument call. Registering is + // harmless in any case: every caller re-applies the wrap through `wrapInt32` + // immediately afterwards, so the import is always used. + | Expression.Call call when call.Args.Length = 1 && call.Func = libValue com ctx "core" "int32" -> + call.Args.Head + | _ -> e + + /// `int32(expr)`. + let wrapInt32 (com: IPythonCompiler) ctx r (e: Expression) = libCall com ctx r "core" "int32" [ e ] + let enumerator2iterator com ctx = let enumerator = diff --git a/src/Fable.Transforms/Python/README.md b/src/Fable.Transforms/Python/README.md index 71321c76eb..ad5657668b 100644 --- a/src/Fable.Transforms/Python/README.md +++ b/src/Fable.Transforms/Python/README.md @@ -32,24 +32,120 @@ Python source code. | `[]` | `FSharpArray` | Custom pyo3 wrapper (array.rs) | | `[]<'T>` | `list` | Python list module | +`FSharpArray` stores elements unboxed and hands them back in their Python representation, so an +`int[]` yields plain `int`s. The element type is spelled with the Python representation: +`Array[int]` and `Array[float]`, with `Array[uint8]` and friends for the wrapped widths. + ## Numerics -Most numeric types are now implemented using custom pyo3 wrapper types that maintain F#-style semantics while integrating with Python. Only `bigint` is still translated to Python's native `int` type. The wrapper types provide proper overflow behavior, type safety, and performance optimization while remaining compatible with Python code. +F#'s two default numeric types are represented natively: `int` is a plain Python `int` and `float` is a +plain Python `float`. The other widths use custom pyo3 wrapper types, which is what keeps a bare `int` +or `float` an unambiguous runtime tag for the default width. | F# | .NET | Python | Implementation | |:-----------------|:-----------|---------|---------------------------------| | bool | Boolean | bool | Native Python type | -| int | Int32 | Int32 | Custom pyo3 wrapper (ints.rs) | +| int | Int32 | int | Native Python type | +| float / double | Double | float | Native Python type | +| bigint | BigInteger | int | Native Python type | | byte | Byte | UInt8 | Custom pyo3 wrapper (ints.rs) | | sbyte | SByte | Int8 | Custom pyo3 wrapper (ints.rs) | | int16 | Int16 | Int16 | Custom pyo3 wrapper (ints.rs) | -| int64 | Int64 | Int64 | Custom pyo3 wrapper (ints.rs) | | uint16 | Uint16 | UInt16 | Custom pyo3 wrapper (ints.rs) | | uint32 | Uint32 | UInt32 | Custom pyo3 wrapper (ints.rs) | +| int64 | Int64 | Int64 | Custom pyo3 wrapper (ints.rs) | | uint64 | Uint64 | UInt64 | Custom pyo3 wrapper (ints.rs) | -| float / double | Double | Float64 | Custom pyo3 wrapper (floats.rs) | | float32 / single | Single | Float32 | Custom pyo3 wrapper (floats.rs) | -| bigint | BigInteger | int | Native Python type | + +### Int32 normalization + +A Python `int` is arbitrary precision, so the compiler normalizes results that can leave the 32-bit +range, emitting `int32(...)` from `fable_library.core`. Two properties keep this cheap: + +- Normalization commutes with `+ - * << & | ^`, so **one normalization per expression tree** is + equivalent to one per operation. `int32(a * b + c)` and `int32(int32(a * b) + c)` always agree. +- Only `+ - * <<` and unary `-` can leave the range at all. `& | ^ ~ >> / %`, comparisons, indexing + and literals map in-range operands to in-range results and are emitted bare. + +`transformOperation` implements this as a strip-then-wrap peephole keyed on the *Fable* operand node, +never on the emitted Python — so it cannot strip a meaningful `int32(someFloat)` truncation, which +does not commute with arithmetic. A four-operation tree emits a single wrap: + +```py +def nested(a: int, b: int, c: int) -> int: + return int32(((a * b) + (c * 2)) - 1) +``` + +Float64 needs none of this: a Python `float` *is* an IEEE double, so arithmetic, overflow-to-infinity, +NaN comparison, banker's rounding and signed zero already match .NET bit for bit. + +### Operators that diverge from Python + +| Expression | Python | .NET | Emitted | +| --- | --- | --- | --- | +| `-7 / 2` | `-4` (floors) | `-3` (truncates) | `int(a / b)` | +| `-5 % 3` | `1` (sign of divisor) | `-2` (sign of dividend) | `op_remainder_int32(a, b)` | +| `1 <<< 32` | `4294967296` | `1` (count masked) | `a << (b & 31)` | +| `1.0 / 0.0` | `ZeroDivisionError` | `infinity` | `op_division_float64(a, b)` | +| `-5.0 % 3.0` | `1.0` | `-2.0` | `op_remainder_float64(a, b)` | +| `str(5.0)` | `'5.0'` | `'5'` | `exceptions.to_string` | +| `str(inf)` | `'inf'` | `'Infinity'` | `exceptions.to_string` | + +Float division keeps a bare `/` when the divisor is a non-zero literal. + +`exceptions.to_string` must not call `int()` on a non-finite double — that raises `OverflowError` for +the infinities and `ValueError` for NaN — so it checks `math.isfinite` before taking the whole-number +path. The printf machinery in `strings.rs` spells the same three values out. + +**Known divergence:** `Int32.MinValue / -1` yields `2147483648` rather than throwing +`OverflowException`. `/` cannot leave the 32-bit range for any other operand pair, so it carries no +normalization, and adding one just for this case would tax every integer division. + +### Conversions between widths + +A widening conversion such as `int (x: sbyte)` or `float (x: float32)` reaches codegen as a bare +`TypeCast` — `Replacements.needToCast` is false, because on .NET the value always fits. On Python the +cast is not free even so: the source is a wrapper object, and leaving it in place would keep the +arithmetic at the *source* width, so `Int8(100) + 100` would wrap to -56 instead of widening to 200. +`transformCast` therefore emits `int32(...)` (which also truncates, matching an unchecked .NET cast) +or the builtin `float(...)` whenever the source is not already the target's representation. Casting +between two values that are both plain `int`, or both plain `float`, stays a no-op. + +`Int32.Parse`/`TryParse` go through the `int32` *module* rather than the `Int32` class, because the +class's static methods return — and store into the ref cell — an `Int32` object. + +### Builtin entity annotations + +`makeBuiltinTypeAnnotation` in `Fable2Python.Annotation.fs` annotates most builtin entities as `Any` +via its catch-all. The arms that would give them real types are present in the file but commented out; +only `Set<'T>` is enabled, and only because the plain-`int` representation forced the issue. + +With `int` emitted as a bare literal, Pyright solves the element type of `singleton(1, cmp)` to +`Literal[1]`. The comparer argument cannot widen it, because `IComparer_1` is contravariant and so +`IComparer_1[int]` already satisfies `IComparer_1[Literal[1]]`. `FSharpSet` is invariant — its `T` +appears in both `Contains(value: T)` and `GetEnumerator() -> IEnumerator[T]` — so +`FSharpSet[Literal[1]]` will not unify with the `FSharpSet[Literal[2]]` of a sibling set. An `Any` +binding gives bidirectional inference nothing to push down into those calls; annotating it +`FSharpSet[T]` resolves both sides to `int`. + +**Not yet investigated:** `Map<'K,'V>`, `Dictionary<'K,'V>` and `HashSet<'T>` sit in the same +commented-out block and still annotate as `Any`. They are not *known* to be wrong — nothing in the +test corpus currently fails on them — but they are subject to the same literal-inference trap as +`Set<'T>` was, so an `Any` there is hiding rather than answering the question. Enabling them is a much +wider change than `Set` was: map- and dictionary-typed bindings are common across generated code, and +`Any` has been absorbing whatever imprecision those annotations would otherwise expose. Worth doing +one entity at a time, measuring the Pyright delta over `temp/tests/Python/` and `temp/fable-library-py/` +after each. + +### Runtime type tests + +`:? int` and `:? float` compile to exact `type(x) is int` / `type(x) is float` checks rather than +`isinstance`, because `bool` subclasses `int` — `isinstance` would report `box true :? int` as true. +Exact matching also keeps `float` distinct from the `Float32` wrapper, which is not a `float` subclass. + +**Known limitation:** Python's `int` is already arbitrary precision, so a boxed `int` cannot be told +apart from a `bigint` at runtime. One assertion in `tests/Python/TestType.fs` is guarded with +`#if !FABLE_COMPILER_PYTHON`. JS avoids this only because it has a native `bigint` primitive. ## Interfaces and Protocols diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 5ab9a3b78f..7383638963 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -292,6 +292,19 @@ let toDecimal com (ctx: Context) r targetType (args: Expr list) : Expr = TypeCast(args.Head, targetType) +/// Calls a sized integer's parse-family method (`parse` or `try_parse`). +/// +/// Most widths use the wrapper class's static method, which returns a value of that +/// wrapper type. Int32 is represented as a plain Python `int`, so it goes through the +/// `int32` module instead: `Int32.parse` would hand back an `Int32` object, and +/// `Int32.try_parse` would store one in the ref cell. +let makeIntParseCall com r t kind meth (args: Expr list) = + match kind with + | Int32 -> Helper.LibCall(com, "int32", meth, t, args, ?loc = r) + | _ -> + let typeExpr = Helper.LibValue(com, "core", getIntTypeName kind, Any) + Helper.InstanceCall(typeExpr, meth, t, args, ?loc = r) + let stringToInt com (_ctx: Context) r targetType (args: Expr list) : Expr = let kind = match targetType with @@ -299,10 +312,10 @@ let stringToInt com (_ctx: Context) r targetType (args: Expr list) : Expr = | x -> FableError $"Unexpected type in stringToInt: %A{x}" |> raise let style = int System.Globalization.NumberStyles.Any + let parseArgs = [ args.Head; makeIntConst style ] @ args.Tail + // Use the type's static parse method: e.g., int8.parse(string, style) - let typeName = getIntTypeName kind - let typeExpr = Helper.LibValue(com, "core", typeName, Any) - Helper.InstanceCall(typeExpr, "parse", targetType, [ args.Head; makeIntConst style ] @ args.Tail, ?loc = r) + makeIntParseCall com r targetType kind "parse" parseArgs let toLong com (ctx: Context) r (unsigned: bool) targetType (args: Expr list) : Expr = let fromInteger kind arg = @@ -396,9 +409,12 @@ let toString com (ctx: Context) r (args: Expr list) = | String -> head | Builtin BclGuid when tail.IsEmpty -> Helper.GlobalCall("str", String, [ head ], ?loc = r) | Builtin(BclGuid | BclTimeSpan as bt) -> Helper.LibCall(com, coreModFor bt, "to_string", String, args) + // Int32 is a plain Python `int`, which has no `to_string` method | Number(Int32, _) -> - let expr = Helper.LibCall(com, "core", "int32", head.Type, [ head ], ?loc = r) - Helper.InstanceCall(expr, "to_string", String, tail, ?loc = r) + if tail.Length > 0 then + Helper.LibCall(com, "int32", "to_string", String, head :: tail, ?loc = r) + else + Helper.GlobalCall("str", String, [ head ], ?loc = r) | Number((Int8 | UInt8 | UInt16 | Int16 | UInt32 | Int64 | UInt64), _) -> if tail.Length > 0 then Helper.InstanceCall(head, "to_string", String, tail, ?loc = r) @@ -487,7 +503,8 @@ let applyOp (com: ICompiler) (ctx: Context) r t opName (args: Expr list) = match argTypes with | Number(Int8, _) :: _ -> Helper.LibCall(com, "int32", "op_unary_negation_int8", t, args, ?loc = r) | Number(Int16, _) :: _ -> Helper.LibCall(com, "int32", "op_unary_negation_int16", t, args, ?loc = r) - | Number(Int32, _) :: _ -> Helper.LibCall(com, "int32", "op_unary_negation_int32", t, args, ?loc = r) + // Int32 is a plain Python `int`; `transformOperation` normalizes the + // result, which is only out of range for `-Int32.MinValue`. | _ -> unOp UnaryMinus operand | Operators.unaryPlus, [ operand ] -> unOp UnaryPlus operand | _ -> @@ -1669,7 +1686,7 @@ let stringModule (com: ICompiler) (ctx: Context) r t (i: CallInfo) (_: Expr opti | "Length", [ arg ] -> // Use int32(len()) to ensure consistent return type let lenExpr = Helper.GlobalCall("len", Int32.Number, [ arg ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some + lenExpr |> Some | ("Iterate" | "IterateIndexed" | "ForAll" | "Exists"), _ -> // Cast the string to char[], see #1279 let args = args |> List.replaceLast (fun e -> stringToCharArray e.Type e) @@ -1704,7 +1721,7 @@ let formattableString let lenExpr = Helper.GlobalCall("len", Int32.Number, [ getField x "args" ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some + lenExpr |> Some | "GetArgument", Some x, [ idx ] -> getExpr r t (getField x "args") idx |> Some | "GetArguments", Some x, [] -> getFieldWith r t x "args" |> Some | _ -> None @@ -1867,7 +1884,7 @@ let resizeArrays (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (this match ar.Type with | Array _ -> let lenExpr = Helper.GlobalCall("len", Int32.Number, [ ar ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some + lenExpr |> Some | _ -> Helper.LibCall(com, "util", "count", t, [ ar ], ?loc = r) |> Some | "Clear", Some ar, _ -> Helper.LibCall(com, "Util", "clear", t, [ ar ], ?loc = r) |> Some | "Find", Some ar, [ arg ] -> @@ -1997,7 +2014,7 @@ let arrays (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg: E match i.CompiledName, thisArg, args with | "get_Length", Some arg, _ -> let lenExpr = Helper.GlobalCall("len", Int32.Number, [ arg ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some + lenExpr |> Some | "get_Item", Some arg, [ idx ] -> getExpr r t arg idx |> Some | "set_Item", Some arg, [ idx; value ] -> setExpr r arg idx value |> Some | "Equals", Some arg1, [ arg2 ] -> makeEqOpStrict r arg1 arg2 BinaryEqual |> Some @@ -2053,7 +2070,7 @@ let arrayModule (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (_: Ex |> Some | ("Length" | "Count"), [ arg ] -> let lenExpr = Helper.GlobalCall("len", Int32.Number, [ arg ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some + lenExpr |> Some | "Item", [ idx; ar ] -> getExpr r t ar idx |> Some | "Get", [ ar; idx ] -> getExpr r t ar idx |> Some | "Set", [ ar; idx; value ] -> setExpr r ar idx value |> Some @@ -2300,10 +2317,15 @@ let parseNum (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op else // For integer types, call the static parse method on the type // This generates: int8.parse(string, style) instead of parse_int32(string, style, unsigned, bitsize) - let typeName = getIntTypeName kind - let typeExpr = Helper.LibValue(com, "core", typeName, Any) let args = [ str; makeIntConst style ] @ outValue - Helper.InstanceCall(typeExpr, Naming.lowerFirst meth, t, args, ?loc = r) |> Some + + let methName = + if meth = "TryParse" then + "try_parse" + else + "parse" + + makeIntParseCall com r t kind methName args |> Some let isFloat = match i.SignatureArgTypes with @@ -2689,7 +2711,7 @@ let dictionaries (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Exp | "get_Count", Some c -> // Use int32(len()) to work with both Dictionary class and plain Python dict let lenExpr = Helper.GlobalCall("len", Int32.Number, [ c ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) |> Some + lenExpr |> Some | "GetEnumerator", Some callee -> getEnumerator com r t callee |> Some | "ContainsValue", _ -> match thisArg, args with @@ -3508,7 +3530,7 @@ let regex com (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Exp thisArg |> Option.map (fun c -> let lenExpr = Helper.GlobalCall("len", Int32.Number, [ c ], ?loc = r) - Helper.LibCall(com, "core", "int32", t, [ lenExpr ], ?loc = r) + lenExpr ) | "GetEnumerator" -> thisArg |> Option.map (fun thisArg -> getEnumerator com r t thisArg) | "IsMatch" diff --git a/src/fable-library-py/fable_library/array_.py b/src/fable-library-py/fable_library/array_.py index db615a17f5..72ce5665ef 100644 --- a/src/fable-library-py/fable_library/array_.py +++ b/src/fable-library-py/fable_library/array_.py @@ -10,12 +10,12 @@ UInt8ArrayCons = array.FSharpCons[uint8]("UInt8") Int16ArrayCons = array.FSharpCons[int16]("Int16") UInt16ArrayCons = array.FSharpCons[uint16]("UInt16") -Int32ArrayCons = array.FSharpCons[int32]("Int32") +Int32ArrayCons = array.FSharpCons[int]("Int32") UInt32ArrayCons = array.FSharpCons[uint32]("UInt32") Int64ArrayCons = array.FSharpCons[int64]("Int64") UInt64ArrayCons = array.FSharpCons[uint64]("UInt64") Float32ArrayCons = array.FSharpCons[float32]("Float32") -Float64ArrayCons = array.FSharpCons[float64]("Float64") +Float64ArrayCons = array.FSharpCons[float]("Float64") BoolArrayCons = array.FSharpCons[bool]("Bool") # Typed array classes (use these for isinstance checks and type annotations). diff --git a/src/fable-library-py/fable_library/bit_converter.py b/src/fable-library-py/fable_library/bit_converter.py index 962c9a23b9..502fbd23a5 100644 --- a/src/fable-library-py/fable_library/bit_converter.py +++ b/src/fable-library-py/fable_library/bit_converter.py @@ -51,7 +51,7 @@ def get_bytes_double(value: SupportsFloat) -> Array[uint8]: return _bytes_to_uint8_array(struct.pack("d", float(value))) -def int64bits_to_double(value: SupportsInt) -> float64: +def int64bits_to_double(value: SupportsInt) -> float: byte_data = int(value).to_bytes(length=8, byteorder=sys.byteorder, signed=True) [number] = struct.unpack("d", byte_data) return float64(number) @@ -95,7 +95,7 @@ def to_uint16(byte_data: bytes | bytearray | Array[uint8], offset: SupportsInt) return uint16(int.from_bytes(data[off : off + 2], byteorder=sys.byteorder, signed=False)) -def to_int32(byte_data: bytes | bytearray | Array[uint8], offset: SupportsInt) -> int32: +def to_int32(byte_data: bytes | bytearray | Array[uint8], offset: SupportsInt) -> int: data = _to_bytes(byte_data) off = int(offset) return int32(int.from_bytes(data[off : off + 4], byteorder=sys.byteorder, signed=True)) @@ -126,7 +126,7 @@ def to_single(byte_data: bytes | bytearray | Array[uint8], offset: SupportsInt) return float32(number) -def to_double(byte_data: bytes | bytearray | Array[uint8], offset: SupportsInt) -> float64: +def to_double(byte_data: bytes | bytearray | Array[uint8], offset: SupportsInt) -> float: data = _to_bytes(byte_data) off = int(offset) [number] = struct.unpack("d", data[off : off + 8]) diff --git a/src/fable-library-py/fable_library/core/__init__.py b/src/fable-library-py/fable_library/core/__init__.py index 29eb4a7259..4599259fb1 100644 --- a/src/fable-library-py/fable_library/core/__init__.py +++ b/src/fable-library-py/fable_library/core/__init__.py @@ -1,4 +1,5 @@ -from typing import Literal +from math import copysign as _copysign, fmod as _fmod, inf as _inf, nan as _nan +from typing import Literal, SupportsFloat, SupportsInt from ._core import ( Int8, @@ -44,16 +45,103 @@ int8 = Int8 int16 = Int16 uint16 = UInt16 -int32 = Int32 uint32 = UInt32 int64 = Int64 uint64 = UInt64 -float32 = floats.Float32 -float64 = floats.Float64 +# The float wrappers, exported by class name for interop and type tests. Only +# Float32 keeps a lowercase alias; `float64` below is the plain-float coercion. +Float32 = floats.Float32 +Float64 = floats.Float64 + +float32 = Float32 + +# System.Int32 is represented as a plain Python `int`, which is arbitrary precision, +# so the compiler normalizes back into 32 bits after operations that can leave the +# range. The `Int32` wrapper class is still exported for interop, but generated code +# no longer constructs it. +# +# These are deliberately pure Python: a call into the Rust extension costs roughly +# twice what the whole helper does, because crossing the boundary is the expensive +# part, not the arithmetic. + + +def int32(value: SupportsInt = 0, /) -> int: + """Normalize `value` to a signed 32-bit int, wrapping as .NET does. + + Shaped for the common case: most values are already in range, so that path is a + single comparison with no temporaries and no module-global lookups. + """ + if type(value) is int: + if -2147483648 <= value <= 2147483647: + return value + + masked = value & 4294967295 + + return masked - 4294967296 if masked > 2147483647 else masked + + return int32(int(value)) + + +def float64(value: SupportsFloat | SupportsInt = 0.0, /) -> float: + """System.Double is represented as a plain Python `float`, which is an IEEE double. + + Arithmetic needs no adjustment; only division by zero and remainder diverge, and + those are routed through the helpers below. + """ + if type(value) is float: + return value + + if isinstance(value, SupportsFloat): + return float(value) + + # SupportsInt only promises __int__, which float() does not accept + return float(int(value)) + + +def op_division_float64(x: float, y: float, /) -> float: + """IEEE division. Python raises on a zero divisor where .NET yields +/-inf or nan.""" + if y: + return x / y + + # `if y` is false for both 0.0 and -0.0, and true for nan + if x == 0.0 or x != x: + return _nan + + return _copysign(_inf, x) * _copysign(1.0, y) + + +def op_remainder_float64(x: float, y: float, /) -> float: + """Remainder taking the sign of the dividend, as in .NET. + + Python's float `%` takes the sign of the divisor instead, so `-5.0 % 3.0` is 1.0 + here where .NET gives -2.0. `fmod` has .NET's semantics but raises where .NET + yields nan. + """ + try: + return _fmod(x, y) + except ValueError: + return _nan + + +def op_remainder_int32(dividend: int, divisor: int, /) -> int: + """Remainder taking the sign of the dividend, as in .NET. + + Python's `%` takes the sign of the divisor instead, so `-5 % 3` is 1 here where + .NET gives -2. + """ + rest = dividend % divisor + + if rest and (rest < 0) != (dividend < 0): + return rest - divisor + + return rest + __all__: list[str] = [ "ArrayType", "FSharpRef", + "Float32", + "Float64", "Int8", "Int16", "Int32", @@ -71,6 +159,9 @@ "int16", "int32", "int64", + "op_division_float64", + "op_remainder_float64", + "op_remainder_int32", "option", "sbyte", "strings", diff --git a/src/fable-library-py/fable_library/core/__init__.pyi b/src/fable-library-py/fable_library/core/__init__.pyi index 34a763b9ea..08c17f605a 100644 --- a/src/fable-library-py/fable_library/core/__init__.pyi +++ b/src/fable-library-py/fable_library/core/__init__.pyi @@ -1,6 +1,6 @@ """Type stubs for fable_library.core module.""" -from typing import Literal +from typing import Literal, SupportsFloat, SupportsInt from .array import FSharpArray from .floats import Float32, Float64 @@ -32,14 +32,20 @@ uint8 = UInt8 int8 = Int8 int16 = Int16 uint16 = UInt16 -int32 = Int32 uint32 = UInt32 + +# Int32 is represented as a plain Python `int`; these normalize into 32 bits. +def int32(value: SupportsInt = 0, /) -> int: ... +def op_remainder_int32(dividend: int, divisor: int, /) -> int: ... int64 = Int64 uint64 = UInt64 -# Float aliases +# Float aliases. Float64 is represented as a plain Python `float`. float32 = Float32 -float64 = Float64 + +def float64(value: SupportsFloat | SupportsInt = 0.0, /) -> float: ... +def op_division_float64(x: float, y: float, /) -> float: ... +def op_remainder_float64(x: float, y: float, /) -> float: ... __all__ = [ "Array", @@ -62,6 +68,9 @@ __all__ = [ "int16", "int32", "int64", + "op_division_float64", + "op_remainder_float64", + "op_remainder_int32", "sbyte", "uint8", "uint16", diff --git a/src/fable-library-py/fable_library/core/array.pyi b/src/fable-library-py/fable_library/core/array.pyi index b4d0d5ecef..3940ac6b94 100644 --- a/src/fable-library-py/fable_library/core/array.pyi +++ b/src/fable-library-py/fable_library/core/array.pyi @@ -20,7 +20,7 @@ from fable_library.protocols import ( ) from fable_library.util import UNIT, Unit -from . import FSharpRef, Int32 +from . import FSharpRef # Type alias for inputs that can be iterated - supports both Python Iterable and F# IEnumerable type Elements[T] = Iterable[T] | IEnumerable_1[T] @@ -58,7 +58,11 @@ class FSharpArray[T](MutableSequence[T]): # F# interop property @property - def length(self) -> Int32: ... + def length(self) -> int: ... + + # Name of the backing storage ("Int32", "Float64", "Generic", ...) + @property + def storage_type(self) -> str: ... # IEnumerable implementation (for .NET compatibility) def GetEnumerator(self, __unit: Unit = UNIT) -> IEnumerator[T]: ... @@ -68,7 +72,7 @@ class FSharpArray[T](MutableSequence[T]): def create(count: SupportsInt, value: T) -> FSharpArray[T]: ... @staticmethod def initialize( - count: SupportsInt, initializer: Callable[[Int32], T], cons: Any | None = None + count: SupportsInt, initializer: Callable[[int], T], cons: Any | None = None ) -> FSharpArray[T]: ... # Instance methods (alphabetically sorted) @@ -80,8 +84,8 @@ class FSharpArray[T](MutableSequence[T]): def choose[U](self, chooser: Callable[[T], Option[U]], cons: FSharpCons[U] | None = None) -> FSharpArray[U]: ... def chunk_by_size(self, size: SupportsInt) -> list[FSharpArray[T]]: ... def collect[U](self, mapping: Callable[[T], FSharpArray[U]], cons: Any | None = None) -> FSharpArray[U]: ... - def compare_to(self, comparer: Callable[[T, T], SupportsInt], other: FSharpArray[T]) -> Int32: ... - def compare_with(self, comparer: Callable[[T, T], SupportsInt], other: FSharpArray[T]) -> Int32: ... + def compare_to(self, comparer: Callable[[T, T], SupportsInt], other: FSharpArray[T]) -> int: ... + def compare_with(self, comparer: Callable[[T, T], SupportsInt], other: FSharpArray[T]) -> int: ... def contains(self, value: T, eq: IEqualityComparer_1[T] | None = None) -> bool: ... def copy(self) -> FSharpArray[T]: ... def copy_to( @@ -94,15 +98,15 @@ class FSharpArray[T](MutableSequence[T]): def filter(self, predicate: Callable[[T], bool]) -> FSharpArray[T]: ... def find(self, predicate: Callable[[T], bool]) -> T: ... def find_back(self, predicate: Callable[[T], bool]) -> T: ... - def find_index(self, predicate: Callable[[T], bool]) -> Int32: ... - def find_index_back(self, predicate: Callable[[T], bool]) -> Int32: ... - def find_last_index(self, predicate: Callable[[T], bool]) -> Int32: ... + def find_index(self, predicate: Callable[[T], bool]) -> int: ... + def find_index_back(self, predicate: Callable[[T], bool]) -> int: ... + def find_last_index(self, predicate: Callable[[T], bool]) -> int: ... def fold[S](self, folder: Callable[[S, T], S], state: S) -> S: ... def fold_back[S](self, folder: Callable[[T, S], S], state: S) -> S: ... def fold_back2[T1, T2, S](self, folder: Callable[[T1, T2, S], S], array2: FSharpArray[T2], state: S) -> S: ... - def fold_back_indexed[S](self, folder: Callable[[Int32, T, S], S], state: S) -> S: ... + def fold_back_indexed[S](self, folder: Callable[[int, T, S], S], state: S) -> S: ... def fold_indexed( - self, folder: Callable[[Int32, Any, Any], Any], state: Any + self, folder: Callable[[int, Any, Any], Any], state: Any ) -> Any: ... # Use Any for fold_indexed function types def for_all(self, predicate: Callable[[T], bool]) -> bool: ... def get_sub_array( @@ -123,7 +127,7 @@ class FSharpArray[T](MutableSequence[T]): def insert_range_in_place(self, index: SupportsInt, values: Iterable[T]) -> None: ... def item(self, index: SupportsInt) -> T: ... def iterate(self, action: Callable[[Any], None]) -> None: ... - def iterate_indexed(self, action: Callable[[Int32, Any], None]) -> None: ... + def iterate_indexed(self, action: Callable[[int, Any], None]) -> None: ... def last(self) -> T: ... def map[U](self, f: Callable[[T], U], cons: Any | None = None) -> FSharpArray[U]: ... def map2[T1, T2, U]( @@ -149,17 +153,17 @@ class FSharpArray[T](MutableSequence[T]): state: S, cons: Any | None = None, ) -> tuple[FSharpArray[R], S]: ... - def map_indexed[U](self, f: Callable[[Int32, T], U], cons: Any | None = None) -> FSharpArray[U]: ... + def map_indexed[U](self, f: Callable[[int, T], U], cons: Any | None = None) -> FSharpArray[U]: ... def map_indexed2[T1, T2, U]( self, - f: Callable[[Int32, T1, T2], U], + f: Callable[[int, T1, T2], U], array1: FSharpArray[T1], array2: FSharpArray[T2], cons: Any | None = None, ) -> FSharpArray[U]: ... def map_indexed3[T1, T2, T3, U]( self, - f: Callable[[Int32, T1, T2, T3], U], + f: Callable[[int, T1, T2, T3], U], array1: FSharpArray[T1], array2: FSharpArray[T2], array3: FSharpArray[T3], @@ -173,7 +177,7 @@ class FSharpArray[T](MutableSequence[T]): def partition( self, f: Callable[[T], bool], cons: FSharpCons[T] | None = None ) -> tuple[FSharpArray[T], FSharpArray[T]]: ... - def permute(self, f: Callable[[Int32], SupportsInt], array: FSharpArray[T]) -> FSharpArray[T]: ... + def permute(self, f: Callable[[int], SupportsInt], array: FSharpArray[T]) -> FSharpArray[T]: ... def pick[U](self, chooser: Callable[[T], Option[U]]) -> U: ... def random_choice(self) -> T: ... def random_choice_by(self, randomizer: Callable[[], SupportsFloat]) -> T: ... @@ -251,8 +255,8 @@ def compare_to[T]( comparer: Callable[[T, T], SupportsInt], source1: Elements[T], source2: Elements[T], -) -> Int32: ... -def compare_with[T](comparer: Callable[[T, T], SupportsInt], array1: Elements[T], array2: Elements[T]) -> Int32: ... +) -> int: ... +def compare_with[T](comparer: Callable[[T, T], SupportsInt], array1: Elements[T], array2: Elements[T]) -> int: ... def concat[T](arrays: Elements[FSharpArray[T]], cons: FSharpCons[T] | None = None) -> FSharpArray[T]: ... def contains[T](value: T, array: Elements[T], eq: IEqualityComparer_1[T] | None = None) -> bool: ... def copy[T](array: Elements[T]) -> FSharpArray[T]: ... @@ -294,14 +298,14 @@ def fold_back2[T1, T2, S]( array2: Elements[T2], state: S, ) -> S: ... -def fold_back_indexed[T, S](folder: Callable[[Int32, T, S], S], array: Elements[T], state: S) -> S: ... +def fold_back_indexed[T, S](folder: Callable[[int, T, S], S], array: Elements[T], state: S) -> S: ... def fold_back_indexed2[T1, T2, S]( - folder: Callable[[Int32, T1, T2, S], S], + folder: Callable[[int, T1, T2, S], S], array1: Elements[T1], array2: Elements[T2], state: S, ) -> S: ... -def fold_indexed[T, S](folder: Callable[[Int32, Any, Any], Any], state: Any, array: Elements[T]) -> Any: ... +def fold_indexed[T, S](folder: Callable[[int, Any, Any], Any], state: Any, array: Elements[T]) -> Any: ... def for_all[T](predicate: Callable[[T], bool], array: Elements[T]) -> bool: ... def for_all2[T1, T2](predicate: Callable[[T1, T2], bool], array1: Elements[T1], array2: Elements[T2]) -> bool: ... def get_sub_array[T]( @@ -317,7 +321,7 @@ def index_of[T]( ) -> SupportsInt: ... def indexed[T](array: Elements[T]) -> FSharpArray[tuple[SupportsInt, T]]: ... def initialize[T]( - count: SupportsInt, initializer: Callable[[Int32], T], cons: FSharpCons[T] | None = None + count: SupportsInt, initializer: Callable[[int], T], cons: FSharpCons[T] | None = None ) -> FSharpArray[T]: ... def insert_at[T]( index: SupportsInt, value: T, array: Elements[T], cons: FSharpCons[T] | None = None @@ -332,8 +336,8 @@ def insert_range_in_place[T](array: FSharpArray[T], index: SupportsInt, values: def item[T](index: SupportsInt, array: Elements[T]) -> T: ... def iterate[T](action: Callable[[T], None], array: Elements[T]) -> None: ... def iterate2[T1, T2](action: Callable[[T1, T2], None], array1: Elements[T1], array2: Elements[T2]) -> None: ... -def iterate_indexed[T](action: Callable[[Int32, T], None], array: Elements[T]) -> None: ... -def iterate_indexed2[T1, T2](action: Callable[[Int32, T1, T2], None], array1: Elements[T1], array2: Elements[T2]) -> None: ... +def iterate_indexed[T](action: Callable[[int, T], None], array: Elements[T]) -> None: ... +def iterate_indexed2[T1, T2](action: Callable[[int, T1, T2], None], array1: Elements[T1], array2: Elements[T2]) -> None: ... def last[T](array: Elements[T]) -> T: ... def map[T, U](f: Callable[[T], U], array: Elements[T], cons: FSharpCons[U] | None = None) -> FSharpArray[U]: ... def map2[T1, T2, U]( @@ -362,16 +366,16 @@ def map_fold_back[T, S, R]( cons: FSharpCons[R] | None = None, ) -> tuple[FSharpArray[R], S]: ... def map_indexed[T, U]( - f: Callable[[Int32, T], U], array: Elements[T], cons: FSharpCons[U] | None = None + f: Callable[[int, T], U], array: Elements[T], cons: FSharpCons[U] | None = None ) -> FSharpArray[U]: ... def map_indexed2[T1, T2, U]( - f: Callable[[Int32, T1, T2], U], + f: Callable[[int, T1, T2], U], array1: Elements[T1], array2: Elements[T2], cons: FSharpCons[U] | None = None, ) -> FSharpArray[U]: ... def map_indexed3[T1, T2, T3, U]( - f: Callable[[Int32, T1, T2, T3], U], + f: Callable[[int, T1, T2, T3], U], array1: Elements[T1], array2: Elements[T2], array3: Elements[T3], @@ -386,7 +390,7 @@ def pairwise[T](array: Elements[T]) -> FSharpArray[tuple[T, T]]: ... def partition[T]( f: Callable[[T], bool], array: Elements[T], cons: FSharpCons[T] | None = None ) -> tuple[FSharpArray[T], FSharpArray[T]]: ... -def permute[T](f: Callable[[Int32], SupportsInt], array: Elements[T]) -> FSharpArray[T]: ... +def permute[T](f: Callable[[int], SupportsInt], array: Elements[T]) -> FSharpArray[T]: ... def pick[T, U](chooser: Callable[[T], Option[U]], array: Elements[T]) -> U: ... def random_choice[T](xs: Elements[T]) -> T: ... def random_choice_by[T](randomizer: Callable[[], SupportsFloat], xs: Elements[T]) -> T: ... @@ -452,8 +456,8 @@ def truncate[T](count: SupportsInt, array: Elements[T]) -> FSharpArray[T]: ... def exactly_one[T](array: Elements[T]) -> T: ... def try_find[T](predicate: Callable[[T], bool], array: Elements[T]) -> T | None: ... def try_find_back[T](predicate: Callable[[T], bool], array: Elements[T]) -> T | None: ... -def try_find_index[T](predicate: Callable[[T], bool], array: Elements[T]) -> Int32 | None: ... -def try_find_index_back[T](predicate: Callable[[T], bool], array: Elements[T]) -> Int32 | None: ... +def try_find_index[T](predicate: Callable[[T], bool], array: Elements[T]) -> int | None: ... +def try_find_index_back[T](predicate: Callable[[T], bool], array: Elements[T]) -> int | None: ... def try_head[T](array: Elements[T]) -> T | None: ... def try_exactly_one[T](array: Elements[T]) -> T | None: ... def try_item[T](index: SupportsInt, array: Elements[T]) -> T | None: ... diff --git a/src/fable-library-py/fable_library/core/floats.pyi b/src/fable-library-py/fable_library/core/floats.pyi index d44acd1dfc..0ff33160ad 100644 --- a/src/fable-library-py/fable_library/core/floats.pyi +++ b/src/fable-library-py/fable_library/core/floats.pyi @@ -93,33 +93,33 @@ negative_infinity: Float64 nan: Float64 # Free functions for mathematical operations -def sqrt(x: Float64) -> Float64: ... -def cos(x: Float64) -> Float64: ... -def sin(x: Float64) -> Float64: ... -def tan(x: Float64) -> Float64: ... -def sinh(x: Float64) -> Float64: ... -def cosh(x: Float64) -> Float64: ... -def tanh(x: Float64) -> Float64: ... -def acos(x: Float64) -> Float64: ... -def asin(x: Float64) -> Float64: ... -def atan(x: Float64) -> Float64: ... -def atan2(y: Float64, x: Float64) -> Float64: ... -def exp(x: Float64) -> Float64: ... -def log(x: Float64, base: float | None = None) -> Float64: ... -def log10(x: Float64) -> Float64: ... -def log2(x: Float64) -> Float64: ... -def degrees(x: Float64) -> Float64: ... -def radians(x: Float64) -> Float64: ... -def is_nan(x: Float64) -> bool: ... -def is_infinity(x: Float64) -> bool: ... -def is_positive_infinity(x: Float64) -> bool: ... -def is_negative_infinity(x: Float64) -> bool: ... -def floor(x: Float64) -> Float64: ... -def ceil(x: Float64) -> Float64: ... -def pow(x: Float64, y: float | FloatNumeric) -> Float64: ... -def parse(x: str) -> Float64: ... +def sqrt(x: float) -> float: ... +def cos(x: float) -> float: ... +def sin(x: float) -> float: ... +def tan(x: float) -> float: ... +def sinh(x: float) -> float: ... +def cosh(x: float) -> float: ... +def tanh(x: float) -> float: ... +def acos(x: float) -> float: ... +def asin(x: float) -> float: ... +def atan(x: float) -> float: ... +def atan2(y: float, x: float) -> float: ... +def exp(x: float) -> float: ... +def log(x: float, base: float | None = None) -> float: ... +def log10(x: float) -> float: ... +def log2(x: float) -> float: ... +def degrees(x: float) -> float: ... +def radians(x: float) -> float: ... +def is_nan(x: float) -> bool: ... +def is_infinity(x: float) -> bool: ... +def is_positive_infinity(x: float) -> bool: ... +def is_negative_infinity(x: float) -> bool: ... +def floor(x: float) -> float: ... +def ceil(x: float) -> float: ... +def pow(x: float, y: float | FloatNumeric) -> float: ... +def parse(x: str) -> float: ... def parse_single(x: str) -> Float32: ... -def abs(x: Float64) -> Float64: ... +def abs(x: float) -> float: ... __all__ = [ "Float32", diff --git a/src/fable-library-py/fable_library/core/ints.pyi b/src/fable-library-py/fable_library/core/ints.pyi index f604cb42b5..c4dcce7744 100644 --- a/src/fable-library-py/fable_library/core/ints.pyi +++ b/src/fable-library-py/fable_library/core/ints.pyi @@ -161,7 +161,11 @@ class UInt32(NumericInts): @final class Int32(NumericInts, int): - # Note that this is not really a subclass of int + # The `int` base is a deliberate fiction: this is a Rust pyclass, not an `int` + # subclass at runtime. It is declared this way so an Int32 -- which now only turns + # up in hand-written interop, since F# `int` is represented as a plain Python + # `int` -- is still assignable wherever an `int` is expected, without needing + # `int | Int32` unions anywhere. ZERO: ClassVar[Int32] ONE: ClassVar[Int32] TWO: ClassVar[Int32] @@ -236,10 +240,13 @@ def parse_int32( unsigned: bool, bitsize: int, radix: int = 10, -) -> Int32: +) -> int: """ Parses a string representation of a 32-bit integer with F#-compatible semantics. + Returns a plain Python `int`, already validated against the range for `bitsize`, + unlike `Int32.parse` which returns an `Int32` wrapper. + Args: string: The string to parse style: NumberStyles flags controlling parsing behavior diff --git a/src/fable-library-py/fable_library/core/strings.pyi b/src/fable-library-py/fable_library/core/strings.pyi index 47d037ef91..aa32cc8d3e 100644 --- a/src/fable-library-py/fable_library/core/strings.pyi +++ b/src/fable-library-py/fable_library/core/strings.pyi @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from typing import Any, overload -from . import Array, int32 +from . import Array class IPrintfFormat: """F# printf-style formatting class. @@ -63,18 +63,18 @@ def format(provider: Any, fmt: str) -> str: ... def format(provider: Any, fmt: str, *args: Any) -> str: ... # String manipulation functions -def initialize(n: int32, f: Callable[[int32], str]) -> str: ... -def insert(string: str, start_index: int32, value: str) -> str: ... +def initialize(n: int, f: Callable[[int], str]) -> str: ... +def insert(string: str, start_index: int, value: str) -> str: ... def is_null_or_empty(string: str | None) -> bool: ... def is_null_or_white_space(string: str | None) -> bool: ... def concat(*strings: str) -> str: ... def join(delimiter: str, strings: Any) -> str: ... -def pad_left(string: str, length: int32, ch: str | None = None, is_right: bool = False) -> str: ... -def pad_right(string: str, length: int32, ch: str | None = None) -> str: ... -def remove(string: str, start_index: int32, count: int32 | None = None) -> str: ... +def pad_left(string: str, length: int, ch: str | None = None, is_right: bool = False) -> str: ... +def pad_right(string: str, length: int, ch: str | None = None) -> str: ... +def remove(string: str, start_index: int, count: int | None = None) -> str: ... def replace(string: str, search: str, replace: str) -> str: ... -def replicate(n: int32, x: str) -> str: ... -def get_char_at_index(input: str, index: int32) -> str: ... +def replicate(n: int, x: str) -> str: ... +def get_char_at_index(input: str, index: int) -> str: ... def split( string: str, splitters: Any, @@ -86,15 +86,15 @@ def trim_start(string: str, *chars: Any) -> str: ... def trim_end(string: str, *chars: Any) -> str: ... def filter(pred: Callable[[str], bool], x: str) -> str: ... def map(mapping: Callable[[str], str], x: str) -> str: ... -def map_indexed(mapping: Callable[[int32, str], str], x: str) -> str: ... +def map_indexed(mapping: Callable[[int, str], str], x: str) -> str: ... def collect(mapping: Callable[[str], str], x: str) -> str: ... def substring(string: str, start_index: int, length: int | None = None) -> str: ... def to_char_array2(string: str, start_index: int, length: int) -> Array[str]: ... # String comparison functions def compare(*args: Any) -> int: ... -def starts_with(string: str, pattern: str, ic: int32 | bool | None = None) -> bool: ... -def ends_with(string: str, pattern: str, ic: int32 | bool | None = None) -> bool: ... +def starts_with(string: str, pattern: str, ic: int | bool | None = None) -> bool: ... +def ends_with(string: str, pattern: str, ic: int | bool | None = None) -> bool: ... def index_of(string: str, pattern: str, start_index: int = 0) -> int: ... def last_index_of(string: str, pattern: str, start_index: int | None = None) -> int: ... diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 8f05a96c9d..0451b62558 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -86,7 +86,7 @@ def create( s: int = 0, ms: int = 0, mc: int = 0, - kind: int32 | None = None, + kind: int | None = None, ) -> datetime: if kind == DateKind.UTC: date = datetime( @@ -107,35 +107,35 @@ def create( return date -def year(d: datetime) -> int32: +def year(d: datetime) -> int: return int32(d.year) -def month(d: datetime) -> int32: +def month(d: datetime) -> int: return int32(d.month) -def day(d: datetime) -> int32: +def day(d: datetime) -> int: return int32(d.day) -def hour(d: datetime) -> int32: +def hour(d: datetime) -> int: return int32(d.hour) -def minute(d: datetime) -> int32: +def minute(d: datetime) -> int: return int32(d.minute) -def second(d: datetime) -> int32: +def second(d: datetime) -> int: return int32(d.second) -def millisecond(d: datetime) -> int32: +def millisecond(d: datetime) -> int: return int32(d.microsecond // 1_000) -def microsecond(d: datetime) -> int32: +def microsecond(d: datetime) -> int: return int32(d.microsecond) @@ -746,7 +746,7 @@ def add_microseconds(d: datetime, v: SupportsInt) -> datetime: return d + timedelta(microseconds=int(v)) -def kind(d: datetime) -> int32: +def kind(d: datetime) -> int: if d.tzinfo == UTC: return DateKind.UTC @@ -757,7 +757,7 @@ def kind(d: datetime) -> int32: return DateKind.Local -def specify_kind(d: datetime, kind: int32) -> datetime: +def specify_kind(d: datetime, kind: int) -> datetime: return create(year(d), month(d), day(d), hour(d), minute(d), second(d), millisecond(d), microsecond(d), kind) @@ -799,7 +799,7 @@ def date_offset(d: datetime) -> int64: # return 0 if d.tzinfo == timezone.utc else -def create_from_epoch_microseconds(us: int, kind: int32 | None = None) -> datetime: +def create_from_epoch_microseconds(us: int, kind: int | None = None) -> datetime: if kind == DateKind.UTC: date = datetime.fromtimestamp(us / 1_000_000, UTC) else: @@ -810,7 +810,7 @@ def create_from_epoch_microseconds(us: int, kind: int32 | None = None) -> dateti return date -def from_ticks(ticks: SupportsInt, kind: int32 | None = None) -> datetime: +def from_ticks(ticks: SupportsInt, kind: int | None = None) -> datetime: # Better default than Unspecified kind = kind or DateKind.Local date = create_from_epoch_microseconds(ticks_to_unix_epoch_microseconds(ticks), kind) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 85c5326a56..8b45fa777e 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -376,35 +376,35 @@ def unix_epoch() -> DateTimeOffset: # --------------------------------------------------------------------------- -def year(d: DateTimeOffset) -> int32: +def year(d: DateTimeOffset) -> int: return int32(d.year) -def month(d: DateTimeOffset) -> int32: +def month(d: DateTimeOffset) -> int: return int32(d.month) -def day(d: DateTimeOffset) -> int32: +def day(d: DateTimeOffset) -> int: return int32(d.day) -def hour(d: DateTimeOffset) -> int32: +def hour(d: DateTimeOffset) -> int: return int32(d.hour) -def minute(d: DateTimeOffset) -> int32: +def minute(d: DateTimeOffset) -> int: return int32(d.minute) -def second(d: DateTimeOffset) -> int32: +def second(d: DateTimeOffset) -> int: return int32(d.second) -def millisecond(d: DateTimeOffset) -> int32: +def millisecond(d: DateTimeOffset) -> int: return int32(d.microsecond // 1000) -def microsecond(d: DateTimeOffset) -> int32: +def microsecond(d: DateTimeOffset) -> int: return int32(d.microsecond % 1000) @@ -640,7 +640,7 @@ def equals_exact(d1: DateTimeOffset, d2: DateTimeOffset) -> bool: return _to_utc_ms(d1) == _to_utc_ms(d2) and d1.offset_ms == d2.offset_ms -def compare(d1: DateTimeOffset, d2: DateTimeOffset) -> int32: +def compare(d1: DateTimeOffset, d2: DateTimeOffset) -> int: a = _to_utc_ms(d1) b = _to_utc_ms(d2) if a < b: @@ -650,7 +650,7 @@ def compare(d1: DateTimeOffset, d2: DateTimeOffset) -> int32: return int32(0) -def compare_to(d1: DateTimeOffset, d2: DateTimeOffset) -> int32: +def compare_to(d1: DateTimeOffset, d2: DateTimeOffset) -> int: return compare(d1, d2) diff --git a/src/fable-library-py/fable_library/date_only.py b/src/fable-library-py/fable_library/date_only.py index 920bea6701..131d95a4e5 100644 --- a/src/fable-library-py/fable_library/date_only.py +++ b/src/fable-library-py/fable_library/date_only.py @@ -44,15 +44,15 @@ def from_date_time(d: datetime) -> datetime: return datetime(d.year, d.month, d.day) -def year(d: datetime) -> int32: +def year(d: datetime) -> int: return int32(d.year) -def month(d: datetime) -> int32: +def month(d: datetime) -> int: return int32(d.month) -def day(d: datetime) -> int32: +def day(d: datetime) -> int: return int32(d.day) diff --git a/src/fable-library-py/fable_library/decimal_.py b/src/fable-library-py/fable_library/decimal_.py index 5d77e34f01..d9e92a2861 100644 --- a/src/fable-library-py/fable_library/decimal_.py +++ b/src/fable-library-py/fable_library/decimal_.py @@ -1,6 +1,6 @@ from decimal import MAX_EMAX, MIN_EMIN, Decimal, getcontext -from .core import FSharpRef, byte, float32, float64, int16, int32, int64, sbyte, uint16, uint32, uint64 +from .core import FSharpRef, byte, float32, float64, int16, int64, sbyte, uint16, uint32, uint64 from .types import IntegerTypes @@ -145,7 +145,7 @@ def to_string(x: Decimal) -> str: return str(x) -def to_number(x: Decimal) -> float64: +def to_number(x: Decimal) -> float: return float64(x) @@ -165,11 +165,13 @@ def try_parse(string: str, def_value: FSharpRef[Decimal]) -> bool: return False -def create(value: float | float32 | float64 | IntegerTypes | str) -> Decimal: +def create(value: float | float32 | IntegerTypes | str) -> Decimal: match value: - case sbyte() | byte() | int16() | uint16() | int32() | uint32() | int64() | uint64(): + # Int32 and Float64 are plain `int`/`float`, which `Decimal` already accepts + # via the last case + case sbyte() | byte() | int16() | uint16() | uint32() | int64() | uint64(): return Decimal(int(value)) - case float32() | float64(): + case float32(): return Decimal(float(value)) case _: return Decimal(value) diff --git a/src/fable-library-py/fable_library/diagnostics.py b/src/fable-library-py/fable_library/diagnostics.py index 346e37a45f..f48cdefd4b 100644 --- a/src/fable-library-py/fable_library/diagnostics.py +++ b/src/fable-library-py/fable_library/diagnostics.py @@ -33,14 +33,14 @@ def stop(self) -> None: self._elapsed_time = time.perf_counter() - self._start_time self._start_time = None - def elapsed_milliseconds(self) -> int32: + def elapsed_milliseconds(self) -> int: """Return the elapsed time in milliseconds""" if self._elapsed_time is None: raise TimerError("Timer is not running. Use .start() to start it") return int32(self._elapsed_time * 1000) - def elapsed_ticks(self) -> int32: + def elapsed_ticks(self) -> int: """Return the elapsed time in ticks""" if self._elapsed_time is None: raise TimerError("Timer is not running. Use .start() to start it") @@ -48,7 +48,7 @@ def elapsed_ticks(self) -> int32: return int32(self._elapsed_time * 10000000) -def frequency() -> int32: +def frequency() -> int: return int32(1000000000) diff --git a/src/fable-library-py/fable_library/double.py b/src/fable-library-py/fable_library/double.py index a8d6372dc3..57403062d1 100644 --- a/src/fable-library-py/fable_library/double.py +++ b/src/fable-library-py/fable_library/double.py @@ -4,15 +4,15 @@ from .core import FSharpRef, float32, float64, floats -def sign(x: float64) -> float64: - return float64(-1) if x < 0 else float64(1) if x > 0 else float64(0) +def sign(x: float) -> float: + return -1.0 if x < 0 else 1.0 if x > 0 else 0.0 @overload def max(x: float32, y: float32) -> float32: ... @overload -def max(x: float64, y: float64) -> float64: ... -def max(x: float32 | float64, y: float32 | float64) -> float32 | float64: +def max(x: float, y: float) -> float: ... +def max(x: float32 | float, y: float32 | float) -> float32 | float: # IEEE 754 maximum (.NET Math.Max since .NET Core 3.0): NaN propagates (preserving # whichever operand was NaN), and +0.0 beats -0.0 even though they compare equal. if x != x: @@ -27,8 +27,8 @@ def max(x: float32 | float64, y: float32 | float64) -> float32 | float64: @overload def min(x: float32, y: float32) -> float32: ... @overload -def min(x: float64, y: float64) -> float64: ... -def min(x: float32 | float64, y: float32 | float64) -> float32 | float64: +def min(x: float, y: float) -> float: ... +def min(x: float32 | float, y: float32 | float) -> float32 | float: # IEEE 754 minimum (.NET Math.Min since .NET Core 3.0): NaN propagates (preserving # whichever operand was NaN), and -0.0 beats +0.0 even though they compare equal. if x != x: @@ -43,12 +43,12 @@ def min(x: float32 | float64, y: float32 | float64) -> float32 | float64: @overload def clamp(value: float32, min: float32, max: float32) -> float32: ... @overload -def clamp(value: float64, min: float64, max: float64) -> float64: ... -def clamp(value: float32 | float64, min: float32 | float64, max: float32 | float64) -> float32 | float64: +def clamp(value: float, min: float, max: float) -> float: ... +def clamp(value: float32 | float, min: float32 | float, max: float32 | float) -> float32 | float: return min if value < min else max if value > max else value -def try_parse(string: str, def_value: FSharpRef[float32 | float64 | Any]) -> bool: +def try_parse(string: str, def_value: FSharpRef[float32 | float | Any]) -> bool: try: def_value.contents = parse(string) return True @@ -57,7 +57,9 @@ def try_parse(string: str, def_value: FSharpRef[float32 | float64 | Any]) -> boo float32 = floats.Float32 -float64 = floats.Float64 +# `float64` is deliberately not rebound here: the name imported from `.core` is the +# plain-float coercion, and rebinding it to the wrapper class would leak wrappers +# back into generated code out of `sign` below. acos = floats.acos asin = floats.asin atan = floats.atan @@ -82,9 +84,11 @@ def try_parse(string: str, def_value: FSharpRef[float32 | float64 | Any]) -> boo sqrt = floats.sqrt tan = floats.tan tanh = floats.tanh -inf = floats.infinity -nan = floats.nan -negative_inf = floats.negative_infinity +# Float64 is a plain Python float, so these are the builtin constants rather than +# the wrapper ones the Rust module still exports for Float64 users. +inf = math.inf +nan = math.nan +negative_inf = -math.inf parse = floats.parse parse_single = floats.parse_single abs = floats.abs diff --git a/src/fable-library-py/fable_library/exceptions.py b/src/fable-library-py/fable_library/exceptions.py index 963e3d6e16..8dfc730d05 100644 --- a/src/fable-library-py/fable_library/exceptions.py +++ b/src/fable-library-py/fable_library/exceptions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from collections.abc import Iterable from typing import Any, cast @@ -41,10 +42,27 @@ def seq_to_string(self: Iterable[Any]) -> str: return str + "]" +def float_to_string(x: float) -> str: + """`Double.ToString()` for a plain Python `float`. + + .NET drops the fractional part of a whole double ("5", not "5.0") and spells the + specials out, where Python's `str` gives "inf"/"-inf"/"nan". The finite case comes + first because it is the only one that is hot; note that `int()` raises on the + specials, so it must not be reached for them. + """ + if math.isfinite(x): + return str(int(x)) if x.is_integer() else str(x) + + if math.isnan(x): + return "NaN" + + return "Infinity" if x > 0 else "-Infinity" + + def to_string(x: object | None, call_stack: int = 0) -> str: match x: - case float() if int(x) == x: - return str(int(x)) + case float(): + return float_to_string(x) case bool(): return str(x).lower() case Iterable() if not hasattr(cast(Iterable[Any], x), "__str__"): @@ -120,6 +138,7 @@ def is_exception(x: Any) -> bool: __all__ = [ "FSharpException", "ObjectDisposedException", + "float_to_string", "is_exception", "seq_to_string", "to_string", diff --git a/src/fable-library-py/fable_library/int32.py b/src/fable-library-py/fable_library/int32.py index 55e511a8c1..624db443ac 100644 --- a/src/fable-library-py/fable_library/int32.py +++ b/src/fable-library-py/fable_library/int32.py @@ -1,17 +1,47 @@ from typing import SupportsInt, overload -from .core import FSharpRef, int8, int16, int32 +from .core import FSharpRef, Int32, int8, int16, int32 from .core._core import get_range -from .core._core import parse_int32 as parse -from .core._core import try_parse_int32 as try_parse +from .core._core import parse_int32 as _parse_int32 +from .core._core import try_parse_int32 as _try_parse_int32 # Re-export constants for compatibility AllowHexSpecifier = 0x00000200 -def sign(x: int32) -> int32: - return int32.NEG_ONE if x < int32.ZERO else int32.ONE if x > int32.ZERO else int32.ZERO +def sign(x: int) -> int: + return -1 if x < 0 else 1 if x > 0 else 0 + + +def parse(string: str, style: int, radix: int = 10) -> int: + """`Int32.Parse`, returning a plain Python `int`. + + The wrapper class's `Int32.parse` hands back an `Int32` object. Int32 is + represented as a plain `int`, so parsing goes to the free function instead, which + already validates the result against the 32-bit range and returns a plain int. + """ + return _parse_int32(string, style, False, 32, radix) + + +def try_parse(string: str, style: int, def_value: FSharpRef[int]) -> bool: + """`Int32.TryParse`, storing a plain Python `int` in `def_value`. + + `Int32.try_parse` would store an `Int32` object instead. See `parse` above. + """ + return _try_parse_int32(string, style, False, 32, def_value) + + +def to_string(value: int, radix: int = 10) -> str: + """`Int32.ToString()`, optionally in another radix (2, 8, 10 or 16). + + Non-decimal radixes render the two's complement bit pattern, so this defers to + the wrapper rather than to Python's `format`, which would render a sign instead. + """ + if radix == 10: + return str(value) + + return Int32(value).to_string(radix) @overload @@ -23,9 +53,15 @@ def div_rem[T: SupportsInt](x: T, y: T, out: FSharpRef[T]) -> T: ... def div_rem[T: SupportsInt](x: T, y: T, out: FSharpRef[T] | None = None) -> T | tuple[T, T]: - # Rust wrapper types already use truncated division and remainder - q = x // y # type: ignore[operator] # ty: ignore[unsupported-operator] - r = x % y # type: ignore[operator] # ty: ignore[unsupported-operator] + if type(x) is int and type(y) is int: + # Plain ints are Int32 (or bigint). Python's // floors and % takes the sign of + # the divisor; .NET truncates toward zero and takes the sign of the dividend. + q = -((-x) // y) if (x < 0) != (y < 0) else x // y + r = x - q * y + else: + # The Rust wrapper types already use truncated division and remainder + q = x // y # type: ignore[operator] # ty: ignore[unsupported-operator] + r = x % y # type: ignore[operator] # ty: ignore[unsupported-operator] if out is None: return (q, r) out.contents = r @@ -40,8 +76,8 @@ def op_unary_negation_int16(x: int16) -> int16: return -x -def op_unary_negation_int32(x: int32) -> int32: - return -x +def op_unary_negation_int32(x: int) -> int: + return int32(-x) __all__ = [ diff --git a/src/fable-library-py/fable_library/list.pyi b/src/fable-library-py/fable_library/list.pyi index ebaaa3b368..376948af8b 100644 --- a/src/fable-library-py/fable_library/list.pyi +++ b/src/fable-library-py/fable_library/list.pyi @@ -1,5 +1,4 @@ from .array_ import Array -from .core import int32 from .option import Option from .protocols import IEnumerable_1 @@ -10,4 +9,4 @@ class FSharpList[T](IEnumerable_1[T]): def of_array[T](xs: Array[T]) -> FSharpList[T]: ... def length[T](xs: FSharpList[T]) -> int: ... def try_head[T](xs: FSharpList[T]) -> Option[T]: ... -def try_item[T](n: int32, xs: FSharpList[T]) -> Option[T]: ... +def try_item[T](n: int, xs: FSharpList[T]) -> Option[T]: ... diff --git a/src/fable-library-py/fable_library/long.py b/src/fable-library-py/fable_library/long.py index 6f12763c8a..90febfa89f 100644 --- a/src/fable-library-py/fable_library/long.py +++ b/src/fable-library-py/fable_library/long.py @@ -146,26 +146,26 @@ def div_rem(x: int64, y: int64, out: FSharpRef[int64] | None = None) -> int64 | @overload -def op_right_shift(a: int64, b: int32) -> int64: ... +def op_right_shift(a: int64, b: int) -> int64: ... @overload -def op_right_shift(a: uint64, b: int32) -> uint64: ... +def op_right_shift(a: uint64, b: int) -> uint64: ... -def op_right_shift(a: int64 | uint64, b: int32) -> int64 | uint64: +def op_right_shift(a: int64 | uint64, b: int) -> int64 | uint64: return a >> b @overload -def op_left_shift(a: int64, b: int32) -> int64: ... +def op_left_shift(a: int64, b: int) -> int64: ... @overload -def op_left_shift(a: uint64, b: int32) -> uint64: ... +def op_left_shift(a: uint64, b: int) -> uint64: ... -def op_left_shift(a: int64 | uint64, b: int32) -> int64 | uint64: +def op_left_shift(a: int64 | uint64, b: int) -> int64 | uint64: return a << b @@ -267,7 +267,7 @@ def from_number(value: SupportsInt, unsigned: bool) -> int64 | uint64: return uint64(value) if unsigned else int64(value) -def to_number(value: SupportsFloat | SupportsInt) -> float64: +def to_number(value: SupportsFloat | SupportsInt) -> float: return float64(value) diff --git a/src/fable-library-py/fable_library/protocols.py b/src/fable-library-py/fable_library/protocols.py index 61cf8bd1e5..49bd980945 100644 --- a/src/fable-library-py/fable_library/protocols.py +++ b/src/fable-library-py/fable_library/protocols.py @@ -47,7 +47,7 @@ def GetHashCode(self) -> int: ... class HashCode(Protocol): - def GetHashCode(self) -> int32: ... + def GetHashCode(self) -> int: ... # ============================================================================= @@ -93,7 +93,7 @@ class IComparer(Protocol): """ @abstractmethod - def Compare(self, x: Any, y: Any, /) -> int32: + def Compare(self, x: Any, y: Any, /) -> int: raise NotImplementedError @@ -104,7 +104,7 @@ class IComparer_1[T_in](Protocol): """ @abstractmethod - def Compare(self, x: T_in, y: T_in, /) -> int32: + def Compare(self, x: T_in, y: T_in, /) -> int: raise NotImplementedError @@ -124,7 +124,7 @@ def Equals(self, x: Any, y: Any, /) -> bool: raise NotImplementedError @abstractmethod - def GetHashCode(self, x: Any, /) -> int32: + def GetHashCode(self, x: Any, /) -> int: raise NotImplementedError @@ -139,7 +139,7 @@ def Equals(self, x: T_in, y: T_in, /) -> bool: raise NotImplementedError @abstractmethod - def GetHashCode(self, x: T_in, /) -> int32: + def GetHashCode(self, x: T_in, /) -> int: raise NotImplementedError @@ -160,7 +160,7 @@ def Equals(self, other: Any, comparer: IEqualityComparer) -> bool: raise NotImplementedError @abstractmethod - def GetHashCode(self, comparer: IEqualityComparer) -> int32: + def GetHashCode(self, comparer: IEqualityComparer) -> int: raise NotImplementedError @@ -168,7 +168,7 @@ class IStructuralComparable(Protocol): """Protocol for structural comparison.""" @abstractmethod - def CompareTo(self, other: Any, comparer: IComparer) -> int32: + def CompareTo(self, other: Any, comparer: IComparer) -> int: raise NotImplementedError @@ -189,7 +189,7 @@ class IGenericAverager[T](Protocol): def GetZero(self, __unit: Unit = UNIT) -> T: ... def Add(self, x: T, y: T, /) -> T: ... - def DivideByInt(self, x: T, i: int32, /) -> T: ... + def DivideByInt(self, x: T, i: int, /) -> T: ... # ============================================================================= diff --git a/src/fable-library-py/fable_library/record.py b/src/fable-library-py/fable_library/record.py index 01cf9fc7d1..12313ed374 100644 --- a/src/fable-library-py/fable_library/record.py +++ b/src/fable-library-py/fable_library/record.py @@ -129,7 +129,7 @@ def Equals(self, other: object) -> bool: # Hashable (HashableBase provides __hash__ from GetHashCode) # ------------------------------------------------------------------------- - def GetHashCode(self) -> int32: + def GetHashCode(self) -> int: return int32(record_get_hashcode(self)) def __hash__(self) -> int: diff --git a/src/fable-library-py/fable_library/reflection.py b/src/fable-library-py/fable_library/reflection.py index a22d5775fc..071b0636d8 100644 --- a/src/fable-library-py/fable_library/reflection.py +++ b/src/fable-library-py/fable_library/reflection.py @@ -67,7 +67,7 @@ def __eq__(self, other: Any) -> bool: return equals(self, other) def __hash__(self) -> int: - hashes: list[int32] = [int32(hash(x)) for x in self.generics or []] + hashes: list[int] = [int32(hash(x)) for x in self.generics or []] hashes.append(int32(hash(self.fullname))) return int(combine_hash_codes(hashes)) diff --git a/src/fable-library-py/fable_library/resize_array.py b/src/fable-library-py/fable_library/resize_array.py index 9e4eef52ec..2710dac9f6 100644 --- a/src/fable-library-py/fable_library/resize_array.py +++ b/src/fable-library-py/fable_library/resize_array.py @@ -19,7 +19,7 @@ def exists[T](predicate: Callable[[T], bool], xs: list[T]) -> bool: return any(predicate(x) for x in xs) -def find_index[T](predicate: Callable[[T], bool], xs: list[T]) -> int32: +def find_index[T](predicate: Callable[[T], bool], xs: list[T]) -> int: """Find the index of the first element in a list that satisfies a predicate.""" for i, x in enumerate(xs): @@ -37,13 +37,13 @@ def remove[T](item: T, xs: list[T]) -> bool: return False -def remove_range[T](start: int32, count: int32, xs: list[T]) -> None: +def remove_range[T](start: int, count: int, xs: list[T]) -> None: """Remove a range of elements from a list in-place.""" del xs[start : start + count] -def remove_all_in_place[T](predicate: Callable[[T], bool], xs: list[T]) -> int32: +def remove_all_in_place[T](predicate: Callable[[T], bool], xs: list[T]) -> int: """Remove all elements matching predicate from the list in-place. Returns the number of removed elements.""" # More efficient O(n) approach: build list of items to keep original_length = len(xs) @@ -61,7 +61,7 @@ def remove_all_in_place[T](predicate: Callable[[T], bool], xs: list[T]) -> int32 return int32(original_length - write_index) -def find_last_index[T](predicate: Callable[[T], bool], xs: list[T]) -> int32: +def find_last_index[T](predicate: Callable[[T], bool], xs: list[T]) -> int: """Find the index of the last element in a list that satisfies a predicate.""" for i in range(len(xs) - 1, -1, -1): if predicate(xs[i]): @@ -90,7 +90,7 @@ def filter[T](predicate: Callable[[T], bool], xs: list[T]) -> list[T]: return [x for x in xs if predicate(x)] -def index_of[T](value: T, start: int32, count: int32 | None, xs: list[T]) -> int32: +def index_of[T](value: T, start: int, count: int | None, xs: list[T]) -> int: """Return the index of value in xs, or -1 if not found. Specify start and count.""" end = min(len(xs), start + count if count is not None else len(xs)) try: @@ -100,7 +100,7 @@ def index_of[T](value: T, start: int32, count: int32 | None, xs: list[T]) -> int return int32(-1) -def insert_range_in_place[T](index: int32, items: Iterable[T] | IEnumerable_1[T], xs: list[T]) -> None: +def insert_range_in_place[T](index: int, items: Iterable[T] | IEnumerable_1[T], xs: list[T]) -> None: """Insert a range of items into xs at the given index.""" xs[index:index] = to_iterable(items) @@ -116,12 +116,12 @@ def add_range_in_place[T](items: Iterable[T] | IEnumerable_1[T], array: list[T]) array.extend(to_iterable(items)) -def add_range[T](index: int32, items: list[T], xs: list[T]) -> list[T]: +def add_range[T](index: int, items: list[T], xs: list[T]) -> list[T]: """Add a range of items to xs at the given index.""" return xs[:index] + items + xs[index:] -def get_sub_array[T](xs: list[T], start: int32, count: int32) -> list[T]: +def get_sub_array[T](xs: list[T], start: int, count: int) -> list[T]: """Get a sub-array of xs from the given start index and count.""" return xs[start : start + count] diff --git a/src/fable-library-py/fable_library/seq_native.py b/src/fable-library-py/fable_library/seq_native.py index c43d01183b..1e2342442b 100644 --- a/src/fable-library-py/fable_library/seq_native.py +++ b/src/fable-library-py/fable_library/seq_native.py @@ -124,7 +124,7 @@ def gen() -> Iterator[U]: return NativeSeq(gen) -def map_indexed[T, U](mapping: Callable[[int32, T], U], xs: IEnumerable_1[T]) -> NativeSeq[U]: +def map_indexed[T, U](mapping: Callable[[int, T], U], xs: IEnumerable_1[T]) -> NativeSeq[U]: return NativeSeq(lambda: (mapping(int32(i), x) for i, x in enumerate(_source_iter(xs)))) @@ -133,7 +133,7 @@ def map2[T1, T2, U](mapping: Callable[[T1, T2], U], xs: IEnumerable_1[T1], ys: I def map_indexed2[T1, T2, U]( - mapping: Callable[[int32, T1, T2], U], xs: IEnumerable_1[T1], ys: IEnumerable_1[T2] + mapping: Callable[[int, T1, T2], U], xs: IEnumerable_1[T1], ys: IEnumerable_1[T2] ) -> NativeSeq[U]: return NativeSeq( lambda: (mapping(int32(i), x, y) for i, (x, y) in enumerate(zip(_source_iter(xs), _source_iter(ys)))) @@ -190,7 +190,7 @@ def head[T](xs: IEnumerable_1[T]) -> T: return value(result) -def try_item[T](index: int32, xs: IEnumerable_1[T]) -> Option[T]: +def try_item[T](index: int, xs: IEnumerable_1[T]) -> Option[T]: match xs: case Array(): return array_try_item(index, xs) @@ -209,7 +209,7 @@ def try_item[T](index: int32, xs: IEnumerable_1[T]) -> Option[T]: return None if v is _SENTINEL else some(v) -def item[T](index: int32, xs: IEnumerable_1[T]) -> T: +def item[T](index: int, xs: IEnumerable_1[T]) -> T: result = try_item(index, xs) if result is None: raise Exception("The input sequence has an insufficient number of elements. (Parameter 'index')") diff --git a/src/fable-library-py/fable_library/string_.py b/src/fable-library-py/fable_library/string_.py index 8c45ead7ae..27c7670e33 100644 --- a/src/fable-library-py/fable_library/string_.py +++ b/src/fable-library-py/fable_library/string_.py @@ -151,7 +151,7 @@ def index_of_any(string: str, any_of: Array[str], *args: int) -> int: return -1 -def get_length(string: str) -> int32: +def get_length(string: str) -> int: """Get string length as int32 for F# interop.""" return int32(len(string)) diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py index c0147bc8f0..de98afc314 100644 --- a/src/fable-library-py/fable_library/time_only.py +++ b/src/fable-library-py/fable_library/time_only.py @@ -71,19 +71,19 @@ def min_value() -> TimeSpan: return TimeSpan(0) -def hour(t: TimeSpan) -> int32: +def hour(t: TimeSpan) -> int: return int32(hours(t)) -def minute(t: TimeSpan) -> int32: +def minute(t: TimeSpan) -> int: return int32(minutes(t)) -def second(t: TimeSpan) -> int32: +def second(t: TimeSpan) -> int: return int32(seconds(t)) -def millisecond(t: TimeSpan) -> int32: +def millisecond(t: TimeSpan) -> int: return int32(milliseconds(t)) diff --git a/src/fable-library-py/fable_library/time_span.py b/src/fable-library-py/fable_library/time_span.py index 2a10084411..3467ce29ad 100644 --- a/src/fable-library-py/fable_library/time_span.py +++ b/src/fable-library-py/fable_library/time_span.py @@ -47,32 +47,32 @@ def create( ) -def total_nanoseconds(ts: TimeSpan) -> float64: +def total_nanoseconds(ts: TimeSpan) -> float: # We store timespan as the Tick value so nanoseconds step is 100 return float64(ts) * 100 -def total_microseconds(ts: TimeSpan) -> float64: +def total_microseconds(ts: TimeSpan) -> float: return float64(ts) / 10 -def total_milliseconds(ts: TimeSpan) -> float64: +def total_milliseconds(ts: TimeSpan) -> float: return float64(ts) / 10000 -def total_seconds(ts: TimeSpan) -> float64: +def total_seconds(ts: TimeSpan) -> float: return float64(ts) / 10000000 -def total_minutes(ts: TimeSpan) -> float64: +def total_minutes(ts: TimeSpan) -> float: return float64(ts) / 600000000 -def total_hours(ts: TimeSpan) -> float64: +def total_hours(ts: TimeSpan) -> float: return float64(ts) / 36000000000 -def total_days(ts: TimeSpan) -> float64: +def total_days(ts: TimeSpan) -> float: return float64(ts) / 864000000000 diff --git a/src/fable-library-py/fable_library/types.py b/src/fable-library-py/fable_library/types.py index 6ae62d5e5c..1d140768dd 100644 --- a/src/fable-library-py/fable_library/types.py +++ b/src/fable-library-py/fable_library/types.py @@ -9,7 +9,9 @@ from typing import Any from .core import ( + Float64, FSharpRef, + Int32, byte, float32, float64, @@ -58,9 +60,14 @@ def __init__(self, message: str | None = None, inner_exception: Exception | None self.inner_exception: Exception | None = inner_exception -# We don't use type aliases here because we need to do isinstance checks -IntegerTypes = int | byte | sbyte | int16 | uint16 | int32 | uint32 | int64 | uint64 -FloatTypes = float | float32 | float64 +# We don't use type aliases here because we need to do isinstance checks. +# +# Generated code represents Int32 as a plain `int` and Float64 as a plain `float`, so +# those builtins come first. The `Int32`/`Float64` classes stay in the unions because +# hand-written interop can still hold one, and neither is a subclass of its builtin -- +# dropping them would make `isinstance` silently miss those values. +IntegerTypes = int | Int32 | byte | sbyte | int16 | uint16 | uint32 | int64 | uint64 +FloatTypes = float | Float64 | float32 __all__ = [ @@ -68,7 +75,9 @@ def __init__(self, message: str | None = None, inner_exception: Exception | None "Attribute", "ExceptionBase", "FSharpRef", + "Float64", "FloatTypes", + "Int32", "IntegerTypes", "Unit", "byte", diff --git a/src/fable-library-py/fable_library/union.py b/src/fable-library-py/fable_library/union.py index bd20a4f99e..b85f33843d 100644 --- a/src/fable-library-py/fable_library/union.py +++ b/src/fable-library-py/fable_library/union.py @@ -26,7 +26,7 @@ class Union(StringableBase, EquatableBase, ComparableBase, HashableBase, ICompar __slots__: list[str] = ["fields", "tag"] - tag: int32 + tag: int fields: Array[Any] def __init__(self) -> None: @@ -90,7 +90,7 @@ def Equals(self, other: Any) -> bool: # Hashable (HashableBase provides __hash__ from GetHashCode) # ------------------------------------------------------------------------- - def GetHashCode(self) -> int32: + def GetHashCode(self) -> int: # Hash the tag and each field structurally instead of hashing a Python # tuple, which would hash fields that only implement F# `GetHashCode` # by identity. diff --git a/src/fable-library-py/fable_library/uri.py b/src/fable-library-py/fable_library/uri.py index 5400b35d40..1b71567eb5 100644 --- a/src/fable-library-py/fable_library/uri.py +++ b/src/fable-library-py/fable_library/uri.py @@ -98,11 +98,11 @@ def __str__(self) -> str: return unquote(res.geturl()) @staticmethod - def create(uri: str | Uri, kind_or_uri: UriKind | Uri | str | int32 = UriKind.Absolute) -> Uri: + def create(uri: str | Uri, kind_or_uri: UriKind | Uri | str | int = UriKind.Absolute) -> Uri: return Uri(uri, kind_or_uri) @staticmethod - def try_create(uri: str | Uri, kind_or_uri: UriKind | Uri | str | int32, out: FSharpRef[Uri]) -> bool: + def try_create(uri: str | Uri, kind_or_uri: UriKind | Uri | str | int, out: FSharpRef[Uri]) -> bool: try: out.contents = Uri.create(uri, kind_or_uri) return True diff --git a/src/fable-library-py/fable_library/util.py b/src/fable-library-py/fable_library/util.py index 49b9cc9a45..7e48d5eaf9 100644 --- a/src/fable-library-py/fable_library/util.py +++ b/src/fable-library-py/fable_library/util.py @@ -179,9 +179,9 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: class DateKind: - Unspecified: Final[int32] = int32(0) - UTC: Final[int32] = int32(1) - Local: Final[int32] = int32(2) + Unspecified: Final[int] = int32(0) + UTC: Final[int] = int32(1) + Local: Final[int] = int32(2) def equals(a: Any, b: Any) -> bool: @@ -307,7 +307,7 @@ def equal_arrays[T](x: Sequence[T], y: Sequence[T]) -> bool: return equal_arrays_with(x, y, equals) -def compare_primitives[TSupportsLessThan: SupportsLessThan](x: TSupportsLessThan, y: TSupportsLessThan) -> int32: +def compare_primitives[TSupportsLessThan: SupportsLessThan](x: TSupportsLessThan, y: TSupportsLessThan) -> int: if x == y: return int32(0) if x < y: @@ -447,12 +447,12 @@ def int_to_string(i: int, radix: int = 10, bitsize: int | None = None) -> str: return str(i) -def count(it: IEnumerable_1[Any] | Iterable[Any]) -> int32: +def count(it: IEnumerable_1[Any] | Iterable[Any]) -> int: it = to_iterable(it) if isinstance(it, Sized): return int32(len(it)) - count = int32.ZERO + count = 0 for _ in it: count += 1 @@ -621,7 +621,7 @@ def id(o: Any) -> int: return ObjectRef.id_map[_id] -def safe_hash(x: Any) -> int32: +def safe_hash(x: Any) -> int: """Hash a declared type that may or may not implement GetHashCode. F# `GetHashCode` takes precedence over Python's `__hash__`: an F# class @@ -632,19 +632,23 @@ def safe_hash(x: Any) -> int32: return identity_hash(x) -def string_hash(s: str) -> int32: +def string_hash(s: str) -> int: h = 5381 for c in s: - h = (h * 33) ^ ord(c) + # Normalized each step: plain ints are arbitrary precision, and this would + # otherwise grow ~5 bits per character. Truncation commutes with `*` and + # `^`, so this gives the same answer as normalizing once at the end. The + # mask is inlined because this overflows on nearly every iteration. + h = ((h * 33) ^ ord(c)) & 4294967295 - return int32(h) + return h - 4294967296 if h > 2147483647 else h -def number_hash(x: Any) -> int32: +def number_hash(x: Any) -> int: return x.GetHashCode() if hasattr(x, "GetHashCode") else int32(hash(x)) -def identity_hash(x: Any) -> int32: +def identity_hash(x: Any) -> int: if x is None: return int32(0) @@ -657,14 +661,16 @@ def identity_hash(x: Any) -> int32: return number_hash(ObjectRef.id(x)) -def combine_hash_codes(hashes: list[int32]) -> int32: +def combine_hash_codes(hashes: list[int]) -> int: if not hashes: return int32(0) - return functools.reduce(lambda h1, h2: ((h1 << 5) + h1) ^ h2, hashes) + combined = functools.reduce(lambda h1, h2: (((h1 << 5) + h1) ^ h2) & 4294967295, hashes) + return combined - 4294967296 if combined > 2147483647 else combined -def structural_hash(x: Any) -> int32: + +def structural_hash(x: Any) -> int: """Hash a value using F# structural semantics. Like `safe_hash`, F# `GetHashCode` wins over Python's `__hash__` so that @@ -673,10 +679,10 @@ def structural_hash(x: Any) -> int32: back to identity for their items. """ if x is None: - return int32.ZERO + return 0 if isinstance(x, bool): - return int32.ONE if x else int32.ZERO + return 1 if x else 0 if isinstance(x, str): return string_hash(x) @@ -696,15 +702,15 @@ def structural_hash(x: Any) -> int32: return number_hash(ObjectRef.id(x)) -def array_hash(xs: Iterable[object]) -> int32: - hashes: list[int32] = [] +def array_hash(xs: Iterable[object]) -> int: + hashes: list[int] = [] for x in xs: hashes.append(structural_hash(x)) return combine_hash_codes(hashes) -def physical_hash(x: Any) -> int32: +def physical_hash(x: Any) -> int: return number_hash(ObjectRef.id(x)) @@ -713,25 +719,32 @@ def round(value: float32, digits: int = 0) -> float32: ... @overload -def round(value: float64, digits: int = 0) -> float64: ... +def round(value: float, digits: int = 0) -> float: ... + + +def round(value: float | float32, digits: int = 0) -> float | float32: + if isinstance(value, float32): + return value.round(digits) + # Float64 is a plain Python float. Same scaling algorithm as the wrapper: + # builtins.round without ndigits rounds half to even, as .NET does. + factor = 10.0**digits -def round(value: float64 | float32, digits: int = 0) -> float64 | float32: - return value.round(digits) + return builtins.round(value * factor) / factor -def create_random(seed: int32 | None = None) -> random.Random: +def create_random(seed: int | None = None) -> random.Random: return random.Random(None if seed is None else int(seed)) -def random_int(rand: random.Random, a: int32, b: int32) -> int32: +def random_int(rand: random.Random, a: int, b: int) -> int: if a == b: return int32(a) return int32(rand.randrange(int(a), int(b))) -def random_double(rand: random.Random) -> float64: +def random_double(rand: random.Random) -> float: return float64(rand.random()) @@ -893,7 +906,7 @@ def __setattr__(cls, name: str, value: Any) -> None: super().__setattr__(name, value) -def range(start: int, stop: int, step: int = 1) -> Iterable[int32]: +def range(start: int, stop: int, step: int = 1) -> Iterable[int]: """Range function that returns an iterable of int32 values. This function handles the difference between F# and Python range semantics: diff --git a/src/fable-library-py/pyproject.toml b/src/fable-library-py/pyproject.toml index 10c5d2f596..7a09c5ca85 100644 --- a/src/fable-library-py/pyproject.toml +++ b/src/fable-library-py/pyproject.toml @@ -36,6 +36,10 @@ include = ["./fable_library"] python-source = "." # Look at project root module-name = "fable_library.core._core" # Full module path features = ["pyo3/extension-module"] +# Build release by default. A debug `_core` is roughly 10x slower, and a bare +# `maturin develop` or `uv sync` would otherwise clobber the release build that +# `./build.sh fable-library --python` installs. +profile = "release" [tool.ty.environment] python-version = "3.12" diff --git a/src/fable-library-py/src/array.rs b/src/fable-library-py/src/array.rs index 498a304703..4e7ff4e4d8 100644 --- a/src/fable-library-py/src/array.rs +++ b/src/fable-library-py/src/array.rs @@ -6,7 +6,7 @@ use crate::types::FSharpRef; use crate::util::{DefaultComparer, ProjectionComparer}; use pyo3::class::basic::CompareOp; use pyo3::types::PyNotImplemented; -use pyo3::types::{PyBool, PyInt}; +use pyo3::types::{PyBool, PyFloat, PyInt}; use pyo3::types::{PyBytes, PyTuple, PyType}; use pyo3::BoundObject; use pyo3::{exceptions, IntoPyObjectExt, PyTypeInfo}; @@ -265,12 +265,16 @@ impl FSharpArray { py: Python<'_>, ) -> PyResult> { // Get type name - either from string or from type.__name__ + // `int32` is a normalizing function rather than a class, so `__name__` is read + // off whatever was passed rather than only off a type object. let type_name: Option = if let Ok(s) = item.extract::() { Some(s) } else if let Ok(py_type) = item.cast::() { py_type.getattr("__name__")?.extract()? } else { - None + item.getattr("__name__") + .ok() + .and_then(|name| name.extract::().ok()) }; // Match on the type name @@ -279,12 +283,13 @@ impl FSharpArray { Some("uint8") | Some("byte") => UInt8Array::type_object(py), Some("int16") => Int16Array::type_object(py), Some("uint16") => UInt16Array::type_object(py), - Some("int32") => Int32Array::type_object(py), + // `int` and `float` are the representations of Int32 and Float64 + Some("int32") | Some("int") => Int32Array::type_object(py), Some("uint32") => UInt32Array::type_object(py), Some("int64") => Int64Array::type_object(py), Some("uint64") => UInt64Array::type_object(py), Some("float32") => Float32Array::type_object(py), - Some("float64") => Float64Array::type_object(py), + Some("float64") | Some("float") => Float64Array::type_object(py), Some("bool") => BoolArray::type_object(py), _ => GenericArray::type_object(py), }; @@ -368,6 +373,27 @@ impl FSharpArray { }); } + // Plain Python `int`/`float` are how Int32 and Float64 values are represented, + // so they specialize to the corresponding storage rather than falling back to + // boxed generic storage. This is checked after `bool`, which subclasses `int` + // and must keep its own storage, and after the narrower widths, whose wrapper + // types do not accept a plain int. + if let Ok(int_val) = value.cast::() { + if let Ok(i32_val) = int_val.extract::() { + let mut vec = Vec::with_capacity(count); + vec.resize(count, i32_val); + return Ok(FSharpArray { + storage: NativeArray::Int32(vec), + }); + } + } else if let Ok(float_val) = value.cast::() { + let mut vec = Vec::with_capacity(count); + vec.resize(count, float_val.value()); + return Ok(FSharpArray { + storage: NativeArray::Float64(vec), + }); + } + // Fallback to generic PyObject storage let mut vec = Vec::with_capacity(count); for _ in 0..count { @@ -474,13 +500,24 @@ impl FSharpArray { self.storage.len() } - /// Returns the length of the array as Int32 (F# compatible). + /// Returns the length of the array. + /// + /// In F# `Array.length` returns an int32, which is represented as a plain + /// Python `int`. A length always fits, so no normalization is needed. + #[getter] + pub fn length(&self) -> usize { + self.storage.len() + } + + /// Returns the name of the backing storage: `"Int32"`, `"Float64"`, `"Generic"`, ... /// - /// This property provides F# interop compatibility by returning the array length - /// as an Int32 instead of Python's native int. In F#, Array.length returns int32. + /// Element storage is chosen by inspecting the values an array is built from, so a + /// change in how values are represented can silently downgrade a specialized array + /// to boxed `Generic` storage. Exposing the storage kind lets tests assert the + /// specialization directly instead of only checking the values that come back out. #[getter] - pub fn length(&self) -> Int32 { - Int32(self.storage.len() as i32) + pub fn storage_type(&self) -> &str { + self.storage.type_name() } /// Returns an iterator over the array elements. diff --git a/src/fable-library-py/src/floats.rs b/src/fable-library-py/src/floats.rs index 63d1968bf9..1019127ce2 100644 --- a/src/fable-library-py/src/floats.rs +++ b/src/fable-library-py/src/floats.rs @@ -621,128 +621,128 @@ float_variant!(Float64, f64); // Free functions for mathematical operations #[pyfunction] -pub fn abs(x: &Bound<'_, PyAny>) -> PyResult { +pub fn abs(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(Float64(x_val.0.abs())); + return Ok(x_val.0.abs()); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val.abs())) + Ok(x_val.abs()) } #[pyfunction] -pub fn sqrt(x: &Bound<'_, PyAny>) -> PyResult { +pub fn sqrt(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return x_val.sqrt(); + return Ok(x_val.sqrt()?.0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Float64(x_val).sqrt() + Ok(Float64(x_val).sqrt()?.0) } #[pyfunction] -pub fn cos(x: &Bound<'_, PyAny>) -> PyResult { +pub fn cos(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.cos()); + return Ok(x_val.cos().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).cos()) + Ok(Float64(x_val).cos().0) } #[pyfunction] -pub fn sin(x: &Bound<'_, PyAny>) -> PyResult { +pub fn sin(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.sin()); + return Ok(x_val.sin().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).sin()) + Ok(Float64(x_val).sin().0) } #[pyfunction] -pub fn tan(x: &Bound<'_, PyAny>) -> PyResult { +pub fn tan(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.tan()); + return Ok(x_val.tan().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).tan()) + Ok(Float64(x_val).tan().0) } #[pyfunction] -pub fn cosh(x: &Bound<'_, PyAny>) -> PyResult { +pub fn cosh(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.cosh()); + return Ok(x_val.cosh().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).cosh()) + Ok(Float64(x_val).cosh().0) } #[pyfunction] -pub fn sinh(x: &Bound<'_, PyAny>) -> PyResult { +pub fn sinh(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.sinh()); + return Ok(x_val.sinh().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).sinh()) + Ok(Float64(x_val).sinh().0) } #[pyfunction] -pub fn tanh(x: &Bound<'_, PyAny>) -> PyResult { +pub fn tanh(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.tanh()); + return Ok(x_val.tanh().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).tanh()) + Ok(Float64(x_val).tanh().0) } #[pyfunction] -pub fn acos(x: &Bound<'_, PyAny>) -> PyResult { +pub fn acos(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return x_val.acos(); + return Ok(x_val.acos()?.0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Float64(x_val).acos() + Ok(Float64(x_val).acos()?.0) } #[pyfunction] -pub fn asin(x: &Bound<'_, PyAny>) -> PyResult { +pub fn asin(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return x_val.asin(); + return Ok(x_val.asin()?.0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Float64(x_val).asin() + Ok(Float64(x_val).asin()?.0) } #[pyfunction] -pub fn atan(x: &Bound<'_, PyAny>) -> PyResult { +pub fn atan(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.atan()); + return Ok(x_val.atan().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).atan()) + Ok(Float64(x_val).atan().0) } #[pyfunction] -pub fn atan2(py: Python<'_>, y: &Bound<'_, PyAny>, x: &Bound<'_, PyAny>) -> PyResult> { +pub fn atan2(_py: Python<'_>, y: &Bound<'_, PyAny>, x: &Bound<'_, PyAny>) -> PyResult { // Fast path: y is already our type let y_val = if let Ok(y_val) = y.extract::() { y_val @@ -758,27 +758,27 @@ pub fn atan2(py: Python<'_>, y: &Bound<'_, PyAny>, x: &Bound<'_, PyAny>) -> PyRe Float64(x.extract::()?) }; let result = y_val.atan2(*x_val)?; - Ok(result.into_pyobject(py)?.into()) + Ok(result.0) } #[pyfunction] -pub fn exp(x: &Bound<'_, PyAny>) -> PyResult { +pub fn exp(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.exp()); + return Ok(x_val.exp().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).exp()) + Ok(Float64(x_val).exp().0) } #[pyfunction] #[pyo3(signature = (x, base = None))] pub fn log( - py: Python<'_>, + _py: Python<'_>, x: &Bound<'_, PyAny>, base: Option<&Bound<'_, PyAny>>, -) -> PyResult> { +) -> PyResult { // Fast path: x is already our type let f64_val = if let Ok(f64_val) = x.extract::() { f64_val @@ -791,33 +791,33 @@ pub fn log( None => None, }; let result = f64_val.log(base)?; - Ok(result.into_pyobject(py)?.into()) + Ok(result.0) } #[pyfunction] -pub fn log10(x: &Bound<'_, PyAny>) -> PyResult { +pub fn log10(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.log10()); + return Ok(x_val.log10().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).log10()) + Ok(Float64(x_val).log10().0) } #[pyfunction] -pub fn log2(x: &Bound<'_, PyAny>) -> PyResult { +pub fn log2(x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return Ok(x_val.log2()); + return Ok(x_val.log2().0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Ok(Float64(x_val).log2()) + Ok(Float64(x_val).log2().0) } #[pyfunction] -pub fn degrees(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { +pub fn degrees(_py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type let f64_val = if let Ok(f64_val) = x.extract::() { f64_val @@ -826,11 +826,11 @@ pub fn degrees(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { Float64(x.extract::()?) }; let result = f64_val.degrees(); - Ok(result.into_pyobject(py)?.into()) + Ok(result.0) } #[pyfunction] -pub fn radians(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { +pub fn radians(_py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type let f64_val = if let Ok(f64_val) = x.extract::() { f64_val @@ -839,7 +839,7 @@ pub fn radians(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { Float64(x.extract::()?) }; let result = f64_val.radians(); - Ok(result.into_pyobject(py)?.into()) + Ok(result.0) } #[pyfunction] @@ -891,7 +891,7 @@ pub fn is_negative_infinity(_py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult, x: &Bound<'_, PyAny>) -> PyResult> { +pub fn floor(_py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type let f64_val = if let Ok(f64_val) = x.extract::() { f64_val @@ -900,11 +900,11 @@ pub fn floor(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { Float64(x.extract::()?) }; let result = f64_val.floor(); - Ok(result.into_pyobject(py)?.into()) + Ok(result.0) } #[pyfunction] -pub fn ceil(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { +pub fn ceil(_py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type let f64_val = if let Ok(f64_val) = x.extract::() { f64_val @@ -913,24 +913,23 @@ pub fn ceil(py: Python<'_>, x: &Bound<'_, PyAny>) -> PyResult> { Float64(x.extract::()?) }; let result = f64_val.ceil(); - Ok(result.into_pyobject(py)?.into()) + Ok(result.0) } #[pyfunction] -pub fn pow(x: &Bound<'_, PyAny>, y: &Bound<'_, PyAny>) -> PyResult { +pub fn pow(x: &Bound<'_, PyAny>, y: &Bound<'_, PyAny>) -> PyResult { // Fast path: x is already our type if let Ok(x_val) = x.extract::() { - return x_val.__pow__(y, None); + return Ok(x_val.__pow__(y, None)?.0); } // Slow path: extract as primitive let x_val = x.extract::()?; - Float64(x_val).__pow__(y, None) + Ok(Float64(x_val).__pow__(y, None)?.0) } #[pyfunction] -pub fn parse(x: &str) -> PyResult { - let value = x.trim().parse::()?; - Ok(Float64(value)) +pub fn parse(x: &str) -> PyResult { + Ok(x.trim().parse::()?) } #[pyfunction] diff --git a/src/fable-library-py/src/ints.rs b/src/fable-library-py/src/ints.rs index 4525c88502..6580ed1b2f 100644 --- a/src/fable-library-py/src/ints.rs +++ b/src/fable-library-py/src/ints.rs @@ -660,9 +660,10 @@ macro_rules! integer_variant { /// Left shift operator (<<). /// - /// Performs left rotation to handle overflow gracefully. + /// Bits shifted out of the width are discarded, and the shift count is + /// masked to the width, matching .NET's `<<` on sized integers. pub fn __lshift__(&self, other: u32) -> PyResult<$name> { - Ok($name(self.0.rotate_left(other))) + Ok($name(self.0.wrapping_shl(other))) } /// Right-hand left shift operator. diff --git a/src/fable-library-py/src/native_array.rs b/src/fable-library-py/src/native_array.rs index 4a8992c12a..09d8810b30 100644 --- a/src/fable-library-py/src/native_array.rs +++ b/src/fable-library-py/src/native_array.rs @@ -166,11 +166,18 @@ impl Clone for NativeArray { } // Helper macro for filling storage +// +// The wrapper type is tried first so a same-type value costs no conversion dunder; +// the primitive fallback accepts a plain Python int/float, which is how Int32 and +// Float64 values are represented. macro_rules! fill_typed_vec { - ($vec:expr, $value:expr, $target_index:expr, $count:expr, $type:ty) => {{ - let typed_value: $type = $value.extract()?; + ($vec:expr, $value:expr, $target_index:expr, $count:expr, $type:ty, $prim:ty) => {{ + let typed_value: $prim = match $value.extract::<$type>() { + Ok(wrapped) => *wrapped, + Err(_) => $value.extract::<$prim>()?, + }; for i in 0..$count { - $vec[$target_index + i] = *typed_value; + $vec[$target_index + i] = typed_value; } }}; } @@ -184,11 +191,14 @@ macro_rules! reverse_vec { }}; } -// Helper macro for inserting values +// Helper macro for inserting values (see `fill_typed_vec!` for the fallback rationale) macro_rules! insert_typed_value { - ($vec:expr, $index:expr, $value:expr, $type:ty) => {{ - let typed_value: $type = $value.extract()?; - $vec.insert($index, *typed_value); + ($vec:expr, $index:expr, $value:expr, $type:ty, $prim:ty) => {{ + let typed_value: $prim = match $value.extract::<$type>() { + Ok(wrapped) => *wrapped, + Err(_) => $value.extract::<$prim>()?, + }; + $vec.insert($index, typed_value); }}; } @@ -586,16 +596,26 @@ impl NativeArray { value: &Bound<'_, PyAny>, ) -> PyResult<()> { match storage { - NativeArray::Int8(vec) => fill_typed_vec!(vec, value, target_index, count, Int8), - NativeArray::UInt8(vec) => fill_typed_vec!(vec, value, target_index, count, UInt8), - NativeArray::Int16(vec) => fill_typed_vec!(vec, value, target_index, count, Int16), - NativeArray::UInt16(vec) => fill_typed_vec!(vec, value, target_index, count, UInt16), - NativeArray::Int32(vec) => fill_typed_vec!(vec, value, target_index, count, Int32), - NativeArray::UInt32(vec) => fill_typed_vec!(vec, value, target_index, count, UInt32), - NativeArray::Int64(vec) => fill_typed_vec!(vec, value, target_index, count, Int64), - NativeArray::UInt64(vec) => fill_typed_vec!(vec, value, target_index, count, UInt64), - NativeArray::Float32(vec) => fill_typed_vec!(vec, value, target_index, count, Float32), - NativeArray::Float64(vec) => fill_typed_vec!(vec, value, target_index, count, Float64), + NativeArray::Int8(vec) => fill_typed_vec!(vec, value, target_index, count, Int8, i8), + NativeArray::UInt8(vec) => fill_typed_vec!(vec, value, target_index, count, UInt8, u8), + NativeArray::Int16(vec) => fill_typed_vec!(vec, value, target_index, count, Int16, i16), + NativeArray::UInt16(vec) => { + fill_typed_vec!(vec, value, target_index, count, UInt16, u16) + } + NativeArray::Int32(vec) => fill_typed_vec!(vec, value, target_index, count, Int32, i32), + NativeArray::UInt32(vec) => { + fill_typed_vec!(vec, value, target_index, count, UInt32, u32) + } + NativeArray::Int64(vec) => fill_typed_vec!(vec, value, target_index, count, Int64, i64), + NativeArray::UInt64(vec) => { + fill_typed_vec!(vec, value, target_index, count, UInt64, u64) + } + NativeArray::Float32(vec) => { + fill_typed_vec!(vec, value, target_index, count, Float32, f32) + } + NativeArray::Float64(vec) => { + fill_typed_vec!(vec, value, target_index, count, Float64, f64) + } NativeArray::Bool(vec) => { let value: bool = value.extract()?; for i in 0..count { @@ -680,16 +700,16 @@ impl NativeArray { _py: Python<'_>, ) -> PyResult<()> { match self { - NativeArray::Int8(vec) => insert_typed_value!(vec, index, value, Int8), - NativeArray::UInt8(vec) => insert_typed_value!(vec, index, value, UInt8), - NativeArray::Int16(vec) => insert_typed_value!(vec, index, value, Int16), - NativeArray::UInt16(vec) => insert_typed_value!(vec, index, value, UInt16), - NativeArray::Int32(vec) => insert_typed_value!(vec, index, value, Int32), - NativeArray::UInt32(vec) => insert_typed_value!(vec, index, value, UInt32), - NativeArray::Int64(vec) => insert_typed_value!(vec, index, value, Int64), - NativeArray::UInt64(vec) => insert_typed_value!(vec, index, value, UInt64), - NativeArray::Float32(vec) => insert_typed_value!(vec, index, value, Float32), - NativeArray::Float64(vec) => insert_typed_value!(vec, index, value, Float64), + NativeArray::Int8(vec) => insert_typed_value!(vec, index, value, Int8, i8), + NativeArray::UInt8(vec) => insert_typed_value!(vec, index, value, UInt8, u8), + NativeArray::Int16(vec) => insert_typed_value!(vec, index, value, Int16, i16), + NativeArray::UInt16(vec) => insert_typed_value!(vec, index, value, UInt16, u16), + NativeArray::Int32(vec) => insert_typed_value!(vec, index, value, Int32, i32), + NativeArray::UInt32(vec) => insert_typed_value!(vec, index, value, UInt32, u32), + NativeArray::Int64(vec) => insert_typed_value!(vec, index, value, Int64, i64), + NativeArray::UInt64(vec) => insert_typed_value!(vec, index, value, UInt64, u64), + NativeArray::Float32(vec) => insert_typed_value!(vec, index, value, Float32, f32), + NativeArray::Float64(vec) => insert_typed_value!(vec, index, value, Float64, f64), NativeArray::Bool(vec) => { let bool_value: bool = value.extract()?; vec.insert(index, bool_value); @@ -763,7 +783,9 @@ impl NativeArray { Ok(()) } NativeArray::Int32(vec) => { - sort_with_error_capture!(vec, comparer, py, |v: i32, py| Int32(v) + // Int32 is represented as a plain Python `int`, so the comparer -- which + // is generated F# code -- must be handed the plain value + sort_with_error_capture!(vec, comparer, py, |v: i32, py| v .into_pyobject(py) .map(|o| o.into_any())); Ok(()) @@ -793,7 +815,8 @@ impl NativeArray { Ok(()) } NativeArray::Float64(vec) => { - sort_with_error_capture!(vec, comparer, py, |v: f64, py| Float64(v) + // Float64 is represented as a plain Python `float` + sort_with_error_capture!(vec, comparer, py, |v: f64, py| v .into_pyobject(py) .map(|o| o.into_any())); Ok(()) @@ -1001,8 +1024,9 @@ impl NativeArray { /// /// Semantics match the Python `IGenericAdder` path: integer types use /// **wrapping** arithmetic at the element width and the result is boxed as the - /// corresponding wrapper type (`Int32`, …) — the same type `GetZero`/`Add` - /// would produce — while floats use IEEE addition. + /// type `GetZero`/`Add` would produce — the corresponding wrapper for most + /// widths, but a plain Python `int` for Int32, which has no wrapper — while + /// floats use IEEE addition. pub fn try_native_sum(&self, py: Python<'_>) -> Option>> { macro_rules! sum_int { ($vec:expr, $w:ident, $t:ty) => {{ @@ -1013,6 +1037,26 @@ impl NativeArray { Some($w(acc).into_py_any(py)) }}; } + // Float64 is represented as a plain Python `float` + macro_rules! sum_float_plain { + ($vec:expr, $t:ty) => {{ + let mut acc: $t = 0.0; + for &x in $vec.iter() { + acc += x; + } + Some(acc.into_py_any(py)) + }}; + } + // Int32 is represented as a plain Python `int` + macro_rules! sum_int_plain { + ($vec:expr, $t:ty) => {{ + let mut acc: $t = 0; + for &x in $vec.iter() { + acc = acc.wrapping_add(x); + } + Some(acc.into_py_any(py)) + }}; + } macro_rules! sum_float { ($vec:expr, $w:ident, $t:ty) => {{ let mut acc: $t = 0.0; @@ -1027,12 +1071,12 @@ impl NativeArray { NativeArray::UInt8(v) => sum_int!(v, UInt8, u8), NativeArray::Int16(v) => sum_int!(v, Int16, i16), NativeArray::UInt16(v) => sum_int!(v, UInt16, u16), - NativeArray::Int32(v) => sum_int!(v, Int32, i32), + NativeArray::Int32(v) => sum_int_plain!(v, i32), NativeArray::UInt32(v) => sum_int!(v, UInt32, u32), NativeArray::Int64(v) => sum_int!(v, Int64, i64), NativeArray::UInt64(v) => sum_int!(v, UInt64, u64), NativeArray::Float32(v) => sum_float!(v, Float32, f32), - NativeArray::Float64(v) => sum_float!(v, Float64, f64), + NativeArray::Float64(v) => sum_float_plain!(v, f64), NativeArray::Bool(_) | NativeArray::PyObject(_) => None, } } diff --git a/src/fable-library-py/src/strings.rs b/src/fable-library-py/src/strings.rs index dfbe226c70..0d627e504e 100644 --- a/src/fable-library-py/src/strings.rs +++ b/src/fable-library-py/src/strings.rs @@ -162,10 +162,25 @@ mod printf { let arg_str = { let type_name = arg.get_type().name()?.to_string(); match type_name.as_str() { + // Int32 is a plain Python `int`. Other widths keep their wrapper, + // so a bare `int` is unambiguous -- except for bigint, which is + // also a plain int and simply does not fit the tag; the hex + // formatter falls through to its untagged path for those. + "int" => format!("{}:i32", arg.str()?), "Int32" => format!("{}:i32", arg.str()?), "Int64" => format!("{}:i64", arg.str()?), "UInt32" => format!("{}:u32", arg.str()?), "UInt64" => format!("{}:u64", arg.str()?), + // Float64 is a plain Python `float`. .NET renders a whole double + // without a trailing ".0", which is what Rust's formatting does; + // Python's `str` would give "5.0" where .NET gives "5". + "float" => { + let value = arg.extract::()?; + match nonfinite_str(value) { + Some(spelled) => spelled.to_string(), + None => value.to_string(), + } + } // Handle booleans with F# lowercase representation (true/false) "bool" => { if arg.is_truthy()? { @@ -402,9 +417,30 @@ mod printf { } } + /// .NET spells the non-finite doubles out where Rust's `to_string` gives + /// "inf"/"-inf". Returns `None` for a finite value, which formats normally. + /// + /// Rust's `f64` parser accepts these spellings back, so a value that has already + /// been rendered by `__call__` still round-trips through the specifier formatters. + fn nonfinite_str(num: f64) -> Option<&'static str> { + if num.is_finite() { + None + } else if num.is_nan() { + Some("NaN") + } else if num > 0.0 { + Some("Infinity") + } else { + Some("-Infinity") + } + } + /// Format float with flags and precision fn format_float(value_str: &str, flags: &str, precision: Option) -> PyResult { if let Ok(num) = value_str.parse::() { + if let Some(spelled) = nonfinite_str(num) { + return Ok(spelled.to_string()); + } + let prec = precision.unwrap_or(6) as usize; Ok(if flags.contains('+') && num >= 0.0 { format!("+{:.prec$}", num, prec = prec) @@ -419,6 +455,10 @@ mod printf { /// Format general number fn format_general(value_str: &str, flags: &str) -> PyResult { if let Ok(num) = value_str.parse::() { + if let Some(spelled) = nonfinite_str(num) { + return Ok(spelled.to_string()); + } + Ok(if flags.contains('+') && num >= 0.0 { format!("+{}", num) } else { @@ -445,72 +485,68 @@ mod printf { type_info: Option<&str>, uppercase: bool, ) -> PyResult { - // Pattern matching paradise: tuple destructuring eliminates nesting - match (type_info, uppercase) { + // A width tag formats the value as two's complement in that width. A parse + // failure means the value does not fit the tag -- e.g. a bigint, which is a + // plain Python `int` and so reports the same type name as an Int32 -- in which + // case fall through to the untagged logic below rather than giving up and + // emitting the raw decimal string. + let tagged = match (type_info, uppercase) { // Signed 32-bit integers - (Some("i32"), true) => Ok(value_str + (Some("i32"), true) => value_str .parse::() - .map(|n| format!("{:X}", n as u32)) - .unwrap_or_else(|_| value_str.to_string())), - (Some("i32"), false) => Ok(value_str + .ok() + .map(|n| format!("{:X}", n as u32)), + (Some("i32"), false) => value_str .parse::() - .map(|n| format!("{:x}", n as u32)) - .unwrap_or_else(|_| value_str.to_string())), + .ok() + .map(|n| format!("{:x}", n as u32)), // Signed 64-bit integers - (Some("i64"), true) => Ok(value_str + (Some("i64"), true) => value_str .parse::() - .map(|n| format!("{:X}", n as u64)) - .unwrap_or_else(|_| value_str.to_string())), - (Some("i64"), false) => Ok(value_str + .ok() + .map(|n| format!("{:X}", n as u64)), + (Some("i64"), false) => value_str .parse::() - .map(|n| format!("{:x}", n as u64)) - .unwrap_or_else(|_| value_str.to_string())), + .ok() + .map(|n| format!("{:x}", n as u64)), // Unsigned 32-bit integers - (Some("u32"), true) => Ok(value_str - .parse::() - .map(|n| format!("{:X}", n)) - .unwrap_or_else(|_| value_str.to_string())), - (Some("u32"), false) => Ok(value_str - .parse::() - .map(|n| format!("{:x}", n)) - .unwrap_or_else(|_| value_str.to_string())), + (Some("u32"), true) => value_str.parse::().ok().map(|n| format!("{:X}", n)), + (Some("u32"), false) => value_str.parse::().ok().map(|n| format!("{:x}", n)), // Unsigned 64-bit integers - (Some("u64"), true) => Ok(value_str - .parse::() - .map(|n| format!("{:X}", n)) - .unwrap_or_else(|_| value_str.to_string())), - (Some("u64"), false) => Ok(value_str - .parse::() - .map(|n| format!("{:x}", n)) - .unwrap_or_else(|_| value_str.to_string())), - - // Default: try parsing as different types - (_, uppercase) => { - // First try as signed i64 - if let Ok(signed_num) = value_str.parse::() { - Ok(match (signed_num < 0, uppercase) { - (true, true) => format!("{:X}", (signed_num as i32) as u32), - (true, false) => format!("{:x}", (signed_num as i32) as u32), - (false, true) => format!("{:X}", signed_num), - (false, false) => format!("{:x}", signed_num), - }) - } - // Then try as unsigned u64 - else if let Ok(num) = value_str.parse::() { - Ok(if uppercase { - format!("{:X}", num) - } else { - format!("{:x}", num) - }) - } - // Fall back to original string - else { - Ok(value_str.to_string()) - } - } + (Some("u64"), true) => value_str.parse::().ok().map(|n| format!("{:X}", n)), + (Some("u64"), false) => value_str.parse::().ok().map(|n| format!("{:x}", n)), + + _ => None, + }; + + if let Some(formatted) = tagged { + return Ok(formatted); + } + + // Untagged (or out-of-range for its tag): try parsing as different types + // First try as signed i64 + if let Ok(signed_num) = value_str.parse::() { + Ok(match (signed_num < 0, uppercase) { + (true, true) => format!("{:X}", (signed_num as i32) as u32), + (true, false) => format!("{:x}", (signed_num as i32) as u32), + (false, true) => format!("{:X}", signed_num), + (false, false) => format!("{:x}", signed_num), + }) + } + // Then try as unsigned u64 + else if let Ok(num) = value_str.parse::() { + Ok(if uppercase { + format!("{:X}", num) + } else { + format!("{:x}", num) + }) + } + // Fall back to original string + else { + Ok(value_str.to_string()) } } diff --git a/src/fable-library-py/tests/test_array.py b/src/fable-library-py/tests/test_array.py index 8278466ed9..cefdcf7d79 100644 --- a/src/fable-library-py/tests/test_array.py +++ b/src/fable-library-py/tests/test_array.py @@ -1401,7 +1401,8 @@ def test_pydantic_nested_model_with_array(): class ComplexModel(BaseModel): name: str scores: array.FSharpArray - age: int32 + # int32 values are plain Python ints + age: int # Use FSharpArray with Float64 type for proper serialization model = ComplexModel( @@ -1469,22 +1470,30 @@ class Data(BaseModel): class _Adder: - """Minimal IGenericAdder; GetZero returns a typed wrapper so callback-path - arithmetic wraps at the element width (matching the native path).""" + """Minimal IGenericAdder. - def __init__(self, zero: Any) -> None: - self._zero = zero + For most widths `GetZero` returns a typed wrapper, so callback-path arithmetic + wraps at the element width on its own. Int32 is represented as a plain Python + `int`, which is arbitrary precision, so for that width the callback normalizes + explicitly -- exactly what the compiler emits for int32 arithmetic. + """ + + def __init__(self, array_type: str) -> None: + self._zero = _ZERO[array_type] + self._is_int32 = array_type == "Int32" def GetZero(self) -> Any: return self._zero def Add(self, a: Any, b: Any) -> Any: - return a + b + return int32(a + b) if self._is_int32 else a + b class _Averager(_Adder): def DivideByInt(self, a: Any, n: int) -> Any: - return a / n + # `/` on two F# ints truncates toward zero; the wrapper types already do + # this, a plain `int` does not. + return int32(int(a / n)) if self._is_int32 else a / n class _Comparer: @@ -1514,12 +1523,14 @@ def test_native_sum_matches_callback(array_type: str, data: st.DataObject) -> No """Native sum on typed storage equals the callback path (value and type).""" elements = data.draw(st.lists(array_types[array_type], min_size=0, max_size=64)) typed, generic = _typed_and_generic(array_type, elements) - adder = _Adder(_ZERO[array_type]) + adder = _Adder(array_type) typed_sum = typed.sum(adder) generic_sum = generic.sum(adder) assert _agg_eq(typed_sum, generic_sum) - # Result wrapper type is preserved (Int32 sum -> Int32, etc.) - assert type(typed_sum).__name__ == array_type + # Result type is preserved: the wrapper for most widths, and the plain builtin + # for Int32/Float64, which no longer have one. + expected_type = {"Int32": "int", "Float64": "float"}.get(array_type, array_type) + assert type(typed_sum).__name__ == expected_type @pytest.mark.parametrize("array_type", _NUMERIC_ARRAY_TYPES) @@ -1528,7 +1539,7 @@ def test_native_average_matches_callback(array_type: str, data: st.DataObject) - """Native average on typed storage equals the callback path.""" elements = data.draw(st.lists(array_types[array_type], min_size=1, max_size=64)) typed, generic = _typed_and_generic(array_type, elements) - averager = _Averager(_ZERO[array_type]) + averager = _Averager(array_type) assert _agg_eq(typed.average(averager), generic.average(averager)) @@ -1537,7 +1548,7 @@ def test_native_average_empty_raises(array_type: str) -> None: """Average of an empty typed array raises, like the callback path.""" empty = Array(array_type=array_type, elements=[]) with pytest.raises(Exception): - empty.average(_Averager(_ZERO[array_type])) + empty.average(_Averager(array_type)) @pytest.mark.parametrize("array_type", _INT_ARRAY_TYPES) diff --git a/src/fable-library-py/tests/test_array_create.py b/src/fable-library-py/tests/test_array_create.py index c6ad328b14..f5e37d9c33 100644 --- a/src/fable-library-py/tests/test_array_create.py +++ b/src/fable-library-py/tests/test_array_create.py @@ -166,3 +166,53 @@ def test_array_initialize(): # Initialize with empty array (count = 0) empty_array = array.initialize(0, lambda i: int32(i)) assert len(empty_array) == 0 + + +def test_create_specializes_plain_int_and_float(): + """Plain `int`/`float` are the representations of Int32/Float64. + + Element storage is inferred from the sample value, so if inference misses a + representation the array silently degrades to boxed `Generic` storage. Assert + the storage kind, not just the values, or that regression is invisible. + """ + assert array.create(3, 5).storage_type == "Int32" + assert array.create(3, 2.5).storage_type == "Float64" + + # bool subclasses int but must keep its own storage + assert array.create(3, True).storage_type == "Bool" + + # the wrapper types keep specializing as before + assert array.create(3, int32(5)).storage_type == "Int32" + assert array.create(3, float64(2.5)).storage_type == "Float64" + assert array.create(3, byte(5)).storage_type == "UInt8" + assert array.create(3, int64(5)).storage_type == "Int64" + + # anything that does not fit falls back to boxed storage + assert array.create(3, 2**40).storage_type == "Generic" + assert array.create(3, "x").storage_type == "Generic" + + +def test_array_subscript_accepts_builtin_types(): + assert array.FSharpArray[int].__name__ == "Int32Array" + assert array.FSharpArray[float].__name__ == "Float64Array" + # the existing spellings keep working + assert array.FSharpArray[int32].__name__ == "Int32Array" + assert array.FSharpArray[float64].__name__ == "Float64Array" + assert array.FSharpArray[byte].__name__ == "UInt8Array" + + +def test_fill_and_insert_accept_plain_values(): + """Typed storage accepts a plain int/float as well as the wrapper.""" + xs = array.create(3, int32(0)) + xs.fill(0, 3, 7) + assert list(xs) == [7, 7, 7] + assert xs.storage_type == "Int32" + + xs.insert(0, 9) + assert list(xs) == [9, 7, 7, 7] + assert xs.storage_type == "Int32" + + ys = array.create(2, float64(0.0)) + ys.fill(0, 2, 1.5) + assert list(ys) == [1.5, 1.5] + assert ys.storage_type == "Float64" diff --git a/src/fable-library-py/tests/test_float_math.py b/src/fable-library-py/tests/test_float_math.py index a7bd02698a..a46bffe73b 100644 --- a/src/fable-library-py/tests/test_float_math.py +++ b/src/fable-library-py/tests/test_float_math.py @@ -1,4 +1,4 @@ -from fable_library.core import float32, float64 +from fable_library.core import Float64, float32 def test_float_math_operations(): @@ -6,20 +6,20 @@ def test_float_math_operations(): # Test floor assert float32(3.7).floor().value() == 3.0 assert float32(-3.7).floor().value() == -4.0 - assert float64(3.7).floor().value() == 3.0 - assert float64(-3.7).floor().value() == -4.0 + assert Float64(3.7).floor().value() == 3.0 + assert Float64(-3.7).floor().value() == -4.0 # Test ceil assert float32(3.7).ceil().value() == 4.0 assert float32(-3.7).ceil().value() == -3.0 - assert float64(3.7).ceil().value() == 4.0 - assert float64(-3.7).ceil().value() == -3.0 + assert Float64(3.7).ceil().value() == 4.0 + assert Float64(-3.7).ceil().value() == -3.0 # Test round assert float32(3.7).round().value() == 4.0 assert float32(3.2).round().value() == 3.0 - assert float64(3.7).round().value() == 4.0 - assert float64(3.2).round().value() == 3.0 + assert Float64(3.7).round().value() == 4.0 + assert Float64(3.2).round().value() == 3.0 def test_float_to_int_with_math(): @@ -37,14 +37,14 @@ def test_float_to_int_with_math(): assert uint64(float32(3.7).floor()) == 3 # Test Float64 floor to various integer types - assert byte(float64(3.7).floor()) == 3 - assert sbyte(float64(3.7).floor()) == 3 - assert int16(float64(3.7).floor()) == 3 - assert uint16(float64(3.7).floor()) == 3 - assert int32(float64(3.7).floor()) == 3 - assert uint32(float64(3.7).floor()) == 3 - assert int64(float64(3.7).floor()) == 3 - assert uint64(float64(3.7).floor()) == 3 + assert byte(Float64(3.7).floor()) == 3 + assert sbyte(Float64(3.7).floor()) == 3 + assert int16(Float64(3.7).floor()) == 3 + assert uint16(Float64(3.7).floor()) == 3 + assert int32(Float64(3.7).floor()) == 3 + assert uint32(Float64(3.7).floor()) == 3 + assert int64(Float64(3.7).floor()) == 3 + assert uint64(Float64(3.7).floor()) == 3 # Test negative values assert sbyte(float32(-3.7).floor()) == -4 @@ -52,7 +52,7 @@ def test_float_to_int_with_math(): assert int32(float32(-3.7).floor()) == -4 assert int64(float32(-3.7).floor()) == -4 - assert sbyte(float64(-3.7).floor()) == -4 - assert int16(float64(-3.7).floor()) == -4 - assert int32(float64(-3.7).floor()) == -4 - assert int64(float64(-3.7).floor()) == -4 + assert sbyte(Float64(-3.7).floor()) == -4 + assert int16(Float64(-3.7).floor()) == -4 + assert int32(Float64(-3.7).floor()) == -4 + assert int64(Float64(-3.7).floor()) == -4 diff --git a/src/fable-library-py/tests/test_floats.py b/src/fable-library-py/tests/test_floats.py index 3af2fc6920..c4e7e1abcd 100644 --- a/src/fable-library-py/tests/test_floats.py +++ b/src/fable-library-py/tests/test_floats.py @@ -1,7 +1,7 @@ import math import pytest -from fable_library.core import float32, float64 +from fable_library.core import Float64, float32 from pydantic import BaseModel @@ -11,7 +11,7 @@ def test_float32_create(): assert float32(0) == 0.0 assert float32(42.5) == 42.5 assert float32(float32(42.5)) == 42.5 - assert float32(float64(42.5)) == 42.5 + assert float32(Float64(42.5)) == 42.5 assert float32(42) == 42.0 assert float32(-42.5) == -42.5 # assert float32(Decimal("42.5")) == pytest.approx(42.5, rel=1e-9) # Decimal conversion might need explicit float() @@ -21,18 +21,18 @@ def test_float32_create(): def test_float64_create(): - assert float64(0) == 0.0 - assert float64(42.5) == 42.5 - assert float64(float64(42.5)) == 42.5 - assert float64(float32(42.5)) == pytest.approx( + assert Float64(0) == 0.0 + assert Float64(42.5) == 42.5 + assert Float64(Float64(42.5)) == 42.5 + assert Float64(float32(42.5)) == pytest.approx( 42.5, rel=1e-9 ) # Precision loss expected here, comparison should handle it - assert float64(42) == 42.0 - assert float64(-42.5) == -42.5 - # assert float64(Decimal("42.5")) == pytest.approx(42.5, rel=1e-9) - assert math.isnan(float(float64(math.nan))) - assert float64(math.inf) == math.inf - assert float64(-math.inf) == -math.inf + assert Float64(42) == 42.0 + assert Float64(-42.5) == -42.5 + # assert Float64(Decimal("42.5")) == pytest.approx(42.5, rel=1e-9) + assert math.isnan(float(Float64(math.nan))) + assert Float64(math.inf) == math.inf + assert Float64(-math.inf) == -math.inf def test_float32_addition(): @@ -145,8 +145,8 @@ def test_float32_zero_divided_by_zero(): def test_float64_addition(): """Test Float64 addition operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a + f64_b == 12.5 # f64 should have enough precision assert f64_a + 2.0 == 12.5 assert 10.5 + f64_b == 12.5 @@ -154,8 +154,8 @@ def test_float64_addition(): def test_float64_subtraction(): """Test Float64 subtraction operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a - f64_b == 8.5 assert f64_a - 2.0 == 8.5 assert 10.5 - f64_b == 8.5 @@ -163,8 +163,8 @@ def test_float64_subtraction(): def test_float64_multiplication(): """Test Float64 multiplication operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a * f64_b == 21.0 assert f64_a * 2.0 == 21.0 assert 10.5 * f64_b == 21.0 @@ -172,8 +172,8 @@ def test_float64_multiplication(): def test_float64_division(): """Test Float64 division operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a / f64_b == 5.25 assert f64_a / 2.0 == 5.25 assert 10.5 / f64_b == 5.25 @@ -181,8 +181,8 @@ def test_float64_division(): def test_float64_floor_division(): """Test Float64 floor division operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a // f64_b == 5.0 assert f64_a // 2.0 == 5.0 assert 10.5 // f64_b == 5.0 @@ -190,8 +190,8 @@ def test_float64_floor_division(): def test_float64_modulo(): """Test Float64 modulo operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a % f64_b == 0.5 assert f64_a % 2.0 == 0.5 assert 10.5 % f64_b == 0.5 @@ -199,8 +199,8 @@ def test_float64_modulo(): def test_float64_power(): """Test Float64 power operations.""" - f64_a = float64(10.5) - f64_b = float64(2.0) + f64_a = Float64(10.5) + f64_b = Float64(2.0) assert f64_a**f64_b == 10.5**2.0 assert f64_a**2.0 == 10.5**2.0 @@ -213,8 +213,8 @@ def test_float64_math_functions_accept_native_float(): native_float: float = float(10.5) - assert double.pow(native_float, float64(2.0)) == 10.5**2.0 - assert double.pow(float64(10.5), float64(2.0)) == 10.5**2.0 + assert double.pow(native_float, Float64(2.0)) == 10.5**2.0 + assert double.pow(Float64(10.5), Float64(2.0)) == 10.5**2.0 assert double.floor(native_float) == 10.0 assert double.ceil(native_float) == 11.0 @@ -244,7 +244,7 @@ def test_float64_math_functions_accept_native_float(): def test_float64_unary_operations(): """Test Float64 unary operations.""" - f64_a = float64(10.5) + f64_a = Float64(10.5) assert -f64_a == -10.5 assert abs(f64_a) == 10.5 assert abs(-f64_a) == 10.5 @@ -253,37 +253,37 @@ def test_float64_unary_operations(): def test_float64_division_by_zero(): """Test Float64 division by zero (.NET semantics: returns infinity).""" - f64_a = float64(10.5) + f64_a = Float64(10.5) # Regular division by zero result1 = f64_a / 0.0 - result2 = f64_a / float64(0.0) + result2 = f64_a / Float64(0.0) assert result1.is_infinity() assert result2.is_infinity() assert result1.is_positive_infinity() # Positive infinity for positive dividend # Negative dividend division by zero - neg_result = float64(-10.5) / 0.0 + neg_result = Float64(-10.5) / 0.0 assert neg_result.is_infinity() assert neg_result.is_negative_infinity() # Negative infinity for negative dividend # Floor division by zero assert (f64_a // 0.0).is_infinity() - assert (f64_a // float64(0.0)).is_infinity() + assert (f64_a // Float64(0.0)).is_infinity() def test_float64_modulo_by_zero(): """Test Float64 modulo by zero (.NET semantics: returns NaN).""" - f64_a = float64(10.5) + f64_a = Float64(10.5) assert (f64_a % 0.0).is_nan() - assert (f64_a % float64(0.0)).is_nan() + assert (f64_a % Float64(0.0)).is_nan() def test_float64_zero_divided_by_zero(): """Test Float64 zero divided by zero (.NET semantics: returns NaN).""" - assert (float64(0.0) / 0.0).is_nan() - assert (float64(0.0) // 0.0).is_nan() - assert (float64(0.0) % 0.0).is_nan() + assert (Float64(0.0) / 0.0).is_nan() + assert (Float64(0.0) // 0.0).is_nan() + assert (Float64(0.0) % 0.0).is_nan() def test_float32_equality(): @@ -400,9 +400,9 @@ def test_float32_incompatible_comparisons(): def test_float64_equality(): """Test Float64 equality comparisons.""" - f64_a = float64(42.5) - f64_b = float64(42.5) - f64_c = float64(43.0) + f64_a = Float64(42.5) + f64_b = Float64(42.5) + f64_c = Float64(43.0) assert f64_a == f64_b assert f64_a == 42.5 @@ -414,9 +414,9 @@ def test_float64_equality(): def test_float64_inequality(): """Test Float64 inequality comparisons.""" - f64_a = float64(42.5) - f64_b = float64(42.5) - f64_c = float64(43.0) + f64_a = Float64(42.5) + f64_b = Float64(42.5) + f64_c = Float64(43.0) assert f64_a != f64_c assert f64_a != 43.0 @@ -428,8 +428,8 @@ def test_float64_inequality(): def test_float64_less_than(): """Test Float64 less than comparisons.""" - f64_a = float64(42.5) - f64_c = float64(43.0) + f64_a = Float64(42.5) + f64_c = Float64(43.0) assert f64_a < f64_c assert f64_a < 43.0 @@ -439,9 +439,9 @@ def test_float64_less_than(): def test_float64_less_equal(): """Test Float64 less than or equal comparisons.""" - f64_a = float64(42.5) - f64_b = float64(42.5) - f64_c = float64(43.0) + f64_a = Float64(42.5) + f64_b = Float64(42.5) + f64_c = Float64(43.0) assert f64_a <= f64_c assert f64_a <= f64_b @@ -453,8 +453,8 @@ def test_float64_less_equal(): def test_float64_greater_than(): """Test Float64 greater than comparisons.""" - f64_a = float64(42.5) - f64_c = float64(43.0) + f64_a = Float64(42.5) + f64_c = Float64(43.0) assert f64_c > f64_a assert f64_c > 42.5 @@ -464,8 +464,8 @@ def test_float64_greater_than(): def test_float64_greater_equal(): """Test Float64 greater than or equal comparisons.""" - f64_a = float64(42.5) - f64_c = float64(43.0) + f64_a = Float64(42.5) + f64_c = Float64(43.0) assert f64_c >= f64_a assert f64_c >= f64_c @@ -477,8 +477,8 @@ def test_float64_greater_equal(): def test_float64_nan_comparisons(): """Test Float64 NaN comparisons (.NET semantics).""" - f64_a = float64(42.5) - f64_nan = float64(math.nan) + f64_a = Float64(42.5) + f64_nan = Float64(math.nan) # NaN comparisons assert not (f64_nan == f64_nan) @@ -495,7 +495,7 @@ def test_float64_nan_comparisons(): def test_float64_incompatible_comparisons(): """Test Float64 comparisons with incompatible types.""" - f64_a = float64(42.5) + f64_a = Float64(42.5) # Comparison with incompatible types should raise TypeError for ordered, False/True for Eq/Ne with pytest.raises(TypeError): @@ -512,7 +512,7 @@ def test_float64_incompatible_comparisons(): def test_float_conversion(): f32_a = float32(42.7) - f64_a = float64(42.7) + f64_a = Float64(42.7) # Compare f32 conversion to f64 using pytest.approx assert float(f32_a) == pytest.approx(42.7, rel=1e-7) # Adjust tolerance for f32 @@ -524,7 +524,7 @@ def test_float_conversion(): assert float(f64_a) == 42.7 assert int(f64_a) == 42 assert bool(f64_a) is True - assert bool(float64(0.0)) is False + assert bool(Float64(0.0)) is False # Conversion of NaN/inf to int should fail with pytest.raises(ValueError): @@ -543,27 +543,27 @@ def test_float_hash(): # CPython hash(float('-inf')) == -sys.hash_info.inf (e.g., -314159) assert hash(float32(1.0)) != 0 - assert hash(float64(1.0)) != 0 + assert hash(Float64(1.0)) != 0 assert hash(float32(1.0)) == hash(float32(1.0)) - assert hash(float64(1.0)) == hash(float64(1.0)) + assert hash(Float64(1.0)) == hash(Float64(1.0)) # Check if hash matches standard float hash (might fail due to implementation difference) # assert hash(float32(1.5)) == hash(1.5) - # assert hash(float64(1.5)) == hash(1.5) + # assert hash(Float64(1.5)) == hash(1.5) # Test NaN/inf hashing based on Rust implementation assert hash(float32(math.nan)) == 0 - assert hash(float64(math.nan)) == 0 + assert hash(Float64(math.nan)) == 0 assert hash(float32(math.inf)) == 9223372036854775807 # i64::MAX used in Rust code - assert hash(float64(math.inf)) == 9223372036854775807 + assert hash(Float64(math.inf)) == 9223372036854775807 assert hash(float32(-math.inf)) == -9223372036854775808 # i64::MIN used in Rust code - assert hash(float64(-math.inf)) == -9223372036854775808 + assert hash(Float64(-math.inf)) == -9223372036854775808 def test_float_repr_str(): assert repr(float32(42.5)) == "42.5" assert str(float32(42.5)) == "42.5" - assert repr(float64(42.5)) == "42.5" - assert str(float64(42.5)) == "42.5" + assert repr(Float64(42.5)) == "42.5" + assert str(Float64(42.5)) == "42.5" assert repr(float32(math.nan)) == "NaN" assert str(float32(math.nan)) == "NaN" @@ -577,10 +577,10 @@ def test_pydantic_float_model_definition(): """Test that Fable float types can be used in Pydantic models.""" class Measurement(BaseModel): - value: float64 + value: Float64 precision: float32 - m = Measurement(value=float64(3.14159), precision=float32(0.001)) + m = Measurement(value=Float64(3.14159), precision=float32(0.001)) assert m.value == 3.14159 assert m.precision == pytest.approx(0.001, rel=1e-6) assert type(m.value).__name__ == "Float64" @@ -591,9 +591,9 @@ def test_pydantic_float_serialization(): """Test JSON serialization of Pydantic models with Fable float types.""" class Measurement(BaseModel): - value: float64 + value: Float64 - m = Measurement(value=float64(3.14159)) + m = Measurement(value=Float64(3.14159)) assert m.model_dump_json() == '{"value":3.14159}' assert m.model_dump() == {"value": 3.14159} @@ -602,7 +602,7 @@ def test_pydantic_float_json_schema(): """Test JSON Schema generation for Pydantic models with Fable float types.""" class Measurement(BaseModel): - value: float64 + value: Float64 schema = Measurement.model_json_schema() assert schema["properties"]["value"]["type"] == "number" @@ -612,23 +612,23 @@ def test_pydantic_float_from_fable_type(): """Test that Pydantic models accept Fable float types directly as input.""" class Simple(BaseModel): - val: float64 + val: Float64 # Pass a Fable Float64 directly - simple = Simple(val=float64(2.718)) + simple = Simple(val=Float64(2.718)) assert simple.val == 2.718 assert type(simple.val).__name__ == "Float64" def test_pydantic_mixed_numeric_types(): """Test Pydantic models with mixed Fable integer and float types.""" - from fable_library.core import int32 + from fable_library.core import Int32 class MixedModel(BaseModel): - count: int32 - average: float64 + count: Int32 + average: Float64 - model = MixedModel(count=int32(10), average=float64(3.5)) + model = MixedModel(count=Int32(10), average=Float64(3.5)) assert model.count == 10 assert model.average == 3.5 assert type(model.count).__name__ == "Int32" @@ -640,7 +640,7 @@ def test_pydantic_float_json_deserialization(): """Test JSON deserialization into Pydantic models with Fable float types.""" class Measurement(BaseModel): - value: float64 + value: Float64 json_str = '{"value": 3.14159}' m = Measurement.model_validate_json(json_str) @@ -653,9 +653,9 @@ def test_pydantic_float_round_trip(): class Data(BaseModel): x: float32 - y: float64 + y: Float64 - original = Data(x=float32(1.5), y=float64(2.718281828)) + original = Data(x=float32(1.5), y=Float64(2.718281828)) json_str = original.model_dump_json() restored = Data.model_validate_json(json_str) assert restored.y == 2.718281828 diff --git a/src/fable-library-py/tests/test_ints.py b/src/fable-library-py/tests/test_ints.py index 7b980c4b7b..601d189ac2 100644 --- a/src/fable-library-py/tests/test_ints.py +++ b/src/fable-library-py/tests/test_ints.py @@ -3,7 +3,9 @@ import pytest -from fable_library.core import FSharpRef, byte, int16, int32, int64, sbyte, uint16, uint32, uint64 +from fable_library.core import FSharpRef, Int32, byte, int16, int64, sbyte, uint16, uint32, uint64 +from fable_library.int32 import parse as int32_parse +from fable_library.int32 import try_parse as int32_try_parse from pydantic import BaseModel @@ -43,8 +45,8 @@ def test_int16_create() -> None: assert int16(32767) == 32767 assert int16(32768) == -32768 assert int16(-1) == -1 - assert int16(int32(1)) == 1 - assert int16(int32(-1)) == -1 + assert int16(Int32(1)) == 1 + assert int16(Int32(-1)) == -1 def test_uint64_create() -> None: @@ -70,7 +72,7 @@ def test_uint64_create() -> None: def test_int32_try_parse_multibyte_unicode_returns_false(style: int) -> None: result = FSharpRef(0) - assert int32.try_parse("–", style, result) is False + assert Int32.try_parse("–", style, result) is False assert result.contents == 0 @@ -95,7 +97,7 @@ def test_int32_try_parse_multibyte_unicode_returns_false(style: int) -> None: ], ) def test_int32_parse_prefix_after_whitespace_and_sign(text: str, expected: int) -> None: - assert int32.parse(text, 511) == expected + assert Int32.parse(text, 511) == expected @pytest.mark.parametrize( @@ -123,7 +125,7 @@ def test_int64_parse_large_hex_is_twos_complement() -> None: def test_int32_parse_rejects_invalid(text: str) -> None: # `int "+0x11"` is a format error on .NET: only `-` precedes the specifier with pytest.raises(ValueError): - int32.parse(text, 511) + Int32.parse(text, 511) def test_byte_add() -> None: @@ -193,6 +195,30 @@ def test_byte_lshift(): assert 1 << byte(9) == 512 +def test_lshift_discards_bits_shifted_out_of_width(): + # Multi-bit values distinguish a shift from a rotation: a rotation would wrap + # bit 1 around to bit 0 and give -2147483647 for the Int32 case. + assert Int32(3) << 31 == -2147483648 + assert sbyte(3) << 7 == -128 + assert int16(3) << 15 == -32768 + assert int64(3) << 63 == -9223372036854775808 + assert byte(3) << 7 == 128 + assert uint16(3) << 15 == 32768 + assert uint32(3) << 31 == 2147483648 + assert uint64(3) << 63 == 9223372036854775808 + + +def test_lshift_masks_shift_count_to_width(): + assert Int32(1) << 32 == 1 + assert Int32(1) << 33 == 2 + assert sbyte(1) << 8 == 1 + assert int16(1) << 16 == 1 + assert int64(1) << 64 == 1 + assert byte(1) << 9 == 2 + assert uint16(1) << 16 == 1 + assert uint32(1) << 32 == 1 + + def test_binary_complement(): assert ~byte(0) == 255 assert ~byte(1) == 254 @@ -223,8 +249,8 @@ def test_abs(): assert abs(sbyte(-1)) == 1 assert abs(int16(-42)) == 42 assert abs(int16(-1)) == 1 - assert abs(int32(-42)) == 42 - assert abs(int32(-1)) == 1 + assert abs(Int32(-42)) == 42 + assert abs(Int32(-1)) == 1 assert abs(int64(-42)) == 42 assert abs(int64(-1)) == 1 assert abs(uint16(42)) == 42 @@ -242,7 +268,7 @@ def test_divide(): assert 10 / byte(3) == 3.3333333333333335 assert 10 / sbyte(3) == 3.3333333333333335 assert 10 / int16(3) == 3.3333333333333335 - assert 10 / int32(3) == 3.3333333333333335 + assert 10 / Int32(3) == 3.3333333333333335 assert 10 / int64(3) == 3.3333333333333335 assert 10 / uint16(3) == 3.3333333333333335 assert 10 / uint32(3) == 3.3333333333333335 @@ -254,9 +280,9 @@ def test_divide(): def test_divide_same_type(): # Same-type division uses the typed integer fast path (truncates toward # zero, matching .NET) rather than a float fallback. - assert int32(100) / int32(7) == 14 - assert int32(-100) / int32(7) == -14 - assert int32(100) / int32(-7) == -14 + assert Int32(100) / Int32(7) == 14 + assert Int32(-100) / Int32(7) == -14 + assert Int32(100) / Int32(-7) == -14 assert int64(100) / int64(7) == 14 assert byte(10) / byte(3) == 3 # uint64 operand exceeding i64::MAX: the old OtherType path fell back to @@ -266,14 +292,14 @@ def test_divide_same_type(): def test_divide_same_type_by_zero(): with pytest.raises(ZeroDivisionError): - int32(1) / int32(0) + Int32(1) / Int32(0) def test_hash(): assert hash(byte(42)) != 0 assert hash(sbyte(42)) != 0 assert hash(int16(42)) != 0 - assert hash(int32(42)) != 0 + assert hash(Int32(42)) != 0 assert hash(int64(42)) != 0 assert hash(uint16(42)) != 0 assert hash(uint32(42)) != 0 @@ -300,8 +326,8 @@ def test_addition(): assert sbyte(1) + byte(1) == 2 assert byte(1) + int16(1) == 2 assert int16(1) + byte(1) == 2 - assert byte(1) + int32(1) == 2 - assert int32(1) + byte(1) == 2 + assert byte(1) + Int32(1) == 2 + assert Int32(1) + byte(1) == 2 assert byte(1) + int64(1) == 2 assert int64(1) + byte(1) == 2 @@ -314,8 +340,8 @@ def test_addition_overflow(): assert sbyte(1) + byte(127) == -128 assert byte(255) + int16(1) == 0 assert int16(1) + 32767 == -32768 - assert byte(255) + int32(1) == 0 - assert int32(1) + 2147483647 == -2147483648 + assert byte(255) + Int32(1) == 0 + assert Int32(1) + 2147483647 == -2147483648 assert byte(255) + int64(1) == 0 assert int64(1) + 9223372036854775807 == -9223372036854775808 @@ -331,8 +357,8 @@ def test_float_to_int_conversion(): assert int16(float32(42.7)) == 42 assert int16(float32(-42.7)) == -42 assert uint16(float32(42.7)) == 42 - assert int32(float32(42.7)) == 42 - assert int32(float32(-42.7)) == -42 + assert Int32(float32(42.7)) == 42 + assert Int32(float32(-42.7)) == -42 assert uint32(float32(42.7)) == 42 assert int64(float32(42.7)) == 42 assert int64(float32(-42.7)) == -42 @@ -345,8 +371,8 @@ def test_float_to_int_conversion(): assert int16(float64(42.7)) == 42 assert int16(float64(-42.7)) == -42 assert uint16(float64(42.7)) == 42 - assert int32(float64(42.7)) == 42 - assert int32(float64(-42.7)) == -42 + assert Int32(float64(42.7)) == 42 + assert Int32(float64(-42.7)) == -42 assert uint32(float64(42.7)) == 42 assert int64(float64(42.7)) == 42 assert int64(float64(-42.7)) == -42 @@ -357,10 +383,10 @@ def test_pydantic_int_model_definition(): """Test that Fable integer types can be used in Pydantic models.""" class User(BaseModel): - age: int32 + age: Int32 level: byte - user = User(age=int32(25), level=byte(3)) + user = User(age=Int32(25), level=byte(3)) assert user.age == 25 assert user.level == 3 assert type(user.age).__name__ == "Int32" @@ -371,10 +397,10 @@ def test_pydantic_int_serialization(): """Test JSON serialization of Pydantic models with Fable integer types.""" class User(BaseModel): - age: int32 + age: Int32 level: byte - user = User(age=int32(25), level=byte(3)) + user = User(age=Int32(25), level=byte(3)) assert user.model_dump_json() == '{"age":25,"level":3}' assert user.model_dump() == {"age": 25, "level": 3} @@ -383,7 +409,7 @@ def test_pydantic_int_json_schema(): """Test JSON Schema generation for Pydantic models with Fable integer types.""" class User(BaseModel): - age: int32 + age: Int32 schema = User.model_json_schema() assert schema["properties"]["age"]["type"] == "integer" @@ -397,7 +423,7 @@ class AllInts(BaseModel): b: byte c: int16 d: uint16 - e: int32 + e: Int32 f: uint32 g: int64 h: uint64 @@ -407,7 +433,7 @@ class AllInts(BaseModel): b=byte(255), c=int16(-32768), d=uint16(65535), - e=int32(-2147483648), + e=Int32(-2147483648), f=uint32(4294967295), g=int64(-1000000), h=uint64(1000000), @@ -434,10 +460,10 @@ def test_pydantic_int_from_fable_type(): """Test that Pydantic models accept Fable types directly as input.""" class Simple(BaseModel): - val: int32 + val: Int32 # Pass a Fable Int32 directly - simple = Simple(val=int32(42)) + simple = Simple(val=Int32(42)) assert simple.val == 42 assert type(simple.val).__name__ == "Int32" @@ -446,13 +472,13 @@ def test_pydantic_nested_int_models(): """Test nested Pydantic models with Fable integer types.""" class Address(BaseModel): - zip_code: int32 + zip_code: Int32 class Person(BaseModel): - age: int32 + age: Int32 address: Address - person = Person(age=int32(30), address=Address(zip_code=int32(12345))) + person = Person(age=Int32(30), address=Address(zip_code=Int32(12345))) assert person.age == 30 assert person.address.zip_code == 12345 assert person.model_dump_json() == '{"age":30,"address":{"zip_code":12345}}' @@ -463,7 +489,7 @@ def test_pydantic_int_json_deserialization(): class User(BaseModel): name: str - age: int32 + age: Int32 json_str = '{"name": "Alice", "age": 30}' user = User.model_validate_json(json_str) @@ -526,3 +552,27 @@ class UInt64Data(BaseModel): restored = Int64Data.model_validate_json(json_str) assert restored.value == 9223372036854775807 assert type(restored.value).__name__ == "Int64" + + +def test_int32_module_parse_returns_plain_int() -> None: + """`Int32.Parse` must hand back a plain `int`, not an `Int32`. + + The wrapper class's static `parse` returns an `Int32`, so generated code goes + through the `int32` module instead. A value assertion alone would not catch a + regression here: an `Int32` compares equal to its value. + """ + assert int32_parse("42", 511) == 42 + assert type(int32_parse("42", 511)) is int + assert type(int32_parse("-2147483648", 511)) is int + # Non-decimal radixes read as two's complement, as .NET does + assert int32_parse("FFFFFFFF", 515) == -1 + + with pytest.raises(ValueError): + int32_parse("2147483648", 511) + + ref: FSharpRef[int] = FSharpRef(0) + assert int32_try_parse("7", 511, ref) is True + assert ref.contents == 7 + assert type(ref.contents) is int + + assert int32_try_parse("foo", 511, ref) is False diff --git a/src/fable-library-py/tests/test_to_string.py b/src/fable-library-py/tests/test_to_string.py new file mode 100644 index 0000000000..478dec2dee --- /dev/null +++ b/src/fable-library-py/tests/test_to_string.py @@ -0,0 +1,35 @@ +"""`Double.ToString()` semantics for the plain-float representation of Float64.""" + +import math + +from fable_library.exceptions import float_to_string, to_string + + +def test_float_to_string_shortens_whole_doubles() -> None: + assert float_to_string(5.0) == "5" + assert float_to_string(-5.0) == "-5" + assert float_to_string(2.5) == "2.5" + assert float_to_string(-0.5) == "-0.5" + + +def test_float_to_string_spells_out_the_specials() -> None: + # `int()` raises OverflowError on the infinities and ValueError on NaN, so the + # whole-number path must not be reached for them + assert float_to_string(math.inf) == "Infinity" + assert float_to_string(-math.inf) == "-Infinity" + assert float_to_string(math.nan) == "NaN" + + +def test_to_string_routes_floats() -> None: + assert to_string(math.inf) == "Infinity" + assert to_string(-math.inf) == "-Infinity" + assert to_string(math.nan) == "NaN" + assert to_string(5.0) == "5" + + +def test_to_string_keeps_the_other_arms() -> None: + # bool is not a float, so it still takes its own arm + assert to_string(True) == "true" + assert to_string(False) == "false" + assert to_string(5) == "5" + assert to_string("x") == "x" diff --git a/tests/Python/TestArithmetic.fs b/tests/Python/TestArithmetic.fs index 1294a1139a..96d6715cd2 100644 --- a/tests/Python/TestArithmetic.fs +++ b/tests/Python/TestArithmetic.fs @@ -102,6 +102,122 @@ let ``test Math.DivRem works with longs and outref`` () = let ``test Evaluation order is preserved by generated code`` () = (4 - 2) * 2 + 1 |> equal 5 +// Int32 is a plain Python `int` on the Python target, which is arbitrary precision, +// so the compiler normalizes results back into 32 bits. These use function +// parameters rather than literals so the values are not constant-folded away and +// the runtime normalization path is exercised. All expectations verified on .NET. +let private addI (a: int) (b: int) = a + b +let private mulI (a: int) (b: int) = a * b +let private divI (a: int) (b: int) = a / b +let private remI (a: int) (b: int) = a % b +let private shlI (a: int) (n: int) = a <<< n +let private shrI (a: int) (n: int) = a >>> n + +[] +let ``test Int32 arithmetic wraps at 32 bits`` () = + let a = addI 2000000000 0 + let b = addI 1500000000 0 + addI a a |> equal -294967296 + mulI a a |> equal -1651507200 + addI (addI a a) a |> equal 1705032704 + addI (mulI a b) a |> equal 1835111424 + addI 2147483647 1 |> equal -2147483648 + -(addI -2147483648 0) |> equal -2147483648 + +[] +let ``test Int32 operands are normalized before non-wrapping operations`` () = + // Normalization commutes with + - * <<< so it can happen once per expression + // tree, but these consumers must see the already-wrapped value. + let a = addI 2000000000 0 + divI (addI a a) 2 |> equal -147483648 + remI (addI a a) 7 |> equal -1 + (addI a a) &&& 0xFF |> equal 0 + (addI a a) > 0 |> equal false + string (addI a a) |> equal "-294967296" + shrI (addI a a) 4 |> equal -18435456 + +[] +let ``test Int32 division truncates toward zero`` () = + // Python's // floors: -7 // 2 is -4 there and -3 in .NET + divI -7 2 |> equal -3 + divI 7 -2 |> equal -3 + divI -7 -2 |> equal 3 + divI 7 2 |> equal 3 + +[] +let ``test Int32 remainder takes the sign of the dividend`` () = + // Python's % takes the sign of the divisor: -5 % 3 is 1 there and -2 in .NET + remI -5 3 |> equal -2 + remI 5 -3 |> equal 2 + remI -5 -3 |> equal -2 + remI 5 3 |> equal 2 + remI 6 3 |> equal 0 + remI -6 3 |> equal 0 + +[] +let ``test Int32 shift counts are masked at runtime`` () = + shlI 1 32 |> equal 1 + shlI 1 33 |> equal 2 + shlI 3 31 |> equal -2147483648 + shrI -1 32 |> equal -1 + shrI -2 1 |> equal -1 + +// Float64 is a plain Python `float` on the Python target -- an IEEE double, so +// arithmetic needs no adjustment. Only `/` by zero and `%` diverge: Python raises +// where .NET yields +/-inf or nan, and Python's float `%` takes the sign of the +// divisor where .NET takes the dividend's. Non-literal operands keep the runtime +// path in play. All expectations verified on .NET. +let private divF (a: float) (b: float) = a / b +let private remF (a: float) (b: float) = a % b + +[] +let ``test Float division by zero yields infinity at runtime`` () = + divF 1.0 0.0 |> Double.IsPositiveInfinity |> equal true + divF -1.0 0.0 |> Double.IsNegativeInfinity |> equal true + divF 1.0 -0.0 |> Double.IsNegativeInfinity |> equal true + divF -1.0 -0.0 |> Double.IsPositiveInfinity |> equal true + divF 0.0 0.0 |> Double.IsNaN |> equal true + divF infinity 0.0 |> Double.IsPositiveInfinity |> equal true + divF 1.0 2.0 |> equal 0.5 + +[] +let ``test Float remainder takes the sign of the dividend`` () = + remF -5.0 3.0 |> equal -2.0 + remF 5.0 -3.0 |> equal 2.0 + remF -5.0 -3.0 |> equal -2.0 + remF 5.0 3.0 |> equal 2.0 + +[] +let ``test Float remainder edge cases yield NaN`` () = + remF 5.0 0.0 |> Double.IsNaN |> equal true + remF infinity 2.0 |> Double.IsNaN |> equal true + remF nan 2.0 |> Double.IsNaN |> equal true + remF 5.0 infinity |> equal 5.0 + remF -5.0 infinity |> equal -5.0 + +// Rendering a plain Python `float` has to shorten a whole double ("5", not "5.0") and +// spell the non-finite values out, and the two interact: the shortening runs `int()`, +// which raises OverflowError on the infinities and ValueError on NaN. Non-literal +// operands keep the runtime path in play. All expectations verified on .NET. +let private strF (x: float) = string x + +[] +let ``test Float to string spells the non-finite values out`` () = + strF infinity |> equal "Infinity" + strF -infinity |> equal "-Infinity" + strF nan |> equal "NaN" + strF 5.0 |> equal "5" + strF 2.5 |> equal "2.5" + strF -0.5 |> equal "-0.5" + +[] +let ``test Float printf formatting handles the non-finite values`` () = + sprintf "%f" (divF 1.0 0.0) |> equal "Infinity" + sprintf "%f" (divF -1.0 0.0) |> equal "-Infinity" + sprintf "%f" (divF 0.0 0.0) |> equal "NaN" + sprintf "%f" 2.5 |> equal "2.500000" + sprintf "%g" (divF 1.0 0.0) |> equal "Infinity" + [] let ``test Bitwise and can be generated`` () = 6 &&& 2 |> equal 2 @@ -118,6 +234,30 @@ let ``test Bitwise shift left can be generated`` () = let ``test Bitwise shift left with unsigned integer works`` () = 1u <<< 31 |> equal 2147483648u +[] +let ``test Bitwise shift left discards bits shifted out of the width`` () = + // Multi-bit values distinguish a shift from a rotation: a rotation would + // wrap bit 1 around to bit 0 and give -2147483647 here. + 3 <<< 31 |> equal -2147483648 + 3y <<< 7 |> equal -128y + 3s <<< 15 |> equal -32768s + 3L <<< 63 |> equal -9223372036854775808L + 3uy <<< 7 |> equal 128uy + 3us <<< 15 |> equal 32768us + 3u <<< 31 |> equal 2147483648u + 3UL <<< 63 |> equal 9223372036854775808UL + +[] +let ``test Bitwise shift left masks the shift count to the type width`` () = + 1 <<< 32 |> equal 1 + 1 <<< 33 |> equal 2 + 1y <<< 8 |> equal 1y + 1s <<< 16 |> equal 1s + 1L <<< 64 |> equal 1L + 1uy <<< 9 |> equal 2uy + 1us <<< 16 |> equal 1us + 1u <<< 32 |> equal 1u + [] let ``test Bitwise OR on large unsigned integer works`` () = 0x80000000u ||| 0u |> equal (0x80000000u ||| 0u >>> 0) diff --git a/tests/Python/TestConvert.fs b/tests/Python/TestConvert.fs index 7cb280b80a..0ab2b74a89 100644 --- a/tests/Python/TestConvert.fs +++ b/tests/Python/TestConvert.fs @@ -400,6 +400,42 @@ let ``test System.Convert.ToInt16 works`` () = (fun () -> Convert.ToInt16("1.4")) |> throwsError "" (fun () -> Convert.ToInt16("foo")) |> throwsError "" +// Python represents int32 as a plain `int` and float64 as a plain `float`, while the +// other widths are wrapper objects. A widening conversion therefore has to shed the +// wrapper -- leaving it in place would keep the arithmetic at the *source* width, and +// a value assertion alone would not notice, since a wrapper compares equal to its +// value. These use function parameters so the conversions are not constant-folded +// away. All expectations verified on .NET. +let private toIntFromSByte (x: sbyte) = int x +let private toIntFromInt16 (x: int16) = int x +let private toFloatFromSingle (x: float32) = float x +let private toFloatFromByte (x: byte) = float x +let private toFloatFromInt (x: int) = float x + +[] +let ``test Widening to int does not keep the source width`` () = + toIntFromSByte 100y + 100 |> equal 200 + toIntFromSByte -100y - 100 |> equal -200 + toIntFromInt16 30000s * 2 |> equal 60000 + toIntFromInt16 -30000s - 30000 |> equal -60000 + +[] +let ``test Widening to float does not keep the source width`` () = + toFloatFromSingle 0.1f |> equal 0.10000000149011612 + toFloatFromByte 200uy / 3.0 |> equal (200.0 / 3.0) + toFloatFromInt 3 + 0.5 |> equal 3.5 + +[] +let ``test Widened and parsed values have the target runtime type`` () = + box (toIntFromSByte 5y) :? int |> equal true + box (toFloatFromInt 3) :? float |> equal true + box (toFloatFromSingle 0.5f) :? float |> equal true + box (Int32.Parse "42") :? int |> equal true + + let mutable parsed = 0 + Int32.TryParse("7", &parsed) |> equal true + box parsed :? int |> equal true + [] let ``test System.Convert.ToInt32 works`` () = let x = 1 diff --git a/tests/Python/TestType.fs b/tests/Python/TestType.fs index 9cf5458456..29e5416dcb 100644 --- a/tests/Python/TestType.fs +++ b/tests/Python/TestType.fs @@ -958,7 +958,12 @@ let ``test Type test with BigInt`` () = | _ -> false box 5I |> isBigInt |> equal true +#if !FABLE_COMPILER_PYTHON + // Python represents both bigint and int32 as the built-in `int`, which is already + // arbitrary precision, so a boxed int32 cannot be told apart from a bigint at + // runtime. JS only avoids this because it has a native `bigint` primitive. box 50 |> isBigInt |> equal false +#endif [] let ``test Property names don't clash with built-in JS objects`` () = // See #168