Adds beta support for Python in Fable. Transforming the Fable AST into a Python AST, then printing to typed Python source code.
| F# | Python | Comment |
|---|---|---|
| List (F#) | List.fs | F# immutable list |
| ResizeArray | list |
Python list |
| Map | Map.fs | F# immutable map |
| Record | types.py | dataclasses.dataclass decorated Record type |
| Option | Erased | F# None will be translated to Python None |
| An. Record | dict |
Python dict |
| dict | dict |
|
| Dictionary | dict |
MutableMap if comparer |
| tuple | tuple |
Python tuple |
| Decimal | decimal |
|
| DateTime | datetime |
|
| string | string |
|
| char | string |
|
[]<byte> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<sbyte> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<int16> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<uint16> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<int> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<uint32> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<long> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<uint64> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<float> |
FSharpArray |
Custom pyo3 wrapper (array.rs) |
[]<single> |
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 ints. The element type is spelled with the Python representation:
Array[int] and Array[float], with Array[uint8] and friends for the wrapped widths.
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 | 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) |
| 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) |
| float32 / single | Single | Float32 | Custom pyo3 wrapper (floats.rs) |
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)andint32(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:
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.
| 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.
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.
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.
:? 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.
| .NET | Python | Comment |
|---|---|---|
IEquatable |
__eq__ |
for determining equality of instances with method Equals |
IEnumerator |
__next__ |
|
IEnumerable |
__iter__ |
|
IComparable |
__lt__+ __eq__ |
Method CompareTo returns 0, 1 or -1 and is implemented for types that can be ordered or sorted. |
IDisposable |
__exit__ + __exit__ |
Every IDisposable will (and should) also implement a resource manager. |
ToString |
__str__ |
Calls to x.ToString will be translated to str(x). |
Python do not support multi-line lambdas. Currently we transform any arrow function into a separate function that is lifted up into the nearest statement block.
Currently translated to classes since they can be used to implement an interface and have methods etc. They are more like anonymous classes.
Translated to nested functions. Python has some support for named expressions (:=) but only for naming new
expressions. You cannot assign to e.g. an object property.
The generated Python code is now with type annotation. Python types and F# types are not fully compatible (Ref: microsoft/pyright#1264)
E.g:
let length(xs: 'TSource list) =
42Would translate to:
def length(source: List[TSource]) -> int:
return 42However, this gives errors in Pyright type checker (used by Pylance):
TypeVar "TSource" appears only once in generic function signature Pylance(reportInvalidTypeVarUse) (type variable) TSource
Thus we currently check that the generic parameters are used more than once. If not they will be translated as Any.
def length(source: List[Any]) -> int:
return 42Fable projects compiling to Python should set OutputType to Exe for projects having the main EntryPoint.
<OutputType>Exe</OutputType>Such projects will then be compiled with absolute imports. Python programs are not allowed to do relative imports. If
the project is compiled as a Library (default) it will use relative imports. This is important since the library
modules do not know the path where they are mounted by the application using them.