From 092dc000ce2b5dbad141913285c5e32bc1916cb4 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sun, 4 Oct 2026 11:39:30 +0200 Subject: [PATCH 1/2] feat(python): give System exceptions native Python bases --- .../Python/Fable2Python.Reflection.fs | 13 ++- .../fable_library/Fable.Library.Python.fsproj | 2 +- src/fable-library-py/fable_library/Native.fs | 32 +++++++ src/fable-library-py/fable_library/System.fs | 96 +++++++++++++++++++ .../fable_library/exception_bases.py | 38 ++++++++ tests/Python/TestMisc.fs | 83 ++++++++++++++++ 6 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 src/fable-library-py/fable_library/System.fs create mode 100644 src/fable-library-py/fable_library/exception_bases.py diff --git a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs index 0c45731748..477d12facf 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs @@ -291,6 +291,17 @@ let transformTypeInfo (com: IPythonCompiler) ctx r (genMap: Map Option.bind (fun baseType -> + let baseEnt = com.GetEntity(baseType.Entity) + + // decision: native exception bridges add Python ancestry without changing .NET reflection ancestry. + match (com :> Compiler), baseEnt.Attributes with + | FSharp2Fable.Util.ImportAtt(_, "fable_library.exception_bases") -> baseEnt.BaseType + | _ -> Some baseType + ) + let transformReflectionInfo com ctx r (ent: Fable.Entity) generics = if ent.IsFSharpRecord then transformRecordReflectionInfo com ctx r ent generics @@ -310,7 +321,7 @@ let transformReflectionInfo com ctx r (ent: Fable.Entity) generics = yield Expression.name (name.Name |> Naming.toPythonNaming), stmts | Some(cons, stmts) -> yield cons, stmts | None -> () - match ent.BaseType with + match getReflectionBaseType com ent with | Some d -> let genMap = Seq.zip ent.GenericParameters generics diff --git a/src/fable-library-py/fable_library/Fable.Library.Python.fsproj b/src/fable-library-py/fable_library/Fable.Library.Python.fsproj index 55b52d724b..b080b104a0 100644 --- a/src/fable-library-py/fable_library/Fable.Library.Python.fsproj +++ b/src/fable-library-py/fable_library/Fable.Library.Python.fsproj @@ -24,7 +24,7 @@ - + diff --git a/src/fable-library-py/fable_library/Native.fs b/src/fable-library-py/fable_library/Native.fs index 7b67f2e35c..1a9ac42bbc 100644 --- a/src/fable-library-py/fable_library/Native.fs +++ b/src/fable-library-py/fable_library/Native.fs @@ -6,6 +6,38 @@ module Native open Fable.Core open Fable.Core.PyInterop +[] +type ValueErrorBase(message: string, innerException: exn | null) = + inherit System.Exception(message, innerException) + +[] +type IndexErrorBase(message: string) = + inherit System.Exception(message) + +[] +type RuntimeErrorBase(message: string) = + inherit System.Exception(message) + +[] +type ZeroDivisionErrorBase(message: string) = + inherit System.Exception(message) + +[] +type OverflowErrorBase(message: string) = + inherit System.Exception(message) + +[] +type NotImplementedErrorBase(message: string) = + inherit System.Exception(message) + +[] +type MemoryErrorBase(message: string) = + inherit System.Exception(message) + +[] +type TimeoutErrorBase(message: string) = + inherit System.Exception(message) + [] [] type Cons<'T>(arrayType: string) = diff --git a/src/fable-library-py/fable_library/System.fs b/src/fable-library-py/fable_library/System.fs new file mode 100644 index 0000000000..a85c921c46 --- /dev/null +++ b/src/fable-library-py/fable_library/System.fs @@ -0,0 +1,96 @@ +namespace System + +// decision: keep Python exception ancestry local so other targets retain their existing bases. + +type SystemException(message: string) = + inherit Exception(message) + new() = SystemException(SR.Arg_SystemException) + +type ApplicationException(message: string) = + inherit Exception(message) + new() = ApplicationException(SR.Arg_ApplicationException) + +type ArithmeticException(message: string) = + inherit Exception(message) + new() = ArithmeticException(SR.Arg_ArithmeticException) + +type DivideByZeroException(message: string) = + inherit Native.ZeroDivisionErrorBase(message) + new() = DivideByZeroException(SR.Arg_DivideByZero) + +type FormatException(message: string) = + inherit Native.ValueErrorBase(message, Unchecked.defaultof) + new() = FormatException(SR.Arg_FormatException) + +type IndexOutOfRangeException(message: string) = + inherit Native.IndexErrorBase(message) + new() = IndexOutOfRangeException(SR.Arg_IndexOutOfRangeException) + +type InvalidOperationException(message: string) = + inherit Native.RuntimeErrorBase(message) + new() = InvalidOperationException(SR.Arg_InvalidOperationException) + +type NotFiniteNumberException(message: string) = + inherit Exception(message) + new() = NotFiniteNumberException(SR.Arg_NotFiniteNumberException) + +type NotImplementedException(message: string) = + inherit Native.NotImplementedErrorBase(message) + new() = NotImplementedException(SR.Arg_NotImplementedException) + +type NotSupportedException(message: string) = + inherit Exception(message) + new() = NotSupportedException(SR.Arg_NotSupportedException) + +type NullReferenceException(message: string) = + inherit Exception(message) + new() = NullReferenceException(SR.Arg_NullReferenceException) + +type OutOfMemoryException(message: string) = + inherit Native.MemoryErrorBase(message) + new() = OutOfMemoryException(SR.Arg_OutOfMemoryException) + +type OverflowException(message: string) = + inherit Native.OverflowErrorBase(message) + new() = OverflowException(SR.Arg_OverflowException) + +type RankException(message: string) = + inherit Exception(message) + new() = RankException(SR.Arg_RankException) + +type StackOverflowException(message: string) = + inherit Exception(message) + new() = StackOverflowException(SR.Arg_StackOverflowException) + +type TimeoutException(message: string) = + inherit Native.TimeoutErrorBase(message) + new() = TimeoutException(SR.Arg_TimeoutException) + +type ArgumentException(message: string, paramName: string, innerException: exn | null) = + inherit + Native.ValueErrorBase( + (if System.String.IsNullOrEmpty(paramName) then + message + else + message + SR.Arg_ParamName_Name + paramName + "')"), + innerException + ) + + // Use Unchecked.defaultof rather than a `null` literal for the absent inner + // exception: Fable erases nullable reference annotations, so a `null` literal + // would be emitted against a non-nullable parameter type and fail type checking. + new() = ArgumentException(SR.Arg_ArgumentException, "", Unchecked.defaultof) + new(message) = ArgumentException(message, "", Unchecked.defaultof) + new(message: string, paramName: string) = ArgumentException(message, paramName, Unchecked.defaultof) + new(message: string, innerException: exn | null) = ArgumentException(message, "", innerException) + member _.ParamName = paramName + +type ArgumentNullException(paramName: string, message: string) = + inherit ArgumentException(message, paramName, Unchecked.defaultof) + new(paramName) = ArgumentNullException(paramName, SR.ArgumentNull_Generic) + new() = ArgumentNullException("") + +type ArgumentOutOfRangeException(paramName: string, message: string) = + inherit ArgumentException(message, paramName, Unchecked.defaultof) + new(paramName) = ArgumentOutOfRangeException(paramName, SR.Arg_ArgumentOutOfRangeException) + new() = ArgumentOutOfRangeException("") diff --git a/src/fable-library-py/fable_library/exception_bases.py b/src/fable-library-py/fable_library/exception_bases.py new file mode 100644 index 0000000000..d7947c9c0d --- /dev/null +++ b/src/fable-library-py/fable_library/exception_bases.py @@ -0,0 +1,38 @@ +"""Python exception bases for generated .NET exception classes. + +decision: retain ExceptionBase ancestry and payloads while adding one specialized Python exception base. +""" + +from .types import ExceptionBase + + +class ValueErrorBase(ExceptionBase, ValueError): + """A value error with .NET message and inner-exception support.""" + + +class IndexErrorBase(ExceptionBase, IndexError): + """An index error with .NET message and inner-exception support.""" + + +class RuntimeErrorBase(ExceptionBase, RuntimeError): + """A runtime error with .NET message and inner-exception support.""" + + +class ZeroDivisionErrorBase(ExceptionBase, ZeroDivisionError): + """A division error with .NET message and inner-exception support.""" + + +class OverflowErrorBase(ExceptionBase, OverflowError): + """An overflow error with .NET message and inner-exception support.""" + + +class NotImplementedErrorBase(ExceptionBase, NotImplementedError): + """An unimplemented operation with .NET message and inner-exception support.""" + + +class MemoryErrorBase(ExceptionBase, MemoryError): + """A memory error with .NET message and inner-exception support.""" + + +class TimeoutErrorBase(ExceptionBase, TimeoutError): + """A timeout error with .NET message and inner-exception support.""" diff --git a/tests/Python/TestMisc.fs b/tests/Python/TestMisc.fs index 973873b61e..91201d64d0 100644 --- a/tests/Python/TestMisc.fs +++ b/tests/Python/TestMisc.fs @@ -1122,6 +1122,89 @@ let ``test Exception InnerException is null when not provided`` () = let ex = System.ArgumentException("no inner") isNull (box ex.InnerException) |> equal true +[] +let ``test TimeoutException preserves message and empty InnerException`` () = + let ex = TimeoutException("timeout message") + ex.Message |> equal "timeout message" + isNull (box ex.InnerException) |> equal true + let caught = + try + raise ex + false + with + | :? TimeoutException as caught -> obj.ReferenceEquals(ex, caught) + caught |> equal true + +[] +let ``test exceptions sharing a Python base retain distinct dotnet types`` () = + let caught = + try + raise (FormatException("bad format")) + "not caught" + with + | :? ArgumentException -> "argument" + | :? FormatException as ex -> ex.Message + caught |> equal "bad format" + +#if FABLE_COMPILER +module private PythonExceptionBases = + open Fable.Core.PyInterop + + let valueError: obj = import "ValueError" "builtins" + let indexError: obj = import "IndexError" "builtins" + let runtimeError: obj = import "RuntimeError" "builtins" + let zeroDivisionError: obj = import "ZeroDivisionError" "builtins" + let overflowError: obj = import "OverflowError" "builtins" + let notImplementedError: obj = import "NotImplementedError" "builtins" + let memoryError: obj = import "MemoryError" "builtins" + let timeoutError: obj = import "TimeoutError" "builtins" + let exceptionBase: obj = import "ExceptionBase" "fable_library.types" + let timeoutReflection: Func = import "TimeoutException_reflection" "fable_library.system" + let argumentNullReflection: Func = import "ArgumentNullException_reflection" "fable_library.system" + + [] + type TimeoutError() = inherit Exception() + +[] +let ``test dotnet exceptions also match their Python exception bases`` () = + let mappings: (exn * obj) list = [ + ArgumentException("message"), PythonExceptionBases.valueError + ArgumentNullException("parameter", "message"), PythonExceptionBases.valueError + ArgumentOutOfRangeException("parameter", "message"), PythonExceptionBases.valueError + FormatException("message"), PythonExceptionBases.valueError + IndexOutOfRangeException("message"), PythonExceptionBases.indexError + InvalidOperationException("message"), PythonExceptionBases.runtimeError + DivideByZeroException("message"), PythonExceptionBases.zeroDivisionError + OverflowException("message"), PythonExceptionBases.overflowError + NotImplementedException("message"), PythonExceptionBases.notImplementedError + OutOfMemoryException("message"), PythonExceptionBases.memoryError + TimeoutException("message"), PythonExceptionBases.timeoutError + ] + for ex, pythonBase in mappings do + Fable.Core.PyInterop.pyInstanceof ex pythonBase |> equal true + Fable.Core.PyInterop.pyInstanceof ex PythonExceptionBases.exceptionBase |> equal true + ex.Message.Contains("message") |> equal true + +[] +let ``test Python TimeoutError handler catches dotnet TimeoutException`` () = + let ex = TimeoutException("timeout message") + let caught = + try + raise ex + false + with + | :? PythonExceptionBases.TimeoutError as caught -> obj.ReferenceEquals(ex, caught) + caught |> equal true + +[] +let ``test Python exception reflection preserves dotnet ancestry`` () = + let timeoutType = PythonExceptionBases.timeoutReflection.Invoke() + timeoutType.IsSubclassOf(typeof) |> equal true + let argumentNullType = PythonExceptionBases.argumentNullReflection.Invoke() + argumentNullType.IsSubclassOf(typeof) |> equal true + argumentNullType.IsSubclassOf(typeof) |> equal true +#endif + [] let ``test use doesn't return on finally clause`` () = // See #211 let foo() = From 7a0f53af1f9fe3b73dee142c1278dd7951d9735b Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sun, 4 Oct 2026 14:00:49 +0200 Subject: [PATCH 2/2] refactor(python): centralize exception base definitions --- src/Fable.Transforms/Python/Replacements.fs | 4 ++-- .../fable_library/exception_bases.py | 16 +++++++++++++++- src/fable-library-py/fable_library/types.py | 17 +---------------- tests/Python/TestMisc.fs | 7 ++++++- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 2f18e27862..5fb4154f42 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -2847,7 +2847,7 @@ let exceptions (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr // | "System.Collections.Generic.KeyNotFoundException" | BuiltinSystemException _ -> bclType com ctx r t i thisArg args | _ -> - let e = makeImportLib com Any "ExceptionBase" "Types" + let e = makeImportLib com Any "ExceptionBase" "exception_bases" Helper.ConstructorCall(e, t, args, ?loc = r) |> Some | "get_Message", Some e -> Helper.GlobalCall("str", t, [ thisArg.Value ], ?loc = r) |> Some | "get_StackTrace", Some e -> getFieldWith r t e "stack" |> Some @@ -4329,7 +4329,7 @@ let tryCall (com: ICompiler) (ctx: Context) r t (info: CallInfo) (thisArg: Expr let tryBaseConstructor com ctx (ent: EntityRef) (argTypes: Lazy) genArgs args = match ent.FullName with - | Types.exception_ -> Some(makeImportLib com Any "ExceptionBase" "Types", args) + | Types.exception_ -> Some(makeImportLib com Any "ExceptionBase" "exception_bases", args) | Types.attribute -> Some(makeImportLib com Any "Attribute" "Types", args) | Types.dictionary -> let args = diff --git a/src/fable-library-py/fable_library/exception_bases.py b/src/fable-library-py/fable_library/exception_bases.py index d7947c9c0d..6e66ff5c20 100644 --- a/src/fable-library-py/fable_library/exception_bases.py +++ b/src/fable-library-py/fable_library/exception_bases.py @@ -3,7 +3,21 @@ decision: retain ExceptionBase ancestry and payloads while adding one specialized Python exception base. """ -from .types import ExceptionBase + +class ExceptionBase(Exception): + """Base class for .NET ``System.Exception`` and its subclasses. + + Subclasses the built-in ``Exception`` so ``raise``/``except``/``isinstance`` + keep working as before. Only the message is forwarded to the built-in + initializer, so ``str(exc)`` still returns the message even when an inner + exception is supplied (the built-in would otherwise stringify the whole + argument tuple). The inner exception is kept on a dedicated attribute so it + can be read back through ``System.Exception.InnerException``. + """ + + def __init__(self, message: str | None = None, inner_exception: Exception | None = None) -> None: + super().__init__(message if message is not None else "") + self.inner_exception: Exception | None = inner_exception class ValueErrorBase(ExceptionBase, ValueError): diff --git a/src/fable-library-py/fable_library/types.py b/src/fable-library-py/fable_library/types.py index 1d140768dd..33af68d883 100644 --- a/src/fable-library-py/fable_library/types.py +++ b/src/fable-library-py/fable_library/types.py @@ -25,6 +25,7 @@ uint32, uint64, ) +from .exception_bases import ExceptionBase as ExceptionBase # Unit type for F# unit-typed parameters. Using None mirrors F#'s @@ -44,22 +45,6 @@ class Attribute: ... -class ExceptionBase(Exception): - """Base class for .NET ``System.Exception`` and its subclasses. - - Subclasses the built-in ``Exception`` so ``raise``/``except``/``isinstance`` - keep working as before. Only the message is forwarded to the built-in - initializer, so ``str(exc)`` still returns the message even when an inner - exception is supplied (the built-in would otherwise stringify the whole - argument tuple). The inner exception is kept on a dedicated attribute so it - can be read back through ``System.Exception.InnerException``. - """ - - def __init__(self, message: str | None = None, inner_exception: Exception | None = None) -> None: - super().__init__(message if message is not None else "") - self.inner_exception: Exception | None = inner_exception - - # 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 diff --git a/tests/Python/TestMisc.fs b/tests/Python/TestMisc.fs index 91201d64d0..77534d182e 100644 --- a/tests/Python/TestMisc.fs +++ b/tests/Python/TestMisc.fs @@ -1158,13 +1158,18 @@ module private PythonExceptionBases = let notImplementedError: obj = import "NotImplementedError" "builtins" let memoryError: obj = import "MemoryError" "builtins" let timeoutError: obj = import "TimeoutError" "builtins" - let exceptionBase: obj = import "ExceptionBase" "fable_library.types" + let exceptionBase: obj = import "ExceptionBase" "fable_library.exception_bases" + let legacyExceptionBase: obj = import "ExceptionBase" "fable_library.types" let timeoutReflection: Func = import "TimeoutException_reflection" "fable_library.system" let argumentNullReflection: Func = import "ArgumentNullException_reflection" "fable_library.system" [] type TimeoutError() = inherit Exception() +[] +let ``test Python ExceptionBase compatibility import preserves class identity`` () = + obj.ReferenceEquals(PythonExceptionBases.exceptionBase, PythonExceptionBases.legacyExceptionBase) |> equal true + [] let ``test dotnet exceptions also match their Python exception bases`` () = let mappings: (exn * obj) list = [