diff --git a/src/build/fan/BuildPod.fan b/src/build/fan/BuildPod.fan
index 9801cb914..d7305ea02 100644
--- a/src/build/fan/BuildPod.fan
+++ b/src/build/fan/BuildPod.fan
@@ -107,6 +107,12 @@ abstract class BuildPod : BuildScript
**
Uri[]? jsDirs
+ **
+ ** List of Uris relative to build script of directories containing
+ ** the Python source files to compile for Python native methods.
+ **
+ Uri[]? pyDirs
+
**
** List of Uris relative to build script that should be searched for '.props'
** files to compile to JavaScript. You may also give relative paths to files
@@ -244,6 +250,7 @@ abstract class BuildPod : BuildScript
meta["pod.native.jni"] = (jniDirs != null && !jniDirs.isEmpty).toStr
meta["pod.native.dotnet"] = (dotnetDirs != null && !dotnetDirs.isEmpty).toStr
meta["pod.native.js"] = (jsDirs != null && !jsDirs.isEmpty).toStr
+ meta["pod.native.py"] = (pyDirs != null && !pyDirs.isEmpty).toStr
// TODO: add additinal meta props defined by config file/env var
// this behavior is not guaranteed in future versions, rather we
@@ -289,6 +296,7 @@ abstract class BuildPod : BuildScript
ci.resFiles = resDirs
ci.jsFiles = jsDirs
ci.jsPropsFiles = jsProps ?: resDirs
+ ci.pyFiles = pyDirs
ci.log = log
ci.includeDoc = docApi
ci.includeSrc = docSrc
diff --git a/src/compiler/fan/CompilerInput.fan b/src/compiler/fan/CompilerInput.fan
index 0bbce9838..4857ef0ad 100644
--- a/src/compiler/fan/CompilerInput.fan
+++ b/src/compiler/fan/CompilerInput.fan
@@ -191,6 +191,13 @@ class CompilerInput
**
Uri[]? jsPropsFiles
+ **
+ ** List of Python files or directories containing Python files
+ ** to include in the Python output. Uris are relative to `baseDir`.
+ ** This field is used only in file mode.
+ **
+ Uri[]? pyFiles
+
//////////////////////////////////////////////////////////////////////////
// CompilerInputMode.str
//////////////////////////////////////////////////////////////////////////
diff --git a/src/fanc/build.fan b/src/fanc/build.fan
index aade43567..a17aed73b 100755
--- a/src/fanc/build.fan
+++ b/src/fanc/build.fan
@@ -28,8 +28,8 @@ class Build : BuildPod
depends = ["sys 1.0", "build 1.0", "compiler 1.0", "util 1.0"]
srcDirs = [`fan/`,
`fan/java/`,
+ `fan/py/`,
`fan/util/`]
docSrc = true
}
}
-
diff --git a/src/fanc/fan/py/PyExprPrinter.fan b/src/fanc/fan/py/PyExprPrinter.fan
new file mode 100644
index 000000000..7cf5d8d6f
--- /dev/null
+++ b/src/fanc/fan/py/PyExprPrinter.fan
@@ -0,0 +1,1730 @@
+//
+// Copyright (c) 2025, Brian Frank and Andy Frank
+// Licensed under the Academic Free License version 3.0
+//
+// History:
+// 25 Feb 2026 Trevor Adelman Creation
+//
+
+using compiler
+
+**
+** PyExprPrinter generates Python expressions from Fantom AST nodes.
+**
+** Key Patterns:
+** - Primitives (Int, Str, Bool, Float) use static dispatch: sys.Int.plus(x, y)
+** - List/Map use instance dispatch: list.each(f) (NOT primitives)
+** - Closures wrap in Func.make_closure() for Fantom Func API (bind, params, etc.)
+** - Safe navigation (?.) uses lambda wrapper: ((lambda _safe_: ... if _safe_ is not None else None)(target))
+** - ObjUtil handles cross-type operations: equals, compare, typeof, is_, as_
+**
+** Dispatch Priority in call():
+** 1. Safe navigation check (?.method())
+** 2. cvar wrapper detection (closure variable wrappers)
+** 3. Dynamic call (-> operator) via ObjUtil.trap()
+** 4. Func.call() / Func.callList() -> direct invocation
+** 5. ObjUtil methods (equals, compare, typeof, etc.)
+** 6. Primitive type static dispatch (Int, Str, etc.)
+** 7. Private method static dispatch (non-virtual)
+** 8. Normal instance/static method call
+**
+** See design.md in this directory for full documentation.
+**
+class PyExprPrinter : PyPrinter
+{
+ new make(PyPrinter parent) : super.make(parent.m.out)
+ {
+ this.m = parent.m
+ }
+
+ ** Print an expression
+ Void expr(Expr e)
+ {
+ switch (e.id)
+ {
+ case ExprId.nullLiteral: nullLiteral
+ case ExprId.trueLiteral: trueLiteral
+ case ExprId.falseLiteral: falseLiteral
+ case ExprId.intLiteral: intLiteral(e)
+ case ExprId.floatLiteral: floatLiteral(e)
+ case ExprId.strLiteral: strLiteral(e)
+ case ExprId.listLiteral: listLiteral(e)
+ case ExprId.mapLiteral: mapLiteral(e)
+ case ExprId.rangeLiteral: rangeLiteral(e)
+ case ExprId.durationLiteral: durationLiteral(e)
+ case ExprId.decimalLiteral: decimalLiteral(e)
+ case ExprId.uriLiteral: uriLiteral(e)
+ case ExprId.localVar: localVar(e)
+ case ExprId.thisExpr: thisExpr
+ case ExprId.superExpr: superExpr(e)
+ case ExprId.call: call(e)
+ case ExprId.construction: construction(e)
+ case ExprId.field: field(e)
+ case ExprId.assign: assign(e)
+ case ExprId.same: same(e)
+ case ExprId.notSame: notSame(e)
+ case ExprId.boolNot: boolNot(e)
+ case ExprId.boolOr: boolOr(e)
+ case ExprId.boolAnd: boolAnd(e)
+ case ExprId.cmpNull: cmpNull(e)
+ case ExprId.cmpNotNull: cmpNotNull(e)
+ case ExprId.isExpr: isExpr(e)
+ case ExprId.isnotExpr: isnotExpr(e)
+ case ExprId.asExpr: asExpr(e)
+ case ExprId.coerce: coerce(e)
+ case ExprId.ternary: ternary(e)
+ case ExprId.elvis: elvis(e)
+ case ExprId.shortcut: shortcut(e)
+ case ExprId.closure: closure(e)
+ case ExprId.staticTarget: staticTarget(e)
+ case ExprId.typeLiteral: typeLiteral(e)
+ case ExprId.slotLiteral: slotLiteral(e)
+ case ExprId.itExpr: itExpr(e)
+ case ExprId.throwExpr: throwExpr(e)
+ case ExprId.unknownVar: unknownVar(e)
+ default:
+ throw UnsupportedErr("Unhandled expr type: $e.id")
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Literals
+//////////////////////////////////////////////////////////////////////////
+
+ private Void nullLiteral() { none }
+
+ private Void trueLiteral() { true_ }
+
+ private Void falseLiteral() { false_ }
+
+ private Void intLiteral(LiteralExpr e) { w(e.val) }
+
+ private Void floatLiteral(LiteralExpr e) { w(e.val) }
+
+ private Void strLiteral(LiteralExpr e) { str(e.val) }
+
+ private Void listLiteral(ListLiteralExpr e)
+ {
+ // Cast to ListType to get element type directly (no try/catch, no dynamic dispatch)
+ lt := (ListType)((CType)(e.explicitType ?: e.ctype)).deref
+ sysPrefix()
+ w("List.from_literal([")
+ e.vals.each |val, i|
+ {
+ if (i > 0) w(", ")
+ expr(val)
+ }
+ w("], ")
+ str(lt.v.signature)
+ w(")")
+ }
+
+ private Void mapLiteral(MapLiteralExpr e)
+ {
+ // Cast to MapType to get key/value types directly (no try/catch, no dynamic dispatch)
+ mt := (MapType)(e.explicitType ?: e.ctype)
+ sysPrefix()
+ w("Map.from_literal([")
+ e.keys.each |key, i|
+ {
+ if (i > 0) w(", ")
+ expr(key)
+ }
+ w("], [")
+ e.vals.each |val, i|
+ {
+ if (i > 0) w(", ")
+ expr(val)
+ }
+ w("], ")
+ str(mt.k.signature)
+ w(", ")
+ str(mt.v.signature)
+ w(")")
+ }
+
+ private Void rangeLiteral(RangeLiteralExpr e)
+ {
+ // Generate Range.make(start, end, exclusive)
+ sysPrefix()
+ w("Range.make(")
+ expr(e.start)
+ w(", ")
+ expr(e.end)
+ if (e.exclusive)
+ w(", True")
+ w(")")
+ }
+
+ private Void durationLiteral(LiteralExpr e)
+ {
+ // Duration literal - value is in nanoseconds
+ dur := e.val as Duration
+ sysPrefix()
+ if (dur != null)
+ w("Duration.make(").w(dur.ticks).w(")")
+ else
+ w("Duration.make(0)")
+ }
+
+ private Void decimalLiteral(LiteralExpr e)
+ {
+ // Decimal literal (5d suffix) - emit as Decimal.make("value")
+ // Matches JS transpiler pattern: sys.Decimal.make(value)
+ // Use string constructor to preserve precision for large values
+ val := e.val.toStr
+ sysPrefix()
+ w("Decimal.make(\"").w(val).w("\")")
+ }
+
+ private Void uriLiteral(LiteralExpr e)
+ {
+ // URI literal `http://example.com` -> Uri.from_str("http://example.com")
+ uri := e.val as Uri
+ sysPrefix()
+ if (uri != null)
+ w("Uri.from_str(").str(uri.toStr).w(")")
+ else
+ w("Uri.from_str(").str(e.val.toStr).w(")")
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Variables
+//////////////////////////////////////////////////////////////////////////
+
+ private Void localVar(LocalVarExpr e) { w(escapeName(e.var.name)) }
+
+ private Void thisExpr() { w("self") }
+
+ private Void superExpr(SuperExpr e) { w("super()") }
+
+ private Void itExpr(Expr e)
+ {
+ // "it" is the implicit closure parameter - output as "it"
+ w("it")
+ }
+
+ ** Unresolved variable reference -- output target.name if target present (matches ES compiler)
+ private Void unknownVar(Expr e)
+ {
+ uv := e as UnknownVarExpr
+ if (uv.target != null) { expr(uv.target); w(".") }
+ w(escapeName(uv.name))
+ }
+
+ private Void throwExpr(Expr e)
+ {
+ // throw as an expression (used in elvis, ternary, etc.)
+ // Python's `raise` is a statement, so we use a helper function
+ // ObjUtil.throw_(err) raises the exception and never returns
+ te := e as ThrowExpr
+ w("ObjUtil.throw_(")
+ expr(te.exception)
+ w(")")
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Calls
+//////////////////////////////////////////////////////////////////////////
+
+ private Void call(CallExpr e)
+ {
+ methodName := e.method.name
+
+ // Dispatch priority chain (see class doc)
+ if (e.isSafe && e.target != null) { callSafe(e); return }
+ if (e.isDynamic) { callDynamic(e); return }
+ // All sys::Func methods handled here: call/callList -> direct invocation,
+ // enterCtor/exitCtor/checkInCtor -> no-op (compiler-injected const protection)
+ if (e.method.parent.qname == "sys::Func")
+ {
+ if (methodName == "call" || methodName == "callList") { callFunc(e); return }
+ if (methodName == "enterCtor" || methodName == "exitCtor" || methodName == "checkInCtor")
+ { w("None"); return }
+ }
+ if (e.target != null && isObjUtilMethod(e.method)) { objUtilCall(e); return }
+ if (e.target != null && isPrimitiveType(e.target.ctype) && e.target.id != ExprId.staticTarget)
+ { primitiveCall(e); return }
+ if (e.method.isPrivate && !e.method.isStatic && !e.method.isCtor) { callPrivate(e); return }
+
+ // Normal instance/static method call -- resolve target prefix
+ callNormal(e)
+ }
+
+ ** Safe navigation: ((lambda _safe_: None if _safe_ is None else
)(target))
+ private Void callSafe(CallExpr e)
+ {
+ w("((lambda _safe_: None if _safe_ is None else ")
+ safeCallBody(e)
+ w(")(")
+ expr(e.target)
+ w("))")
+ }
+
+ ** Dynamic call (-> operator): ObjUtil.trap(target, name, args)
+ ** targetWriter overrides how the target is written (used by safeCallBody for _safe_)
+ private Void callDynamic(CallExpr e, |->|? targetWriter := null)
+ {
+ w("ObjUtil.trap(")
+ if (targetWriter != null) targetWriter()
+ else if (e.target != null) expr(e.target)
+ else w("self")
+ w(", ").str(e.name)
+ if (e.args.isEmpty)
+ {
+ w(", None)")
+ }
+ else
+ {
+ w(", [")
+ writeArgs(e.args)
+ w("])")
+ }
+ }
+
+ ** Func.call/callList -> direct Python invocation
+ private Void callFunc(CallExpr e)
+ {
+ if (e.target != null) expr(e.target)
+ w("(")
+ if (e.method.name == "callList" && !e.args.isEmpty)
+ {
+ w("*")
+ expr(e.args.first)
+ }
+ else
+ {
+ writeArgs(e.args)
+ }
+ w(")")
+ }
+
+ ** Private method -> static dispatch: ClassName.method(self/target, args)
+ private Void callPrivate(CallExpr e)
+ {
+ w(PyUtil.escapeTypeName(e.method.parent.name)).w(".").w(escapeName(e.method.name)).w("(")
+ if (e.target != null)
+ expr(e.target)
+ else if (!m.inStaticContext)
+ w("self")
+ if ((e.target != null || !m.inStaticContext) && !e.args.isEmpty) w(", ")
+ writeArgs(e.args)
+ w(")")
+ }
+
+ ** Normal method call -- resolve target prefix then emit name(args)
+ private Void callNormal(CallExpr e)
+ {
+ if (e.target != null)
+ {
+ expr(e.target)
+ w(".")
+ }
+ else if (e.method.isStatic)
+ {
+ writeTypeRef(e.method.parent.pod.name, PyUtil.escapeTypeName(e.method.parent.name))
+ w(".")
+ }
+ else if (m.inStaticContext)
+ {
+ w(PyUtil.escapeTypeName(e.method.parent.name)).w(".")
+ }
+ else if (e.method.isPrivate && !e.method.isCtor)
+ {
+ // Duplicate private check (reached when private method not caught above)
+ callPrivate(e)
+ return
+ }
+ else
+ {
+ w("self.")
+ }
+ w(escapeName(e.method.name))
+ w("(")
+ writeArgs(e.args)
+ w(")")
+ }
+
+ ** Check if method should be routed through ObjUtil
+ ** These are Obj/Num methods that may be called on primitives coerced to Obj or Num
+ private Bool isObjUtilMethod(CMethod m)
+ {
+ // All sys::Obj methods route through ObjUtil (matches ES compiler)
+ if (m.parent.isObj) return true
+
+ // Force equals/compare through ObjUtil for NaN-aware semantics
+ // even when resolved to a specific type (matches ES compiler)
+ name := m.name
+ if (name == "equals" || name == "compare") return true
+
+ // Num methods on Num-typed values (Python has no Num.py for static dispatch)
+ if (m.parent.isNum)
+ return name == "toFloat" || name == "toInt" || name == "toDecimal" || name == "toLocale"
+
+ // Decimal.toLocale on Decimal-typed values
+ if (m.parent.isDecimal) return name == "toLocale"
+
+ return false
+ }
+
+ ** Output ObjUtil method call: x.method() -> ObjUtil.method(x)
+ ** targetWriter overrides how the target is written (used by safeCallBody for _safe_)
+ private Void objUtilCall(CallExpr e, |->|? targetWriter := null)
+ {
+ pyName := escapeName(e.method.name)
+
+ w("ObjUtil.").w(pyName).w("(")
+ if (targetWriter != null) targetWriter()
+ else expr(e.target)
+ if (!e.args.isEmpty)
+ {
+ e.args.each |arg|
+ {
+ w(", ")
+ expr(arg)
+ }
+ }
+ w(")")
+ }
+
+ ** Map of Fantom primitive type qnames to their Python wrapper class names.
+ ** Primitives use static dispatch: x.method() -> sys.Type.method(x)
+ ** List and Map are NOT primitives -- they use normal instance dispatch.
+ ** Matches ES compiler pmap: Bool, Decimal, Float, Int, Str
+ private static const Str:Str primitiveMap :=
+ [
+ "sys::Bool": "Bool",
+ "sys::Int": "Int",
+ "sys::Float": "Float",
+ "sys::Decimal": "Float", // Decimal uses Float methods in Python
+ "sys::Str": "Str",
+ ]
+
+ ** Check if type is a primitive that needs static method calls
+ private Bool isPrimitiveType(CType? t)
+ {
+ if (t == null) return false
+ return primitiveMap.containsKey(t.toNonNullable.signature)
+ }
+
+ ** Write "sys." prefix when current pod is NOT the sys pod.
+ ** This is the standard pattern for qualifying sys pod types from non-sys code.
+ private Void sysPrefix()
+ {
+ if (m.curType?.pod?.name != "sys")
+ w("sys.")
+ }
+
+ ** Hand-written sys types that use Python @property for instance fields.
+ ** These types use property assignment (self.x = v) not method-call setters (self.x(v)).
+ ** Derived from: grep -l "@property" fan/src/sys/py/fan/*.py
+ private static const Str[] handWrittenSysTypes :=
+ [
+ "sys::Depend", // @property (version, isPlus, etc.)
+ "sys::Endian", // @property
+ "sys::List", // read-write @property (capacity)
+ "sys::Locale", // @property
+ "sys::Map", // read-write @property (def_, ordered, caseInsensitive)
+ "sys::StrBuf", // read-only @property (charset)
+ "sys::Type", // read-only @property (root, v, k, params, ret)
+ ]
+
+ ** Check if type is a hand-written sys type that uses Python @property
+ private Bool isHandWrittenSysType(Str qname) { handWrittenSysTypes.contains(qname) }
+
+
+ ** Get Python wrapper class name for primitive type
+ private Str primitiveClassName(CType t)
+ {
+ return primitiveMap.get(t.toNonNullable.signature, t.name)
+ }
+
+ ** Write a pod-qualified type reference.
+ ** Handles same-pod (dynamic import), sys-to-non-sys (sys. prefix),
+ ** cross-pod (dynamic import), and same-pod-sys (bare name).
+ private Void writeTypeRef(Str targetPod, Str typeName)
+ {
+ curPod := m.curType?.pod?.name
+ if (curPod != null && curPod != "sys" && curPod == targetPod)
+ {
+ // Same pod, non-sys - use dynamic import to avoid circular imports
+ podPath := PyUtil.podImport(targetPod)
+ w("__import__('${podPath}.${typeName}', fromlist=['${typeName}']).${typeName}")
+ }
+ else if (targetPod == "sys" && curPod != "sys")
+ {
+ // Sys pod type from non-sys pod - use sys. prefix
+ w("sys.").w(typeName)
+ }
+ else if (targetPod != "sys" && curPod != null && curPod != targetPod)
+ {
+ // Cross-pod reference (non-sys to non-sys) - use dynamic import
+ podPath := PyUtil.podImport(targetPod)
+ w("__import__('${podPath}.${typeName}', fromlist=['${typeName}']).${typeName}")
+ }
+ else
+ {
+ // Same pod (sys) or already imported directly
+ w(typeName)
+ }
+ }
+
+ ** Write comma-separated argument expressions
+ private Void writeArgs(Expr[] args)
+ {
+ args.each |arg, i|
+ {
+ if (i > 0) w(", ")
+ expr(arg)
+ }
+ }
+
+ ** Output primitive type static method call: x.method() -> sys.Type.method(x)
+ ** targetWriter overrides how the target is written (used by safeCallBody for _safe_)
+ private Void primitiveCall(CallExpr e, |->|? targetWriter := null)
+ {
+ className := primitiveClassName(e.target.ctype)
+ methodName := escapeName(e.method.name)
+
+ sysPrefix()
+ w(className).w(".").w(methodName).w("(")
+ if (targetWriter != null) targetWriter()
+ else expr(e.target)
+ if (!e.args.isEmpty)
+ {
+ e.args.each |arg|
+ {
+ w(", ")
+ expr(arg)
+ }
+ }
+ w(")")
+ }
+
+ ** Generate the body of a safe call using _safe_ as the target variable.
+ ** Delegates to the same dispatch methods as call() but writes _safe_ instead of expr(e.target).
+ private Void safeCallBody(CallExpr e)
+ {
+ safeTarget := |->| { w("_safe_") }
+
+ if (e.isDynamic) { callDynamic(e, safeTarget); return }
+ if (e.target != null && isPrimitiveType(e.target.ctype) && e.target.id != ExprId.staticTarget)
+ { primitiveCall(e, safeTarget); return }
+ if (e.target != null && isObjUtilMethod(e.method)) { objUtilCall(e, safeTarget); return }
+
+ // Regular instance method call: _safe_.method(args)
+ w("_safe_.").w(escapeName(e.method.name)).w("(")
+ writeArgs(e.args)
+ w(")")
+ }
+
+ ** Generate the body of a safe field access using _safe_ as the target variable
+ ** This is called from within a lambda wrapper: ((lambda _safe_: None if _safe_ is None else )(target))
+ private Void safeFieldBody(FieldExpr e)
+ {
+ fieldName := e.field.name
+
+ // useAccessor=false means direct storage access (&field syntax)
+ // In Python, backing fields use _fieldName pattern
+ if (!e.useAccessor && !e.field.isStatic)
+ w("_safe_._").w(escapeName(fieldName))
+ else
+ {
+ w("_safe_.").w(escapeName(fieldName))
+ // Instance fields on transpiled types need () for accessor method
+ if (!e.field.isStatic)
+ {
+ parentSig := e.field.parent.qname
+ if (!isHandWrittenSysType(parentSig))
+ w("()")
+ }
+ }
+ }
+
+ private Void construction(CallExpr e)
+ {
+ // Constructor call - always use factory pattern: ClassName.make(args)
+ writeTypeRef(e.method.parent.pod.name, PyUtil.escapeTypeName(e.method.parent.name))
+
+ // Always call the factory method: .make() or .fromStr() etc.
+ factoryName := e.method.name == "" ? "make" : e.method.name
+ w(".").w(escapeName(factoryName))
+
+ w("(")
+ writeArgs(e.args)
+ w(")")
+ }
+
+ ** Check if a field expression is accessing .val on a Wrap$ wrapper variable
+ ** If so, return the original variable name; null otherwise
+ ** Works for both outer scope (wrapper name -> original name via map) and
+ ** inner closure scope (variable already has original name from capture)
+ private Str? isWrapValAccess(FieldExpr e)
+ {
+ // Must be accessing field named "val" on a synthetic Wrap$ type
+ if (e.field.name != "val") return null
+ parentType := e.field.parent
+ if (!parentType.isSynthetic || !parentType.name.startsWith("Wrap\$")) return null
+
+ // Target must be a local variable
+ if (e.target == null || e.target.id != ExprId.localVar) return null
+ localTarget := e.target as LocalVarExpr
+ varName := localTarget.var.name
+
+ // Check if this is a known wrapper variable (outer scope: wrapper_name -> original_name)
+ origName := m.getNonlocalOriginal(varName)
+ if (origName != null) return origName
+
+ // Inside closures, the variable already has the original name (from captured field)
+ // If the field parent is Wrap$ and field is val, strip the field access regardless
+ return varName
+ }
+
+ private Void field(FieldExpr e)
+ {
+ fieldName := e.field.name
+
+ // Intercept Wrap$.val field access -- output the original variable name
+ // instead of wrapper._val (we use nonlocal instead of cvar wrappers)
+ origName := isWrapValAccess(e)
+ if (origName != null)
+ {
+ w(escapeName(origName))
+ return
+ }
+
+ // Handle safe navigation operator (?.): short-circuit to null if target is null
+ // Pattern: ((lambda _safe_: None if _safe_ is None else _safe_.field)())
+ if (e.isSafe && e.target != null)
+ {
+ w("((lambda _safe_: None if _safe_ is None else ")
+ safeFieldBody(e)
+ w(")(")
+ expr(e.target)
+ w("))")
+ return
+ }
+
+ // Primitive field access uses static dispatch, same as method calls
+ // e.g., str.size -> sys.Str.size(str) because Python str has no .size property
+ if (e.target != null && isPrimitiveType(e.target.ctype))
+ {
+ className := primitiveClassName(e.target.ctype)
+ sysPrefix()
+ w(className).w(".").w(escapeName(fieldName)).w("(")
+ expr(e.target)
+ w(")")
+ return
+ }
+
+ // Check for $this field (outer this capture in closures)
+ if (fieldName == "\$this")
+ {
+ // Inside closure, $this refers to outer self
+ // Multi-statement closures use _self, inline lambdas use _outer
+ if (m.inClosureWithOuter)
+ w("_outer")
+ else
+ w("_self")
+ return
+ }
+
+ // Check for captured local variable: pattern varName$N
+ // Fantom creates synthetic fields like js$0, expected$2 for captured locals
+ if (fieldName.contains("\$"))
+ {
+ idx := fieldName.index("\$")
+ if (idx != null && idx < fieldName.size - 1)
+ {
+ suffix := fieldName[idx+1..-1]
+ // Check if suffix is all digits
+ if (!suffix.isEmpty && suffix.all |c| { c.isDigit })
+ {
+ // This is a captured local variable - output just the base name
+ // If we are in a closure, we use the base name to capture from outer scope
+ baseName := fieldName[0.. property assignment
+ // - Transpiled types use def field(self, _val_=None): -> method call syntax
+ parentSig := fieldExpr.field.parent.qname
+ useMethodCall := !isHandWrittenSysType(parentSig)
+
+ if (useMethodCall)
+ {
+ // Use method call syntax: target.fieldName(value)
+ if (fieldExpr.target != null)
+ {
+ expr(fieldExpr.target)
+ w(".")
+ }
+ w(escapeName(fieldExpr.field.name))
+ w("(")
+ expr(e.rhs)
+ w(")")
+ }
+ else
+ {
+ // Use Python property assignment: self.fieldName = value
+ // This works with @property decorated getters/setters on hand-written types
+ if (fieldExpr.target != null)
+ {
+ expr(fieldExpr.target)
+ w(".")
+ }
+ w(escapeName(fieldExpr.field.name))
+ w(" = ")
+ expr(e.rhs)
+ }
+ }
+ else
+ {
+ // Direct storage access: self._count = value or ClassName._count for static
+ if (fieldExpr.target != null)
+ {
+ expr(fieldExpr.target)
+ w(".")
+ }
+ else if (fieldExpr.field.isStatic)
+ {
+ // Static field without explicit target - need class prefix
+ w(PyUtil.escapeTypeName(fieldExpr.field.parent.name)).w(".")
+ }
+ w("_").w(escapeName(fieldExpr.field.name))
+ w(" = ")
+ expr(e.rhs)
+ }
+ }
+ else
+ {
+ // Local var assignment - use walrus operator to make it an expression
+ // This allows assignment inside function calls, conditions, etc.
+ w("(")
+ expr(e.lhs)
+ w(" := ")
+ expr(e.rhs)
+ w(")")
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Comparison
+//////////////////////////////////////////////////////////////////////////
+
+ private Void same(BinaryExpr e)
+ {
+ // Use ObjUtil.same() for consistent identity semantics
+ // Python's 'is' operator is unreliable with interned literals
+ w("ObjUtil.same(")
+ expr(e.lhs)
+ w(", ")
+ expr(e.rhs)
+ w(")")
+ }
+
+ private Void notSame(BinaryExpr e)
+ {
+ // Use ObjUtil.same() for consistent identity semantics
+ w("(not ObjUtil.same(")
+ expr(e.lhs)
+ w(", ")
+ expr(e.rhs)
+ w("))")
+ }
+
+ private Void cmpNull(UnaryExpr e)
+ {
+ expr(e.operand)
+ w(" is None")
+ }
+
+ private Void cmpNotNull(UnaryExpr e)
+ {
+ expr(e.operand)
+ w(" is not None")
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Boolean Operators
+//////////////////////////////////////////////////////////////////////////
+
+ private Void boolNot(UnaryExpr e)
+ {
+ w("not ")
+ expr(e.operand)
+ }
+
+ private Void boolOr(CondExpr e)
+ {
+ w("(")
+ e.operands.each |op, i|
+ {
+ if (i > 0) w(" or ")
+ expr(op)
+ }
+ w(")")
+ }
+
+ private Void boolAnd(CondExpr e)
+ {
+ w("(")
+ e.operands.each |op, i|
+ {
+ if (i > 0) w(" and ")
+ expr(op)
+ }
+ w(")")
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Type Checks
+//////////////////////////////////////////////////////////////////////////
+
+ private Void isExpr(TypeCheckExpr e)
+ {
+ w("ObjUtil.is_(")
+ expr(e.target)
+ w(", ")
+ typeRef(e.check)
+ w(")")
+ }
+
+ private Void isnotExpr(TypeCheckExpr e)
+ {
+ w("(not ObjUtil.is_(")
+ expr(e.target)
+ w(", ")
+ typeRef(e.check)
+ w("))")
+ }
+
+ private Void asExpr(TypeCheckExpr e)
+ {
+ w("ObjUtil.as_(")
+ expr(e.target)
+ w(", ")
+ typeRef(e.check)
+ w(")")
+ }
+
+ private Void coerce(TypeCheckExpr e)
+ {
+ w("ObjUtil.coerce(")
+ expr(e.target)
+ w(", ")
+ typeRef(e.check)
+ w(")")
+ }
+
+ private Void typeRef(CType t)
+ {
+ // Sanitize Java FFI types so they're valid Python strings
+ // (they'll fail at runtime if actually used, like JS transpiler)
+ sig := PyUtil.sanitizeJavaFfi(t.signature)
+ str(sig)
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Ternary / Elvis
+//////////////////////////////////////////////////////////////////////////
+
+ private Void ternary(TernaryExpr e)
+ {
+ w("(")
+ // Handle assignments in ternary using walrus operator
+ ternaryBranch(e.trueExpr)
+ w(" if ")
+ expr(e.condition)
+ w(" else ")
+ ternaryBranch(e.falseExpr)
+ w(")")
+ }
+
+ ** Output ternary branch, converting assignments to walrus operator
+ private Void ternaryBranch(Expr e)
+ {
+ // Unwrap coerce if present
+ inner := unwrapCoerce(e)
+
+ // If it's an assignment, convert x = val to (x := val)
+ if (inner.id == ExprId.assign)
+ {
+ assign := inner as BinaryExpr
+ // For field assignments, fall back to regular expr (can't use walrus)
+ if (assign.lhs.id == ExprId.field)
+ {
+ expr(e)
+ return
+ }
+ // Use walrus operator for local var assignment
+ w("(")
+ expr(assign.lhs)
+ w(" := ")
+ expr(assign.rhs)
+ w(")")
+ }
+ else
+ {
+ expr(e)
+ }
+ }
+
+ private Void elvis(BinaryExpr e)
+ {
+ // a ?: b -> (lambda v: v if v is not None else b)(a)
+ w("((lambda _v: _v if _v is not None else ")
+ expr(e.rhs)
+ w(")(")
+ expr(e.lhs)
+ w("))")
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Shortcuts (operators)
+//////////////////////////////////////////////////////////////////////////
+
+ private Void shortcut(ShortcutExpr e)
+ {
+ // First, try operator maps for binary operators
+ binaryOp := PyUtil.binaryOperators.get(e.method.qname)
+ if (binaryOp != null)
+ {
+ doShortcutBinaryOp(e, binaryOp)
+ return
+ }
+
+ // Try unary operators
+ unaryOp := PyUtil.unaryOperators.get(e.method.qname)
+ if (unaryOp != null)
+ {
+ w(unaryOp)
+ expr(e.target)
+ return
+ }
+
+ // Fall back to switch for special cases
+ op := e.op
+ switch (op)
+ {
+ case ShortcutOp.eq:
+ // Use ObjUtil for NaN-aware comparison (NaN == NaN should be true)
+ // This matches JS transpiler behavior which uses ObjUtil.compareNE/compareEQ
+ if (e.opToken == Token.notEq)
+ comparison(e, "compare_ne")
+ else
+ comparison(e, "equals")
+ return
+ case ShortcutOp.cmp:
+ // Check the opToken for comparison type
+ switch (e.opToken)
+ {
+ case Token.lt: comparison(e, "compare_lt")
+ case Token.ltEq: comparison(e, "compare_le")
+ case Token.gt: comparison(e, "compare_gt")
+ case Token.gtEq: comparison(e, "compare_ge")
+ default: comparison(e, "compare") // <=>
+ }
+ case ShortcutOp.negate: w("(-"); expr(e.target); w(")")
+ case ShortcutOp.increment: increment(e)
+ case ShortcutOp.decrement: decrement(e)
+ case ShortcutOp.get: indexGet(e)
+ case ShortcutOp.set: indexSet(e)
+ // Fallback for arithmetic ops if not in map
+ case ShortcutOp.plus: doShortcutBinaryOp(e, "+")
+ case ShortcutOp.minus: doShortcutBinaryOp(e, "-")
+ case ShortcutOp.mult: doShortcutBinaryOp(e, "*")
+ case ShortcutOp.div: divOp(e) // Use ObjUtil.div for Fantom semantics (truncated)
+ case ShortcutOp.mod: modOp(e) // Use ObjUtil.mod for Fantom semantics (truncated)
+ default: throw UnsupportedErr("Unhandled shortcut operator: $op")
+ }
+ }
+
+ private Void doShortcutBinaryOp(ShortcutExpr e, Str op)
+ {
+ // String + non-string: route to Str.plus for proper type conversion
+ if (op == "+" && !e.isAssign && isStringPlusNonString(e))
+ {
+ stringPlusNonString(e)
+ return
+ }
+
+ // Compound assignment (x *= 3 -> x = x * 3)
+ if (e.isAssign)
+ {
+ compoundAssign(e, op)
+ return
+ }
+
+ // Simple binary op
+ w("(")
+ expr(e.target)
+ w(" ").w(op).w(" ")
+ expr(e.args.first)
+ w(")")
+ }
+
+ ** Compound assignment dispatch: handles local vars, fields, Wrap$.val, and index access
+ private Void compoundAssign(ShortcutExpr e, Str op)
+ {
+ target := unwrapCoerce(e.target)
+
+ if (target.id == ExprId.localVar)
+ {
+ localExpr := target as LocalVarExpr
+ varName := escapeName(localExpr.var.name)
+ // String += null needs Str.plus
+ if (isStringPlusNullAssign(e))
+ {
+ w("(").w(varName).w(" := ")
+ sysPrefix()
+ w("Str.plus(").w(varName).w(", ")
+ expr(e.args.first)
+ w("))")
+ }
+ else
+ {
+ w("(").w(varName).w(" := (").w(varName).w(" ").w(op).w(" ")
+ expr(e.args.first)
+ w("))")
+ }
+ }
+ else if (target.id == ExprId.field)
+ {
+ fieldExpr := target as FieldExpr
+ // Wrap$.val -> treat as local variable
+ origName := isWrapValAccess(fieldExpr)
+ if (origName != null)
+ {
+ varName := escapeName(origName)
+ w("(").w(varName).w(" := (").w(varName).w(" ").w(op).w(" ")
+ expr(e.args.first)
+ w("))")
+ }
+ else
+ {
+ // Normal field: target._field = target._field op value
+ escapedName := escapeName(fieldExpr.field.name)
+ if (fieldExpr.target != null) { expr(fieldExpr.target); w(".") }
+ w("_").w(escapedName).w(" = ")
+ if (fieldExpr.target != null) { expr(fieldExpr.target); w(".") }
+ w("_").w(escapedName).w(" ").w(op).w(" ")
+ expr(e.args.first)
+ }
+ }
+ else if (target.id == ExprId.shortcut)
+ {
+ shortcutTarget := target as ShortcutExpr
+ if (shortcutTarget.op == ShortcutOp.get)
+ indexCompoundAssign(shortcutTarget, op, e.args.first)
+ else
+ {
+ w("("); expr(e.target); w(" ").w(op).w(" "); expr(e.args.first); w(")")
+ }
+ }
+ else
+ {
+ // Fallback
+ w("("); expr(e.target); w(" ").w(op).w(" "); expr(e.args.first); w(")")
+ }
+ }
+
+ ** Check if this is a string + non-string pattern that needs Str.plus()
+ ** In Fantom, string + anything converts the other operand to string
+ ** Also handles nullable strings (Str?) which might be null at runtime
+ private Bool isStringPlusNonString(ShortcutExpr e)
+ {
+ // If neither is a string, no special handling needed
+ targetIsStr := e.target?.ctype?.toNonNullable?.isStr ?: false
+ argIsStr := e.args.first?.ctype?.toNonNullable?.isStr ?: false
+
+ if (!targetIsStr && !argIsStr) return false
+
+ // If one is string and other is NOT string, use Str.plus for conversion
+ if (targetIsStr && !argIsStr) return true
+ if (argIsStr && !targetIsStr) return true
+
+ // Both are strings - but check if either is nullable (might be null at runtime)
+ // Python can't do "str" + None, so we need Str.plus for null handling
+ targetIsNullable := e.target?.ctype?.isNullable ?: false
+ argIsNullable := e.args.first?.ctype?.isNullable ?: false
+
+ if (targetIsNullable || argIsNullable) return true
+
+ // Both are non-null strings - use native Python concatenation
+ return false
+ }
+
+ ** Check if this is a string += null compound assignment pattern
+ private Bool isStringPlusNullAssign(ShortcutExpr e)
+ {
+ // Check if RHS is null
+ if (e.args.first?.id != ExprId.nullLiteral) return false
+
+ // Check if target is string type
+ return e.target?.ctype?.toNonNullable?.isStr ?: false
+ }
+
+ ** Handle string + non-string concatenation using sys.Str.plus()
+ private Void stringPlusNonString(ShortcutExpr e)
+ {
+ sysPrefix()
+ w("Str.plus(")
+ expr(e.target)
+ w(", ")
+ expr(e.args.first)
+ w(")")
+ }
+
+ ** Handle indexed compound assignment: x[i] += val -> x[i] = x[i] + val
+ private Void indexCompoundAssign(ShortcutExpr indexExpr, Str op, Expr value)
+ {
+ // Generate: container[index] = container[index] op value
+ // We need to evaluate container and index only once in case they have side effects
+ // For simplicity, generate: target[index] = target[index] op value
+
+ expr(indexExpr.target)
+ w("[")
+ expr(indexExpr.args.first)
+ w("] = ")
+ expr(indexExpr.target)
+ w("[")
+ expr(indexExpr.args.first)
+ w("] ").w(op).w(" ")
+ expr(value)
+ }
+
+ private Void comparison(ShortcutExpr e, Str method)
+ {
+ w("ObjUtil.").w(method).w("(")
+ expr(e.target)
+ w(", ")
+ expr(e.args.first)
+ w(")")
+ }
+
+ private Void divOp(ShortcutExpr e)
+ {
+ // Float division uses Python / directly (no truncation issue)
+ if (e.target?.ctype?.toNonNullable?.isFloat ?: false)
+ {
+ doShortcutBinaryOp(e, "/")
+ return
+ }
+ // Int division uses ObjUtil.div for truncated division semantics
+ // (Python // is floor division, Fantom uses truncated toward zero)
+ objUtilOp(e, "div")
+ }
+
+ private Void modOp(ShortcutExpr e)
+ {
+ // Use ObjUtil.mod for Fantom-style modulo semantics
+ // (truncated division vs Python's floor division)
+ objUtilOp(e, "mod")
+ }
+
+ ** Emit ObjUtil.{method}(target, arg) with compound assignment support
+ private Void objUtilOp(ShortcutExpr e, Str method)
+ {
+ // Compound assignment to local var: x /= y -> (x := ObjUtil.div(x, y))
+ if (e.isAssign)
+ {
+ target := unwrapCoerce(e.target)
+ if (target.id == ExprId.localVar)
+ {
+ localExpr := target as LocalVarExpr
+ varName := escapeName(localExpr.var.name)
+ w("(").w(varName).w(" := ObjUtil.").w(method).w("(").w(varName).w(", ")
+ expr(e.args.first)
+ w("))")
+ return
+ }
+ }
+ // Simple call or non-local compound assignment fallback
+ w("ObjUtil.").w(method).w("(")
+ expr(e.target)
+ w(", ")
+ expr(e.args.first)
+ w(")")
+ }
+
+ ** Unwrap coerce expressions to get the underlying expression
+ private Expr unwrapCoerce(Expr e)
+ {
+ if (e.id == ExprId.coerce)
+ {
+ te := e as TypeCheckExpr
+ return unwrapCoerce(te.target)
+ }
+ return e
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Increment / Decrement
+//////////////////////////////////////////////////////////////////////////
+
+ // Python has no ++ or -- operators. We generate different code depending on target:
+ // - Local vars: walrus operator (x := x + 1) for pre, tuple trick for post
+ // - Fields: ObjUtil.inc_field(obj, "_name") / ObjUtil.inc_field_post(...)
+ // - Index: ObjUtil.inc_index(container, key) / ObjUtil.inc_index_post(...)
+ //
+ // Post-increment/decrement returns the OLD value (before modification).
+ // Pre-increment/decrement returns the NEW value (after modification).
+
+ private Void increment(ShortcutExpr e) { incDec(e, "+", "inc") }
+
+ private Void decrement(ShortcutExpr e) { incDec(e, "-", "dec") }
+
+ ** Unified increment/decrement: op is "+" or "-", prefix is "inc" or "dec"
+ ** Pre (++x/--x) returns new value, post (x++/x--) returns old value
+ private Void incDec(ShortcutExpr e, Str op, Str prefix)
+ {
+ target := unwrapCoerce(e.target)
+ isPost := e.isPostfixLeave
+
+ if (target.id == ExprId.field)
+ {
+ fieldExpr := target as FieldExpr
+
+ // Check for Wrap$.val -- treat as local variable
+ origName := isWrapValAccess(fieldExpr)
+ if (origName != null)
+ {
+ varName := escapeName(origName)
+ if (isPost)
+ {
+ w("((_old_").w(varName).w(" := ").w(varName).w(", ")
+ w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1, ")
+ w("_old_").w(varName).w(")[2])")
+ }
+ else
+ {
+ w("(").w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1)")
+ }
+ return
+ }
+
+ // Normal field access - use ObjUtil helper
+ method := isPost ? "${prefix}_field_post" : "${prefix}_field"
+ w("ObjUtil.").w(method).w("(")
+ if (fieldExpr.target != null)
+ expr(fieldExpr.target)
+ else
+ w("self")
+ w(", \"_").w(escapeName(fieldExpr.field.name)).w("\")")
+ }
+ else if (target.id == ExprId.shortcut)
+ {
+ // Index access (list[i]++/--) - use ObjUtil helper
+ shortcutExpr := target as ShortcutExpr
+ if (shortcutExpr.op == ShortcutOp.get)
+ {
+ method := isPost ? "${prefix}_index_post" : "${prefix}_index"
+ w("ObjUtil.").w(method).w("(")
+ expr(shortcutExpr.target)
+ w(", ")
+ expr(shortcutExpr.args.first)
+ w(")")
+ }
+ else
+ {
+ w("(")
+ expr(e.target)
+ w(" ").w(op).w(" 1)")
+ }
+ }
+ else if (target.id == ExprId.localVar)
+ {
+ localExpr := target as LocalVarExpr
+ varName := escapeName(localExpr.var.name)
+ if (isPost)
+ {
+ // Post: return old value via tuple trick: ((_old := x, x := x +/- 1, _old)[2])
+ w("((_old_").w(varName).w(" := ").w(varName).w(", ")
+ w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1, ")
+ w("_old_").w(varName).w(")[2])")
+ }
+ else
+ {
+ w("(").w(varName).w(" := ").w(varName).w(" ").w(op).w(" 1)")
+ }
+ }
+ else
+ {
+ // Fallback - just apply op (won't assign but won't error)
+ w("(")
+ expr(e.target)
+ w(" ").w(op).w(" 1)")
+ }
+ }
+
+ private Void indexGet(ShortcutExpr e)
+ {
+ // Check target type for special handling
+ targetType := e.target?.ctype?.toNonNullable
+ arg := e.args.first
+ argType := arg.ctype?.toNonNullable
+
+ // String indexing: str[i] returns Int codepoint, str[range] returns substring
+ if (targetType?.isStr ?: false)
+ {
+ if (argType?.isRange ?: false)
+ {
+ // str[range] -> sys.Str.get_range(str, range)
+ sysPrefix()
+ w("Str.get_range(")
+ expr(e.target)
+ w(", ")
+ expr(arg)
+ w(")")
+ }
+ else
+ {
+ // str[i] -> sys.Str.get(str, i) returns Int codepoint
+ sysPrefix()
+ w("Str.get(")
+ expr(e.target)
+ w(", ")
+ expr(arg)
+ w(")")
+ }
+ return
+ }
+
+ // Check if index is a Range - need to use sys.List.get_range() instead
+ if (argType?.isRange ?: false)
+ {
+ // list[range] -> sys.List.get_range(list, range)
+ sysPrefix()
+ w("List.get_range(")
+ expr(e.target)
+ w(", ")
+ expr(arg)
+ w(")")
+ }
+ else
+ {
+ expr(e.target)
+ w("[")
+ expr(arg)
+ w("]")
+ }
+ }
+
+ private Void indexSet(ShortcutExpr e)
+ {
+ expr(e.target)
+ w("[")
+ expr(e.args.first)
+ w("] = ")
+ expr(e.args[1])
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Static Targets and Type Literals
+//////////////////////////////////////////////////////////////////////////
+
+ private Void staticTarget(StaticTargetExpr e)
+ {
+ writeTypeRef(e.ctype.pod.name, PyUtil.escapeTypeName(e.ctype.name))
+ }
+
+ private Void typeLiteral(LiteralExpr e)
+ {
+ // Type literal like Bool# - create a Type instance
+ t := e.val as CType
+ if (t != null)
+ {
+ sig := PyUtil.sanitizeJavaFfi(t.signature)
+ sysPrefix()
+ w("Type.find(").str(sig).w(")")
+ }
+ else
+ {
+ w("None")
+ }
+ }
+
+ private Void slotLiteral(SlotLiteralExpr e)
+ {
+ // Slot literal like Int#plus - create Method.find() or Field.find()
+ // Use original Fantom name (not snake_case) - Type.slot() handles the conversion
+ parentSig := e.parent.signature
+ slotName := e.name // Keep original Fantom camelCase name
+
+ // Determine if it's a method or field
+ if (e.slot != null && e.slot is CField)
+ {
+ sysPrefix()
+ w("Field.find(").str("${parentSig}.${slotName}").w(")")
+ }
+ else
+ {
+ // Default to Method
+ sysPrefix()
+ w("Method.find(").str("${parentSig}.${slotName}").w(")")
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Closures
+//////////////////////////////////////////////////////////////////////////
+
+ private Void closure(ClosureExpr e)
+ {
+ // Check if this closure was already registered during scan phase
+ closureId := m.findClosureId(e)
+ if (closureId != null)
+ {
+ // Already emitted as def, just output reference
+ w("_closure_${closureId}")
+ return
+ }
+
+ // Try various fields for closure body
+ Block? codeBlock := null
+ if (e.doCall != null && e.doCall.code != null)
+ codeBlock = e.doCall.code
+ else if (e.call != null && e.call.code != null)
+ codeBlock = e.call.code
+ else if (e.code != null)
+ codeBlock = e.code
+
+ if (codeBlock != null)
+ {
+ stmts := codeBlock.stmts
+
+ // Single-statement closures can use inline lambda
+ // Filter out local var decls, synthetic/void return statements
+ realStmts := stmts.findAll |s|
+ {
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ return ret.expr != null
+ }
+ if (s.id == StmtId.localDef) return false
+ return true
+ }
+
+ // Check if simple single-expression body (can use lambda)
+ // Assignments cannot be in lambdas - they're statements, not expressions
+ if (realStmts.size == 1)
+ {
+ stmt := realStmts.first
+ if (stmt.id == StmtId.returnStmt)
+ {
+ ret := stmt as ReturnStmt
+ // Skip if return contains assignment or index set
+ if (ret.expr != null && !isAssignmentExpr(ret.expr))
+ {
+ closureLambda(e) |->| { expr(ret.expr) }
+ return
+ }
+ }
+ // Handle throw statement - convert to ObjUtil.throw_() for lambda
+ if (stmt.id == StmtId.throwStmt)
+ {
+ throwStmt := stmt as ThrowStmt
+ closureLambda(e) |->|
+ {
+ w("ObjUtil.throw_(")
+ expr(throwStmt.exception)
+ w(")")
+ }
+ return
+ }
+ if (stmt.id == StmtId.expr)
+ {
+ exprStmt := stmt as ExprStmt
+ // Assignments can't be in lambda body (unless we convert them)
+ if (!isAssignmentExpr(exprStmt.expr))
+ {
+ closureLambda(e) |->| { expr(exprStmt.expr) }
+ return
+ }
+ // Check if it's an index set: map[key] = value
+ // Can convert to: map.__setitem__(key, value) for lambda
+ if (exprStmt.expr.id == ExprId.shortcut)
+ {
+ se := exprStmt.expr as ShortcutExpr
+ if (se.op == ShortcutOp.set && se.args.size == 2)
+ {
+ closureLambda(e) |->|
+ {
+ expr(se.target)
+ w(".__setitem__(")
+ expr(se.args.first) // key
+ w(", ")
+ expr(se.args[1]) // value
+ w(")")
+ }
+ return
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback - wrap with Func.make_closure() even when body not handled
+ // This ensures ALL closures have bind(), params(), etc. (consistent with JS transpiler)
+ closureLambda(e) |->| { none }
+ }
+
+ ** Generate lambda with outer self capture if needed
+ ** Uses Func.make_closure() for proper Fantom Func methods (bind, params, etc.)
+ private Void closureLambda(ClosureExpr e, |->| body)
+ {
+ // Check if closure captures outer this (has $this field)
+ needsOuter := e.cls?.fieldDefs?.any |f| { f.name == "\$this" } ?: false
+
+ // Get type info from signature
+ sig := e.signature as FuncType
+
+ // Determine immutability from compiler analysis
+ immutCase := m.closureImmutability(e)
+
+ // Generate Func.make_closure(spec, lambda)
+ sysPrefix()
+ w("Func.make_closure({")
+
+ // Returns type
+ retType := sig?.returns?.signature ?: "sys::Void"
+ w("\"returns\": ").str(retType).w(", ")
+
+ // Immutability case from compiler analysis
+ w("\"immutable\": ").str(immutCase).w(", ")
+
+ // Params (sanitize Java FFI type signatures)
+ w("\"params\": [")
+ if (e.doCall?.params != null)
+ {
+ e.doCall.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ pSig := PyUtil.sanitizeJavaFfi(p.type.signature)
+ w("{\"name\": ").str(p.name).w(", \"type\": ").str(pSig).w("}")
+ }
+ }
+ else if (sig != null && !sig.params.isEmpty)
+ {
+ sig.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ name := sig.names.getSafe(i) ?: "_p${i}"
+ pSig := PyUtil.sanitizeJavaFfi(p.signature)
+ w("{\"name\": ").str(name).w(", \"type\": ").str(pSig).w("}")
+ }
+ }
+ w("]}, ")
+
+ // Lambda body
+ if (needsOuter)
+ {
+ w("(lambda ")
+ closureParams(e)
+ w(", _outer=self: ")
+ m.inClosureWithOuter = true
+ body()
+ m.inClosureWithOuter = false
+ w(")")
+ }
+ else
+ {
+ w("(lambda ")
+ closureParams(e)
+ w(": ")
+ body()
+ w(")")
+ }
+
+ w(")") // Close Func.make_closure()
+ }
+
+ ** Check if expression is an assignment (can't be in lambda body)
+ ** Note: Increment/decrement CAN be in lambdas because they transpile to
+ ** ObjUtil.incField()/decField() which are function calls returning values
+ private Bool isAssignmentExpr(Expr e)
+ {
+ // Direct assignment
+ if (e.id == ExprId.assign) return true
+
+ // Index set (list[i] = x)
+ if (e.id == ExprId.shortcut)
+ {
+ se := e as ShortcutExpr
+ if (se.op == ShortcutOp.set) return true
+ // Compound assignment (x += 5), but NOT increment/decrement
+ // Increment/decrement transpile to ObjUtil.incField() which returns a value
+ if (se.isAssign && se.op != ShortcutOp.increment && se.op != ShortcutOp.decrement)
+ return true
+ }
+
+ // Check wrapped in coerce
+ if (e.id == ExprId.coerce)
+ {
+ tc := e as TypeCheckExpr
+ return isAssignmentExpr(tc.target)
+ }
+
+ return false
+ }
+
+ private Void closureParams(ClosureExpr e)
+ {
+ // Get the signature - this is the EXPECTED type (what the target method wants)
+ // which may have fewer params than declared in source code (Fantom allows coercion)
+ sig := e.signature as FuncType
+ expectedParamCount := sig?.params?.size ?: 0
+
+ // Use doCall.params for parameter names, but LIMIT to expected count
+ // This handles cases where closure declares extra params that get coerced away
+ // ALL params get =None default because Python (unlike JS) requires all args
+ // JS: f(a,b) called as f() gives a=undefined, b=undefined
+ // Python: f(a,b) called as f() raises TypeError
+ if (e.doCall?.params != null && !e.doCall.params.isEmpty)
+ {
+ // Only output up to expectedParamCount params (or all if signature unavailable)
+ maxParams := expectedParamCount > 0 ? expectedParamCount : e.doCall.params.size
+ actualCount := e.doCall.params.size.min(maxParams)
+
+ actualCount.times |i|
+ {
+ if (i > 0) w(", ")
+ w(escapeName(e.doCall.params[i].name)).w("=None")
+ }
+
+ // If no params were output but we need at least one for lambda syntax
+ // Use _=None so it doesn't require an argument
+ if (actualCount == 0 && expectedParamCount == 0)
+ w("_=None")
+ }
+ // Fallback to signature for it-blocks with implicit it
+ else
+ {
+ if (sig != null && !sig.params.isEmpty)
+ {
+ // Check if this is an it-block (uses implicit it)
+ if (e.isItBlock)
+ {
+ w("it=None")
+ }
+ else
+ {
+ sig.names.each |name, i|
+ {
+ if (i > 0) w(", ")
+ if (name.isEmpty)
+ w("_p${i}=None")
+ else
+ w(escapeName(name)).w("=None")
+ }
+ }
+ }
+ else
+ {
+ w("_=None") // Lambda needs placeholder but shouldn't require arg
+ }
+ }
+ }
+}
diff --git a/src/fanc/fan/py/PyPrinter.fan b/src/fanc/fan/py/PyPrinter.fan
new file mode 100644
index 000000000..0c115580d
--- /dev/null
+++ b/src/fanc/fan/py/PyPrinter.fan
@@ -0,0 +1,283 @@
+//
+// Copyright (c) 2025, Brian Frank and Andy Frank
+// Licensed under the Academic Free License version 3.0
+//
+// History:
+// 25 Feb 2026 Trevor Adelman Creation
+//
+
+using compiler
+
+**
+** Base class for Python code printers
+**
+class PyPrinter : CodePrinter
+{
+ new make(OutStream out) { this.m = PyPrinterState(out) }
+
+ override PyPrinterState m
+
+ ** End of statement (newline in Python)
+ This eos() { nl }
+
+ ** Write a colon and newline (for block start)
+ This colon() { w(":").nl }
+
+ ** Write a Python string literal
+ ** Handles UTF-16 surrogate pairs for code points > 0xFFFF
+ This str(Str s)
+ {
+ w("\"")
+ i := 0
+ while (i < s.size)
+ {
+ ch := s[i]
+ code := ch.toInt
+
+ // Check for high surrogate (0xD800-0xDBFF) followed by low surrogate (0xDC00-0xDFFF)
+ if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.size)
+ {
+ lowCh := s[i + 1]
+ lowCode := lowCh.toInt
+ if (lowCode >= 0xDC00 && lowCode <= 0xDFFF)
+ {
+ // Combine surrogate pair into full code point
+ fullCode := ((code - 0xD800) * 0x400) + (lowCode - 0xDC00) + 0x10000
+ w("\\U${fullCode.toHex(8)}")
+ i += 2
+ continue
+ }
+ }
+
+ switch (ch)
+ {
+ case '\n': w("\\n")
+ case '\r': w("\\r")
+ case '\t': w("\\t")
+ case '\"': w("\\\"")
+ case '\\': w("\\\\")
+ default:
+ // Escape control characters and non-ASCII to keep output as valid ASCII
+ if (code < 0x20 || code > 0x7E)
+ {
+ if (code < 0x100)
+ w("\\x${code.toHex(2)}")
+ else if (code < 0x10000)
+ w("\\u${code.toHex(4)}")
+ else
+ w("\\U${code.toHex(8)}")
+ }
+ else
+ {
+ w(ch.toChar)
+ }
+ }
+ i++
+ }
+ w("\"")
+ return this
+ }
+
+ ** Write 'pass' statement for empty blocks
+ This pass() { w("pass").eos }
+
+ ** Write import statement
+ This importStmt(Str module, Str? alias := null)
+ {
+ w("import ").w(module)
+ if (alias != null) w(" as ").w(alias)
+ eos
+ return this
+ }
+
+ ** Write from...import statement
+ This fromImport(Str module, Str[] names)
+ {
+ w("from ").w(module).w(" import ")
+ names.each |n, i|
+ {
+ if (i > 0) w(", ")
+ w(n)
+ }
+ eos
+ return this
+ }
+
+ ** Write Python None literal
+ This none() { w("None") }
+
+ ** Write Python True literal
+ This true_() { w("True") }
+
+ ** Write Python False literal
+ This false_() { w("False") }
+
+ ** Escape Python reserved words
+ Str escapeName(Str name) { PyUtil.escapeName(name) }
+
+ ** Convert Fantom qname to Python module path
+ Str qnameToPy(CType ctype)
+ {
+ "${ctype.pod.name}.${ctype.name}"
+ }
+}
+
+**************************************************************************
+** PyPrinterState
+**************************************************************************
+
+class PyPrinterState : CodePrinterState
+{
+ new make(OutStream out) : super(out) {}
+
+ ** Current type being generated
+ TypeDef? curType
+
+ ** Current method being generated
+ MethodDef? curMethod
+
+ ** Are we in a static context (static method/initializer)?
+ ** When true, 'self' is not available
+ Bool inStaticContext := false
+
+ ** Are we inside a closure that captures outer this?
+ Bool inClosureWithOuter := false
+
+ ** Counter for generating unique closure names
+ Int closureCount := 0
+
+ ** Pending multi-statement closures to emit before next statement
+ Obj[] pendingClosures := [,]
+
+ ** List of [ClosureExpr, Int] pairs for lookup during expr phase
+ ** We use identity comparison since ClosureExpr isn't immutable
+ Obj[] registeredClosures := [,]
+
+ ** Map: closureId -> statementIndex where first used
+ Int:Int closureFirstUse := [:]
+
+ ** Current statement index during emission
+ Int stmtIndex := 0
+
+ ** Closure nesting depth - when > 0, we're scanning inside a closure body
+ ** Nested closures should NOT be extracted to method level
+ Int closureDepth := 0
+
+ ** Current for loop update expression - when set, continue statements
+ ** must emit this expression before the continue to prevent infinite loops
+ Expr? forLoopUpdate
+
+ ** Look up closure ID by object identity
+ Int? findClosureId(Obj ce)
+ {
+ for (i := 0; i < registeredClosures.size; i++)
+ {
+ pair := registeredClosures[i] as Obj[]
+ if (pair[0] === ce) return pair[1]
+ }
+ return null
+ }
+
+ ** Flag to prevent closure emission during expression processing
+ Bool collectingClosures := false
+
+ ** Get next closure ID and increment counter
+ Int nextClosureId() { closureCount++ }
+
+ ** Counter for generating unique switch variable names
+ Int switchVarCount := 0
+
+ ** Get next switch variable ID and increment counter
+ Int nextSwitchVarId() { switchVarCount++ }
+
+ //////////////////////////////////////////////////////////////////////////
+ // Nonlocal Variable Tracking (for closure-captured mutable variables)
+ //////////////////////////////////////////////////////////////////////////
+
+ ** Maps Wrap$ wrapper variable names to their original variable names
+ ** Example: "x_Wrapper" -> "x" when x is captured and modified in a closure
+ ** The Fantom compiler generates Wrap$* classes for these variables;
+ ** we use Python's nonlocal keyword instead of ObjUtil.cvar() wrappers
+ Str:Str nonlocalVars := [:]
+
+ ** Record a wrapper->original variable mapping
+ ** Called when we detect a Wrap$.make() definition in a localDef
+ Void recordNonlocal(Str wrapperVarName, Str originalVarName)
+ {
+ nonlocalVars[wrapperVarName] = originalVarName
+ }
+
+ ** Get the original variable name for a Wrap$ wrapper variable
+ ** Returns null if this variable is not a known wrapper
+ Str? getNonlocalOriginal(Str wrapperVarName)
+ {
+ return nonlocalVars.get(wrapperVarName)
+ }
+
+ ** Get all original variable names that need nonlocal declarations in closures
+ Str[] getNonlocalNames()
+ {
+ return nonlocalVars.vals
+ }
+
+ ** Clear closure state (call between methods)
+ Void clearClosures()
+ {
+ pendingClosures.clear
+ registeredClosures.clear
+ closureFirstUse.clear
+ stmtIndex = 0
+ closureDepth = 0 // Reset nesting depth
+ nonlocalVars.clear // Clear nonlocal mappings for new method
+ }
+
+ //////////////////////////////////////////////////////////////////////////
+ // Closure Immutability
+ //////////////////////////////////////////////////////////////////////////
+
+ ** Determine closure immutability case from ClosureExpr.cls
+ ** The compiler's ClosureToImmutable step adds synthetic methods:
+ ** - Always immutable: isImmutable() { return true }
+ ** - Never immutable: toImmutable() throws NotImmutableErr (no isImmutable override)
+ ** - Maybe immutable: isImmutable() { return this.immutable } + toImmutable() makes copy
+ ** Returns: "always", "never", or "maybe"
+ Str closureImmutability(ClosureExpr e)
+ {
+ cls := e.cls
+ if (cls == null) return "always" // no captures = always immutable
+
+ // Find isImmutable method (added by ClosureToImmutable step)
+ isImmMethod := cls.methodDefs.find |m| { m.name == "isImmutable" && m.isSynthetic }
+
+ if (isImmMethod == null)
+ {
+ // No isImmutable override - check for toImmutable that throws (never immutable)
+ toImmMethod := cls.methodDefs.find |m| { m.name == "toImmutable" && m.isSynthetic }
+ if (toImmMethod != null)
+ {
+ // Check if toImmutable throws NotImmutableErr (never case)
+ // The method body contains ThrowStmt if it's never immutable
+ if (toImmMethod.code?.stmts?.any |s| { s.id == StmtId.throwStmt } ?: false)
+ return "never"
+ }
+ // No isImmutable and no throwing toImmutable = always immutable
+ return "always"
+ }
+
+ // Check what isImmutable returns
+ // Case 1: returns true literal -> always immutable
+ // Case 2: returns false literal -> never immutable (shouldn't happen, but handle it)
+ // Case 3: returns field reference -> maybe immutable
+ retStmt := isImmMethod.code?.stmts?.first as ReturnStmt
+ if (retStmt?.expr != null)
+ {
+ if (retStmt.expr.id == ExprId.trueLiteral) return "always"
+ if (retStmt.expr.id == ExprId.falseLiteral) return "never"
+ // Field reference (isImmutable() { return immutable }) = maybe
+ if (retStmt.expr.id == ExprId.field) return "maybe"
+ }
+
+ // Couldn't determine - default to maybe (safest)
+ return "maybe"
+ }
+}
diff --git a/src/fanc/fan/py/PyStmtPrinter.fan b/src/fanc/fan/py/PyStmtPrinter.fan
new file mode 100644
index 000000000..6786ad9b4
--- /dev/null
+++ b/src/fanc/fan/py/PyStmtPrinter.fan
@@ -0,0 +1,1493 @@
+//
+// Copyright (c) 2025, Brian Frank and Andy Frank
+// Licensed under the Academic Free License version 3.0
+//
+// History:
+// 25 Feb 2026 Trevor Adelman Creation
+//
+
+using compiler
+
+**
+** Python statement printer
+**
+class PyStmtPrinter : PyPrinter
+{
+ new make(PyPrinter parent) : super.make(parent.m.out)
+ {
+ this.m = parent.m
+ this.exprPrinter = PyExprPrinter(this)
+ }
+
+ private PyExprPrinter exprPrinter
+
+ ** Print an expression
+ Void expr(Expr e) { exprPrinter.expr(e) }
+
+ ** Print a statement
+ Void stmt(Stmt s)
+ {
+ // Check if we need to emit closures before this statement
+ emitPendingClosuresForStatement()
+
+ switch (s.id)
+ {
+ case StmtId.nop: return // no-op
+ case StmtId.expr: exprStmt(s)
+ case StmtId.localDef: localDef(s)
+ case StmtId.ifStmt: ifStmt(s)
+ case StmtId.returnStmt: returnStmt(s)
+ case StmtId.throwStmt: throwStmt(s)
+ case StmtId.forStmt: forStmt(s)
+ case StmtId.whileStmt: whileStmt(s)
+ case StmtId.breakStmt: w("break").eos
+ case StmtId.continueStmt: continueStmt()
+ case StmtId.tryStmt: tryStmt(s)
+ case StmtId.switchStmt: switchStmt(s)
+ default:
+ w("# TODO: stmt ${s.id}").eos
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Method-Level Closure Scanning
+//////////////////////////////////////////////////////////////////////////
+
+ ** Scan entire method body for multi-statement closures
+ ** Also pre-scans for Wrap$ definitions so nonlocal names are known before closures emit
+ Void scanMethodForClosures(Block b)
+ {
+ // Pre-scan: find ALL Wrap$ definitions in the method body (recursively)
+ // This must happen first so that when closures are emitted, getNonlocalNames()
+ // returns all nonlocal variables -- including ones defined after the closure
+ scanBlockForNonlocals(b)
+
+ // Use index to track statement location
+ b.stmts.each |s, idx|
+ {
+ m.stmtIndex = idx
+ scanStmt(s)
+ }
+ }
+
+ ** Recursively scan a block for Wrap$ wrapper definitions
+ ** Records all nonlocal variable mappings before any closures are emitted
+ private Void scanBlockForNonlocals(Block b)
+ {
+ b.stmts.each |s| { scanStmtForNonlocals(s) }
+ }
+
+ ** Scan a statement for Wrap$ definitions (and recurse into nested blocks)
+ private Void scanStmtForNonlocals(Stmt s)
+ {
+ switch (s.id)
+ {
+ case StmtId.localDef:
+ localDef := s as LocalDefStmt
+ prescanNonlocal(localDef)
+ case StmtId.ifStmt:
+ ifStmt := s as IfStmt
+ scanBlockForNonlocals(ifStmt.trueBlock)
+ if (ifStmt.falseBlock != null)
+ scanBlockForNonlocals(ifStmt.falseBlock)
+ case StmtId.whileStmt:
+ whileStmt := s as WhileStmt
+ scanBlockForNonlocals(whileStmt.block)
+ case StmtId.forStmt:
+ forStmt := s as ForStmt
+ if (forStmt.init != null) scanStmtForNonlocals(forStmt.init)
+ if (forStmt.block != null)
+ scanBlockForNonlocals(forStmt.block)
+ case StmtId.tryStmt:
+ tryStmt := s as TryStmt
+ scanBlockForNonlocals(tryStmt.block)
+ tryStmt.catches.each |c| { scanBlockForNonlocals(c.block) }
+ if (tryStmt.finallyBlock != null)
+ scanBlockForNonlocals(tryStmt.finallyBlock)
+ case StmtId.switchStmt:
+ switchStmt := s as SwitchStmt
+ switchStmt.cases.each |c| { scanBlockForNonlocals(c.block) }
+ if (switchStmt.defaultBlock != null)
+ scanBlockForNonlocals(switchStmt.defaultBlock)
+ }
+ }
+
+ ** Pre-scan a localDef statement for Wrap$.make() pattern (same logic as detectAndRecordNonlocal)
+ private Void prescanNonlocal(LocalDefStmt s)
+ {
+ if (s.init == null) return
+
+ // Unwrap coerces and assignments
+ initExpr := s.init
+ while (initExpr.id == ExprId.coerce)
+ {
+ tc := initExpr as TypeCheckExpr
+ initExpr = tc.target
+ }
+ if (initExpr.id == ExprId.assign)
+ {
+ assign := initExpr as BinaryExpr
+ initExpr = assign.rhs
+ while (initExpr.id == ExprId.coerce)
+ {
+ tc := initExpr as TypeCheckExpr
+ initExpr = tc.target
+ }
+ }
+
+ if (initExpr.id != ExprId.call) return
+
+ call := initExpr as CallExpr
+ if (call.method.name == "make" && call.target == null && !call.method.isStatic && call.args.size == 1)
+ {
+ parentType := call.method.parent
+ if (parentType.isSynthetic && parentType.name.startsWith("Wrap\$"))
+ {
+ arg := call.args.first
+ while (arg.id == ExprId.coerce)
+ {
+ tc := arg as TypeCheckExpr
+ arg = tc.target
+ }
+
+ wrapperVarName := s.name
+ if (arg.id == ExprId.localVar)
+ {
+ localArg := arg as LocalVarExpr
+ m.recordNonlocal(wrapperVarName, localArg.var.name)
+ }
+ else
+ {
+ m.recordNonlocal(wrapperVarName, wrapperVarName)
+ }
+ }
+ }
+ }
+
+ ** Recursively scan a statement for closure expressions
+ private Void scanStmt(Stmt s)
+ {
+ switch (s.id)
+ {
+ case StmtId.expr:
+ exprStmt := s as ExprStmt
+ scanExprForClosures(exprStmt.expr)
+ case StmtId.localDef:
+ localDef := s as LocalDefStmt
+ if (localDef.init != null)
+ scanExprForClosures(localDef.init)
+ case StmtId.ifStmt:
+ ifStmt := s as IfStmt
+ scanExprForClosures(ifStmt.condition)
+ scanInnerBlockForClosures(ifStmt.trueBlock)
+ if (ifStmt.falseBlock != null)
+ scanInnerBlockForClosures(ifStmt.falseBlock)
+ case StmtId.returnStmt:
+ ret := s as ReturnStmt
+ if (ret.expr != null)
+ scanExprForClosures(ret.expr)
+ case StmtId.throwStmt:
+ throwStmt := s as ThrowStmt
+ scanExprForClosures(throwStmt.exception)
+ case StmtId.whileStmt:
+ whileStmt := s as WhileStmt
+ scanExprForClosures(whileStmt.condition)
+ scanInnerBlockForClosures(whileStmt.block)
+ case StmtId.forStmt:
+ forStmt := s as ForStmt
+ if (forStmt.init != null) scanStmt(forStmt.init)
+ if (forStmt.condition != null) scanExprForClosures(forStmt.condition)
+ if (forStmt.update != null) scanExprForClosures(forStmt.update)
+ if (forStmt.block != null) scanInnerBlockForClosures(forStmt.block)
+ case StmtId.tryStmt:
+ tryStmt := s as TryStmt
+ scanInnerBlockForClosures(tryStmt.block)
+ tryStmt.catches.each |c| { scanInnerBlockForClosures(c.block) }
+ if (tryStmt.finallyBlock != null)
+ scanInnerBlockForClosures(tryStmt.finallyBlock)
+ case StmtId.switchStmt:
+ switchStmt := s as SwitchStmt
+ scanExprForClosures(switchStmt.condition)
+ switchStmt.cases.each |c| { scanInnerBlockForClosures(c.block) }
+ if (switchStmt.defaultBlock != null)
+ scanInnerBlockForClosures(switchStmt.defaultBlock)
+ }
+ }
+
+ ** Scan inner block (don't increment method stmtIndex)
+ private Void scanInnerBlockForClosures(Block b)
+ {
+ b.stmts.each |s| { scanStmt(s) }
+ }
+
+ ** Recursively scan an expression for closures
+ private Void scanExprForClosures(Expr e)
+ {
+ // Check if this is a closure that needs extraction
+ if (e.id == ExprId.closure)
+ {
+ ce := e as ClosureExpr
+ if (isMultiStatementClosure(ce))
+ {
+ // Only register closures at method level (depth == 0)
+ // Nested closures (depth > 0) will be emitted inside their parent closure.
+ // If a closure has nested multi-statement closures, isMultiStatementClosure
+ // returns true, so the parent will be extracted and nested defs can be
+ // properly emitted inside it.
+ if (m.closureDepth == 0)
+ {
+ // Find existing or register new closure
+ closureId := m.findClosureId(ce)
+ if (closureId == null)
+ {
+ closureId = m.nextClosureId
+ m.pendingClosures.add([ce, closureId])
+ m.registeredClosures.add([ce, closureId])
+ }
+
+ // Record first usage location if not already recorded
+ if (!m.closureFirstUse.containsKey(closureId))
+ {
+ m.closureFirstUse[closureId] = m.stmtIndex
+ }
+ }
+ }
+ }
+
+ // Recursively scan child expressions
+ scanExprChildren(e)
+ }
+
+ ** Scan children of an expression
+ private Void scanExprChildren(Expr e)
+ {
+ switch (e.id)
+ {
+ case ExprId.call:
+ ce := e as CallExpr
+ if (ce.target != null) scanExprForClosures(ce.target)
+ ce.args.each |arg| { scanExprForClosures(arg) }
+ case ExprId.construction:
+ // Constructor calls - scan args for closures
+ ce := e as CallExpr
+ ce.args.each |arg| { scanExprForClosures(arg) }
+ case ExprId.listLiteral:
+ // List literals can contain closures
+ le := e as ListLiteralExpr
+ le.vals.each |val| { scanExprForClosures(val) }
+ case ExprId.mapLiteral:
+ // Map literals can contain closures in values
+ me := e as MapLiteralExpr
+ me.keys.each |key| { scanExprForClosures(key) }
+ me.vals.each |val| { scanExprForClosures(val) }
+ case ExprId.shortcut:
+ se := e as ShortcutExpr
+ if (se.target != null) scanExprForClosures(se.target)
+ se.args.each |arg| { scanExprForClosures(arg) }
+ case ExprId.ternary:
+ te := e as TernaryExpr
+ scanExprForClosures(te.condition)
+ scanExprForClosures(te.trueExpr)
+ scanExprForClosures(te.falseExpr)
+ case ExprId.boolOr:
+ co := e as CondExpr
+ co.operands.each |op| { scanExprForClosures(op) }
+ case ExprId.boolAnd:
+ ca := e as CondExpr
+ ca.operands.each |op| { scanExprForClosures(op) }
+ case ExprId.coerce:
+ tc := e as TypeCheckExpr
+ scanExprForClosures(tc.target)
+ case ExprId.assign:
+ be := e as BinaryExpr
+ scanExprForClosures(be.lhs)
+ scanExprForClosures(be.rhs)
+ case ExprId.elvis:
+ ee := e as BinaryExpr
+ scanExprForClosures(ee.lhs)
+ scanExprForClosures(ee.rhs)
+ case ExprId.field:
+ // Field expressions can have targets that contain closures
+ // e.g., ActorPool() { maxThreads = 2 }.maxThreads
+ fe := e as FieldExpr
+ if (fe.target != null) scanExprForClosures(fe.target)
+ case ExprId.closure:
+ // Scan INSIDE the closure body for nested closures
+ // Increment depth so nested closures won't be registered at method level
+ cl := e as ClosureExpr
+ Block? codeBlock := null
+ if (cl.doCall != null && cl.doCall.code != null)
+ codeBlock = cl.doCall.code
+ else if (cl.call != null && cl.call.code != null)
+ codeBlock = cl.call.code
+ else if (cl.code != null)
+ codeBlock = cl.code
+ if (codeBlock != null)
+ {
+ m.closureDepth++
+ scanInnerBlockForClosures(codeBlock)
+ m.closureDepth--
+ }
+ // localVar, literals etc have no children with closures
+ }
+ }
+
+ ** Scan a closure body for nested multi-statement closures and register them
+ ** This allows nested closures to be emitted as defs before being used
+ private Void scanClosureBodyForNestedClosures(ClosureExpr ce)
+ {
+ codeBlock := ce.doCall?.code ?: ce.code
+ if (codeBlock == null) return
+
+ // Scan each statement, tracking index for closure emission
+ codeBlock.stmts.each |s, idx|
+ {
+ m.stmtIndex = idx
+ scanStmtForNestedClosures(s)
+ }
+ }
+
+ ** Scan a statement for nested closures (registers them for emission)
+ private Void scanStmtForNestedClosures(Stmt s)
+ {
+ switch (s.id)
+ {
+ case StmtId.expr:
+ exprStmt := s as ExprStmt
+ scanExprForNestedClosures(exprStmt.expr)
+ case StmtId.localDef:
+ localDef := s as LocalDefStmt
+ if (localDef.init != null)
+ scanExprForNestedClosures(localDef.init)
+ case StmtId.returnStmt:
+ ret := s as ReturnStmt
+ if (ret.expr != null)
+ scanExprForNestedClosures(ret.expr)
+ case StmtId.ifStmt:
+ ifStmt := s as IfStmt
+ scanExprForNestedClosures(ifStmt.condition)
+ ifStmt.trueBlock.stmts.each |st| { scanStmtForNestedClosures(st) }
+ if (ifStmt.falseBlock != null)
+ ifStmt.falseBlock.stmts.each |st| { scanStmtForNestedClosures(st) }
+ case StmtId.whileStmt:
+ whileStmt := s as WhileStmt
+ scanExprForNestedClosures(whileStmt.condition)
+ whileStmt.block.stmts.each |st| { scanStmtForNestedClosures(st) }
+ case StmtId.forStmt:
+ forStmt := s as ForStmt
+ if (forStmt.init != null) scanStmtForNestedClosures(forStmt.init)
+ if (forStmt.condition != null) scanExprForNestedClosures(forStmt.condition)
+ if (forStmt.update != null) scanExprForNestedClosures(forStmt.update)
+ if (forStmt.block != null) forStmt.block.stmts.each |st| { scanStmtForNestedClosures(st) }
+ }
+ }
+
+ ** Scan an expression for nested closures (registers them)
+ ** Only registers IMMEDIATE nested closures - deeper nesting will be handled
+ ** recursively when each nested closure writes its own body
+ private Void scanExprForNestedClosures(Expr e)
+ {
+ if (e.id == ExprId.closure)
+ {
+ ce := e as ClosureExpr
+ if (isMultiStatementClosure(ce))
+ {
+ // Register for emission inside parent closure
+ closureId := m.findClosureId(ce)
+ if (closureId == null)
+ {
+ closureId = m.nextClosureId
+ m.pendingClosures.add([ce, closureId])
+ m.registeredClosures.add([ce, closureId])
+ }
+ if (!m.closureFirstUse.containsKey(closureId))
+ {
+ m.closureFirstUse[closureId] = m.stmtIndex
+ }
+ }
+
+ // DON'T recursively scan inside - that will happen when writeClosure
+ // processes this closure's body and calls scanClosureBodyForNestedClosures
+ return
+ }
+
+ // Scan children (but not inside closures - handled above)
+ scanExprChildrenForNestedClosures(e)
+ }
+
+ ** Scan children of an expression for nested closures
+ private Void scanExprChildrenForNestedClosures(Expr e)
+ {
+ switch (e.id)
+ {
+ case ExprId.call:
+ ce := e as CallExpr
+ if (ce.target != null) scanExprForNestedClosures(ce.target)
+ ce.args.each |arg| { scanExprForNestedClosures(arg) }
+ case ExprId.construction:
+ ce := e as CallExpr
+ ce.args.each |arg| { scanExprForNestedClosures(arg) }
+ case ExprId.shortcut:
+ se := e as ShortcutExpr
+ if (se.target != null) scanExprForNestedClosures(se.target)
+ se.args.each |arg| { scanExprForNestedClosures(arg) }
+ case ExprId.ternary:
+ te := e as TernaryExpr
+ scanExprForNestedClosures(te.condition)
+ scanExprForNestedClosures(te.trueExpr)
+ scanExprForNestedClosures(te.falseExpr)
+ case ExprId.boolOr:
+ co := e as CondExpr
+ co.operands.each |op| { scanExprForNestedClosures(op) }
+ case ExprId.boolAnd:
+ ca := e as CondExpr
+ ca.operands.each |op| { scanExprForNestedClosures(op) }
+ case ExprId.coerce:
+ tc := e as TypeCheckExpr
+ scanExprForNestedClosures(tc.target)
+ case ExprId.assign:
+ be := e as BinaryExpr
+ scanExprForNestedClosures(be.lhs)
+ scanExprForNestedClosures(be.rhs)
+ case ExprId.elvis:
+ ee := e as BinaryExpr
+ scanExprForNestedClosures(ee.lhs)
+ scanExprForNestedClosures(ee.rhs)
+ }
+ }
+
+ ** Check if a closure requires multi-statement def extraction
+ private Bool isMultiStatementClosure(ClosureExpr ce)
+ {
+ // Check all possible code block locations (matching PyExprPrinter)
+ Block? codeBlock := null
+ if (ce.doCall != null && ce.doCall.code != null)
+ codeBlock = ce.doCall.code
+ else if (ce.call != null && ce.call.code != null)
+ codeBlock = ce.call.code
+ else if (ce.code != null)
+ codeBlock = ce.code
+
+ if (codeBlock == null) return false
+
+ stmts := codeBlock.stmts
+
+ // Check if closure has local variable declarations
+ hasLocalVars := stmts.any |s| { s.id == StmtId.localDef }
+ if (hasLocalVars) return true
+
+ // Check if closure has assignments (can't be in lambda body)
+ // Must unwrap coerces since assignments are often wrapped in type coercions
+ hasAssign := stmts.any |s|
+ {
+ if (s.id == StmtId.expr)
+ {
+ es := s as ExprStmt
+ return isAssignmentExpr(es.expr)
+ }
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr != null)
+ return isAssignmentExpr(ret.expr)
+ }
+ return false
+ }
+ if (hasAssign) return true
+
+ // Check if closure has control flow statements that can't be in lambda body
+ // These include if, switch, for, while, try - they have nested blocks
+ hasControlFlow := stmts.any |s|
+ {
+ s.id == StmtId.ifStmt ||
+ s.id == StmtId.switchStmt ||
+ s.id == StmtId.forStmt ||
+ s.id == StmtId.whileStmt ||
+ s.id == StmtId.tryStmt
+ }
+ if (hasControlFlow) return true
+
+ // Count real statements (excluding synthetic returns)
+ realStmtCount := 0
+ stmts.each |s|
+ {
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr != null) realStmtCount++
+ }
+ else if (s.id != StmtId.nop)
+ {
+ realStmtCount++
+ }
+ }
+
+ if (realStmtCount > 1) return true
+
+ // Check if this closure contains any nested multi-statement closures.
+ // If so, this closure must ALSO be extracted as a def so that the nested
+ // closure can be properly emitted inside it (and capture variables from
+ // this closure's scope). This is recursive - the containsNestedMultiStatement
+ // check will propagate up the entire closure tree.
+ if (containsNestedMultiStatementClosure(codeBlock)) return true
+
+ return false
+ }
+
+ ** Recursively check if a code block contains any nested multi-statement closures
+ private Bool containsNestedMultiStatementClosure(Block b)
+ {
+ return b.stmts.any |s| { stmtContainsNestedMultiStatementClosure(s) }
+ }
+
+ ** Check if a statement contains a nested multi-statement closure
+ private Bool stmtContainsNestedMultiStatementClosure(Stmt s)
+ {
+ switch (s.id)
+ {
+ case StmtId.expr:
+ exprStmt := s as ExprStmt
+ return exprContainsNestedMultiStatementClosure(exprStmt.expr)
+ case StmtId.localDef:
+ localDef := s as LocalDefStmt
+ if (localDef.init != null)
+ return exprContainsNestedMultiStatementClosure(localDef.init)
+ return false
+ case StmtId.returnStmt:
+ ret := s as ReturnStmt
+ if (ret.expr != null)
+ return exprContainsNestedMultiStatementClosure(ret.expr)
+ return false
+ default:
+ return false
+ }
+ }
+
+ ** Check if an expression contains or IS a nested multi-statement closure
+ private Bool exprContainsNestedMultiStatementClosure(Expr e)
+ {
+ // Check if this IS a multi-statement closure
+ if (e.id == ExprId.closure)
+ {
+ ce := e as ClosureExpr
+ // NOTE: Use a non-recursive check here to avoid infinite recursion.
+ // We only need to check if THIS closure is multi-statement (local vars, etc.)
+ // The recursive call from isMultiStatementClosure will handle deeper nesting.
+ if (closureNeedsExtractionDirect(ce)) return true
+ }
+
+ // Check children
+ switch (e.id)
+ {
+ case ExprId.call:
+ ce := e as CallExpr
+ if (ce.target != null && exprContainsNestedMultiStatementClosure(ce.target)) return true
+ return ce.args.any |arg| { exprContainsNestedMultiStatementClosure(arg) }
+ case ExprId.construction:
+ ce := e as CallExpr
+ return ce.args.any |arg| { exprContainsNestedMultiStatementClosure(arg) }
+ case ExprId.shortcut:
+ se := e as ShortcutExpr
+ if (se.target != null && exprContainsNestedMultiStatementClosure(se.target)) return true
+ return se.args.any |arg| { exprContainsNestedMultiStatementClosure(arg) }
+ case ExprId.coerce:
+ tc := e as TypeCheckExpr
+ return exprContainsNestedMultiStatementClosure(tc.target)
+ case ExprId.ternary:
+ te := e as TernaryExpr
+ return exprContainsNestedMultiStatementClosure(te.condition) ||
+ exprContainsNestedMultiStatementClosure(te.trueExpr) ||
+ exprContainsNestedMultiStatementClosure(te.falseExpr)
+ case ExprId.boolOr:
+ co := e as CondExpr
+ return co.operands.any |op| { exprContainsNestedMultiStatementClosure(op) }
+ case ExprId.boolAnd:
+ ca := e as CondExpr
+ return ca.operands.any |op| { exprContainsNestedMultiStatementClosure(op) }
+ case ExprId.closure:
+ // Already checked above, but need to check INSIDE for deeply nested
+ cl := e as ClosureExpr
+ Block? codeBlock := null
+ if (cl.doCall != null && cl.doCall.code != null)
+ codeBlock = cl.doCall.code
+ else if (cl.call != null && cl.call.code != null)
+ codeBlock = cl.call.code
+ else if (cl.code != null)
+ codeBlock = cl.code
+ if (codeBlock != null)
+ return containsNestedMultiStatementClosure(codeBlock)
+ return false
+ default:
+ return false
+ }
+ }
+
+ ** Check if a closure needs extraction WITHOUT the recursive nested check.
+ ** This prevents infinite recursion when checking for nested multi-statement closures.
+ private Bool closureNeedsExtractionDirect(ClosureExpr ce)
+ {
+ Block? codeBlock := null
+ if (ce.doCall != null && ce.doCall.code != null)
+ codeBlock = ce.doCall.code
+ else if (ce.call != null && ce.call.code != null)
+ codeBlock = ce.call.code
+ else if (ce.code != null)
+ codeBlock = ce.code
+
+ if (codeBlock == null) return false
+
+ stmts := codeBlock.stmts
+
+ // Check if closure has local variable declarations
+ if (stmts.any |s| { s.id == StmtId.localDef }) return true
+
+ // Check if closure has assignments
+ hasAssign := stmts.any |s|
+ {
+ if (s.id == StmtId.expr)
+ {
+ es := s as ExprStmt
+ return isAssignmentExpr(es.expr)
+ }
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr != null)
+ return isAssignmentExpr(ret.expr)
+ }
+ return false
+ }
+ if (hasAssign) return true
+
+ // Check for control flow
+ if (stmts.any |s|
+ {
+ s.id == StmtId.ifStmt ||
+ s.id == StmtId.switchStmt ||
+ s.id == StmtId.forStmt ||
+ s.id == StmtId.whileStmt ||
+ s.id == StmtId.tryStmt
+ }) return true
+
+ // Count real statements
+ realStmtCount := 0
+ stmts.each |s|
+ {
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr != null) realStmtCount++
+ }
+ else if (s.id != StmtId.nop)
+ {
+ realStmtCount++
+ }
+ }
+
+ return realStmtCount > 1
+ }
+
+ ** Check if expression is an assignment (can't be in lambda body)
+ ** Handles coerce wrapping, shortcuts (compound assignments), etc.
+ private Bool isAssignmentExpr(Expr e)
+ {
+ // Unwrap coerce expressions
+ while (e.id == ExprId.coerce)
+ {
+ tc := e as TypeCheckExpr
+ e = tc.target
+ }
+
+ // Direct assignment
+ if (e.id == ExprId.assign) return true
+
+ // Index set (list[i] = x) or compound assignment (x += 5)
+ if (e.id == ExprId.shortcut)
+ {
+ se := e as ShortcutExpr
+ if (se.op == ShortcutOp.set) return true
+ // Compound assignment, but NOT increment/decrement (those return values)
+ if (se.isAssign && se.op != ShortcutOp.increment && se.op != ShortcutOp.decrement)
+ return true
+ }
+
+ return false
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Closure Emission
+//////////////////////////////////////////////////////////////////////////
+
+ ** Emit any pending closures that are first used in the current statement
+ private Void emitPendingClosuresForStatement()
+ {
+ if (m.pendingClosures.isEmpty) return
+
+ // Find closures to emit for this statement
+ toEmit := [,]
+ remaining := [,]
+
+ m.pendingClosures.each |item|
+ {
+ data := item as Obj[]
+ closureId := data[1] as Int
+ firstUse := m.closureFirstUse[closureId]
+
+ // Emit if this is the first use statement, OR if usage wasn't tracked (fallback)
+ if (firstUse == m.stmtIndex || firstUse == null)
+ toEmit.add(item)
+ else
+ remaining.add(item)
+ }
+
+ if (toEmit.isEmpty) return
+
+ // Update pending list
+ m.pendingClosures = remaining
+
+ // Emit closures
+ toEmit.each |item|
+ {
+ data := item as Obj[]
+ ce := data[0] as ClosureExpr
+ closureId := data[1] as Int
+ writeClosure(ce, closureId)
+ }
+ }
+
+ ** Write a multi-statement closure as a def function
+ private Void writeClosure(ClosureExpr ce, Int closureId)
+ {
+ // def _closure_N(params, _self=self):
+ w("def _closure_${closureId}(")
+
+ // Get the signature - this is the EXPECTED type (what the target method wants)
+ // which may have fewer params than declared in source code (Fantom allows coercion)
+ sig := ce.signature as FuncType
+ expectedParamCount := sig?.params?.size ?: 0
+
+ // Parameters from closure's doCall method - these have the actual names
+ // from the source code, but LIMIT to expected count from signature
+ // ALL params get =None default because Python (unlike JS) requires all args
+ // JS: f(a,b) called as f() gives a=undefined, b=undefined
+ // Python: f(a,b) called as f() raises TypeError
+ hasParams := false
+ if (ce.doCall?.params != null && !ce.doCall.params.isEmpty)
+ {
+ // Only output up to expectedParamCount params (or all if signature unavailable)
+ maxParams := expectedParamCount > 0 ? expectedParamCount : ce.doCall.params.size
+ actualCount := ce.doCall.params.size.min(maxParams)
+
+ actualCount.times |i|
+ {
+ if (i > 0) w(", ")
+ w(escapeName(ce.doCall.params[i].name)).w("=None")
+ hasParams = true
+ }
+ }
+ else
+ {
+ // Fallback to signature names (for it-blocks with implicit it)
+ // sig was already defined above
+ if (sig != null && !sig.params.isEmpty)
+ {
+ // Check if this is an it-block (uses implicit it)
+ if (ce.isItBlock)
+ {
+ w("it=None")
+ hasParams = true
+ }
+ else
+ {
+ sig.names.each |name, i|
+ {
+ if (i > 0) w(", ")
+ if (name.isEmpty)
+ w("_p${i}=None")
+ else
+ w(escapeName(name)).w("=None")
+ hasParams = true
+ }
+ }
+ }
+ }
+
+ // Add _self=self for outer self capture if needed
+ needsOuter := ce.cls?.fieldDefs?.any |f| { f.name == "\$this" } ?: false
+ if (needsOuter)
+ {
+ if (hasParams) w(", ")
+ w("_self=self")
+ }
+
+ w(")").colon
+ indent
+
+ // Multi-statement closures use _self=self, not _outer=self
+ // Ensure the flag is false so $this references output _self
+ m.inClosureWithOuter = false
+
+ // Emit nonlocal declarations for closure-captured mutable variables
+ // Python requires nonlocal to assign to variables from enclosing scope
+ nonlocalNames := m.getNonlocalNames
+ if (!nonlocalNames.isEmpty)
+ {
+ w("nonlocal ")
+ nonlocalNames.each |name, i|
+ {
+ if (i > 0) w(", ")
+ w(escapeName(name))
+ }
+ eos
+ }
+
+ // Save method-level closure state - nested closures have their own scope
+ savedPending := m.pendingClosures.dup
+ savedFirstUse := m.closureFirstUse.dup
+ savedStmtIndex := m.stmtIndex
+ m.pendingClosures = [,]
+ m.closureFirstUse = [:]
+
+ // Scan and register nested closures for emission inside this closure
+ // This ensures nested defs are written before they're referenced
+ scanClosureBodyForNestedClosures(ce)
+
+ // Write the closure body
+ codeBlock := ce.doCall?.code ?: ce.code
+ hasContent := false
+ if (codeBlock != null && !codeBlock.stmts.isEmpty)
+ {
+ codeBlock.stmts.each |s, idx|
+ {
+ // Skip self-referential captured variable assignments (js$0 = js$0 -> js = js)
+ // Python captures variables automatically from the enclosing scope
+ if (isCapturedVarSelfAssign(s)) return
+
+ // Track statement index for nested closure emission
+ m.stmtIndex = idx
+
+ // Note: inClosureWithOuter stays false for multi-statement closures
+ // because they use _self=self parameter, not _outer=self
+ stmt(s)
+ hasContent = true
+ }
+ }
+
+ // Restore method-level closure state
+ m.pendingClosures = savedPending
+ m.closureFirstUse = savedFirstUse
+ m.stmtIndex = savedStmtIndex
+
+ if (!hasContent)
+ {
+ pass
+ }
+
+ unindent
+ nl
+
+ // Wrap the closure with Func.make_closure for proper Fantom Func methods
+ w("_closure_${closureId} = sys.Func.make_closure({")
+
+ // Returns type
+ retType := sig?.returns?.signature ?: "sys::Void"
+ w("\"returns\": ").str(retType).w(", ")
+
+ // Immutability case from compiler analysis
+ immutCase := m.closureImmutability(ce)
+ w("\"immutable\": ").str(immutCase).w(", ")
+
+ // Params (sanitize Java FFI type signatures)
+ w("\"params\": [")
+ if (ce.doCall?.params != null)
+ {
+ maxParams := expectedParamCount > 0 ? expectedParamCount : ce.doCall.params.size
+ actualCount := ce.doCall.params.size.min(maxParams)
+ actualCount.times |i|
+ {
+ if (i > 0) w(", ")
+ p := ce.doCall.params[i]
+ pSig := PyUtil.sanitizeJavaFfi(p.type.signature)
+ w("{\"name\": ").str(p.name).w(", \"type\": ").str(pSig).w("}")
+ }
+ }
+ else if (sig != null && !sig.params.isEmpty)
+ {
+ sig.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ name := sig.names.getSafe(i) ?: "_p${i}"
+ pSig := PyUtil.sanitizeJavaFfi(p.signature)
+ w("{\"name\": ").str(name).w(", \"type\": ").str(pSig).w("}")
+ }
+ }
+ w("]}, _closure_${closureId})").eos
+ }
+
+ ** Check if statement is a self-referential captured variable assignment
+ ** These are generated by Fantom compiler like: js$0 = js$0
+ ** Python captures variables automatically so we skip these
+ private Bool isCapturedVarSelfAssign(Stmt s)
+ {
+ // Must be expression statement
+ if (s.id != StmtId.expr) return false
+
+ exprStmt := s as ExprStmt
+
+ // Must be assignment expression
+ if (exprStmt.expr.id != ExprId.assign) return false
+
+ assign := exprStmt.expr as BinaryExpr
+
+ // Both sides must be field expressions
+ if (assign.lhs.id != ExprId.field || assign.rhs.id != ExprId.field) return false
+
+ lhsField := assign.lhs as FieldExpr
+ rhsField := assign.rhs as FieldExpr
+
+ // Both must reference the same captured variable field (pattern: name$N)
+ lhsName := lhsField.field.name
+ rhsName := rhsField.field.name
+
+ if (lhsName != rhsName) return false
+
+ // Check if it's a captured variable pattern (name$N where N is digits)
+ if (!lhsName.contains("\$")) return false
+
+ idx := lhsName.index("\$")
+ if (idx == null || idx >= lhsName.size - 1) return false
+
+ suffix := lhsName[idx+1..-1]
+ return !suffix.isEmpty && suffix.all |c| { c.isDigit }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Block
+//////////////////////////////////////////////////////////////////////////
+
+ ** Print a block of statements
+ ** Handles "effectively empty" blocks (all nops or catch vars) by adding pass
+ Void block(Block? b, Bool isCatchBlock := false)
+ {
+ indent
+
+ hasContent := false
+ if (b != null && !b.stmts.isEmpty)
+ {
+ b.stmts.each |s|
+ {
+ // Skip nops - they produce no output
+ if (s.id == StmtId.nop) return
+
+ // In catch blocks, skip catch variable declarations (handled by except...as)
+ if (isCatchBlock && s.id == StmtId.localDef && (s as LocalDefStmt).isCatchVar) return
+
+ stmt(s)
+ hasContent = true
+ }
+ }
+
+ // Python requires content in blocks - add pass if effectively empty
+ if (!hasContent)
+ pass
+
+ unindent
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Statements
+//////////////////////////////////////////////////////////////////////////
+
+ private Void exprStmt(ExprStmt s)
+ {
+ // For statement-level local variable assignments, use = not :=
+ // The walrus operator (:=) should only be used inside expressions
+ // (e.g., conditions, function arguments, etc.)
+ e := s.expr
+
+ // Unwrap coerces to find the underlying assignment
+ while (e.id == ExprId.coerce)
+ {
+ tc := e as TypeCheckExpr
+ e = tc.target
+ }
+
+ // Check if this is a local variable assignment at statement level
+ if (e.id == ExprId.assign)
+ {
+ assign := e as BinaryExpr
+ if (assign.lhs.id == ExprId.localVar)
+ {
+ // Statement-level local var assignment: use regular = not :=
+ localExpr := assign.lhs as LocalVarExpr
+ w(escapeName(localExpr.var.name))
+ w(" = ")
+ expr(assign.rhs)
+ eos
+ return
+ }
+ }
+
+ // For all other expressions, use the normal expression printer
+ expr(s.expr)
+ eos
+ }
+
+ private Void localDef(LocalDefStmt s)
+ {
+ // Skip catch vars - handled in tryStmt
+ if (s.isCatchVar) return
+
+ // Skip captured variable self-assignments (js = js$0 -> js = js)
+ // Python captures variables automatically from enclosing scope
+ if (isCapturedVarLocalDef(s)) return
+
+ // Check if this is a Wrap$ wrapper definition (closure-captured mutable variable)
+ // If so, skip the line entirely -- we use nonlocal instead of cvar wrappers
+ if (detectAndRecordNonlocal(s)) return
+
+ w(escapeName(s.name))
+ if (s.init != null)
+ {
+ w(" = ")
+ // If init is an assignment, only output the RHS
+ if (s.init.id == ExprId.assign)
+ {
+ assign := s.init as BinaryExpr
+ expr(assign.rhs)
+ }
+ else
+ {
+ expr(s.init)
+ }
+ }
+ else
+ {
+ w(" = None")
+ }
+ eos
+ }
+
+ ** Detect if this is a Wrap$ wrapper definition and record for nonlocal handling
+ ** Pattern: wrapperVar := Wrap$Type.make(originalVar)
+ ** Returns true if this line should be skipped (it's a wrapper we handle via nonlocal)
+ private Bool detectAndRecordNonlocal(LocalDefStmt s)
+ {
+ if (s.init == null) return false
+
+ // Unwrap coerces and assignments to get the actual call
+ initExpr := s.init
+ while (initExpr.id == ExprId.coerce)
+ {
+ tc := initExpr as TypeCheckExpr
+ initExpr = tc.target
+ }
+ if (initExpr.id == ExprId.assign)
+ {
+ assign := initExpr as BinaryExpr
+ initExpr = assign.rhs
+ while (initExpr.id == ExprId.coerce)
+ {
+ tc := initExpr as TypeCheckExpr
+ initExpr = tc.target
+ }
+ }
+
+ // Check if it's a call expression (for Wrap$.make pattern)
+ if (initExpr.id != ExprId.call) return false
+
+ call := initExpr as CallExpr
+
+ // Pattern: Wrap$Type.make(arg) -- synthetic wrapper constructor
+ if (call.method.name == "make" && call.target == null && !call.method.isStatic && call.args.size == 1)
+ {
+ parentType := call.method.parent
+ if (parentType.isSynthetic && parentType.name.startsWith("Wrap\$"))
+ {
+ // Extract the argument (the value being wrapped)
+ arg := call.args.first
+ while (arg.id == ExprId.coerce)
+ {
+ tc := arg as TypeCheckExpr
+ arg = tc.target
+ }
+
+ wrapperVarName := s.name
+
+ if (arg.id == ExprId.localVar)
+ {
+ // Case 1: Wrap$.make(existingVar) - the original variable already exists
+ // Skip this line entirely; the original variable is already defined
+ localArg := arg as LocalVarExpr
+ originalVarName := localArg.var.name
+ m.recordNonlocal(wrapperVarName, originalVarName)
+ return true // Skip this line
+ }
+ else
+ {
+ // Case 2: Wrap$.make(literal/expr) - no separate original variable
+ // e.g., params = Wrap$Str.make(null), buf = Wrap$Buf.make(self.make(...))
+ // Rewrite as: varName = and record for nonlocal
+ m.recordNonlocal(wrapperVarName, wrapperVarName)
+ w(escapeName(wrapperVarName))
+ w(" = ")
+ expr(call.args.first) // Use original arg (with coerces) for proper output
+ eos
+ return true // We emitted the rewritten line
+ }
+ }
+ }
+ return false
+ }
+
+ ** Check if this localDef is a captured variable initialization
+ ** Pattern: js := assign(field(js$0)) where js$0 is a captured variable field
+ private Bool isCapturedVarLocalDef(LocalDefStmt s)
+ {
+ if (s.init == null) return false
+
+ // Unwrap coerce expressions to get to the actual content
+ initExpr := s.init
+ while (initExpr.id == ExprId.coerce)
+ {
+ tc := initExpr as TypeCheckExpr
+ initExpr = tc.target
+ }
+
+ // Check if init is assignment - get the RHS
+ if (initExpr.id == ExprId.assign)
+ {
+ assign := initExpr as BinaryExpr
+ initExpr = assign.rhs
+ // Unwrap coerce on RHS too
+ while (initExpr.id == ExprId.coerce)
+ {
+ tc := initExpr as TypeCheckExpr
+ initExpr = tc.target
+ }
+ }
+
+ // Check if we have a field reference to a captured variable
+ if (initExpr.id != ExprId.field) return false
+
+ fieldExpr := initExpr as FieldExpr
+ fieldName := fieldExpr.field.name
+
+ // Check if field name matches pattern: varName$N
+ if (!fieldName.contains("\$")) return false
+
+ idx := fieldName.index("\$")
+ if (idx == null || idx >= fieldName.size - 1) return false
+
+ baseName := fieldName[0.. acc[name] = hit; return hit
+ // Handle return with compound assignment: return x += 5 -> x += 5; return x
+ if (unwrapped.id == ExprId.shortcut)
+ {
+ shortcut := unwrapped as ShortcutExpr
+
+ // Index set (container[key] = value)
+ if (shortcut.op == ShortcutOp.set)
+ {
+ // Execute index set first
+ expr(s.expr)
+ eos
+ // Then return the assigned value (the second arg to set)
+ w("return ")
+ expr(shortcut.args[1])
+ eos
+ return
+ }
+
+ // Compound assignment (x += 5, x *= 2, etc.)
+ if (shortcut.isAssign)
+ {
+ // Execute assignment first
+ expr(s.expr)
+ eos
+ // Then return the target (the updated value)
+ w("return ")
+ expr(shortcut.target)
+ eos
+ return
+ }
+ }
+ }
+
+ w("return")
+ if (s.expr != null)
+ {
+ w(" ")
+ expr(s.expr)
+ }
+ eos
+ }
+
+ ** Unwrap coerce expressions
+ private Expr unwrapCoerce(Expr e)
+ {
+ if (e.id == ExprId.coerce)
+ {
+ tc := e as TypeCheckExpr
+ return unwrapCoerce(tc.target)
+ }
+ return e
+ }
+
+ private Void throwStmt(ThrowStmt s)
+ {
+ w("raise ")
+ expr(s.exception)
+ eos
+ }
+
+ private Void forStmt(ForStmt s)
+ {
+ // Fantom for loop: for (init; cond; update)
+ // Python equivalent: init; while cond: block; update
+ //
+ // IMPORTANT: We track the update expression so that continue statements
+ // inside the loop body can emit it before jumping. Otherwise continue
+ // would skip the update and cause an infinite loop.
+ if (s.init != null) stmt(s.init)
+ w("while ")
+ if (s.condition != null)
+ expr(s.condition)
+ else
+ w("True")
+ colon
+ indent
+
+ // Set forLoopUpdate so continue statements know to emit it
+ savedUpdate := m.forLoopUpdate
+ m.forLoopUpdate = s.update
+
+ if (s.block != null)
+ s.block.stmts.each |st| { stmt(st) }
+
+ // Restore previous update (for nested for loops)
+ m.forLoopUpdate = savedUpdate
+
+ if (s.update != null)
+ {
+ expr(s.update)
+ eos
+ }
+ unindent
+ }
+
+ ** Handle continue statement - must emit for loop update expression first
+ private Void continueStmt()
+ {
+ // If we're in a for loop with an update expression, emit it before continue
+ // This prevents infinite loops where continue skips the i++ update
+ if (m.forLoopUpdate != null)
+ {
+ expr(m.forLoopUpdate)
+ eos
+ }
+ w("continue").eos
+ }
+
+ private Void whileStmt(WhileStmt s)
+ {
+ w("while ")
+ expr(s.condition)
+ colon
+ block(s.block)
+ }
+
+ private Void tryStmt(TryStmt s)
+ {
+ w("try")
+ colon
+ block(s.block)
+
+ s.catches.each |c|
+ {
+ w("except")
+ if (c.errType != null)
+ {
+ w(" ")
+ // For sys::Err (base class), catch all Python exceptions
+ // This ensures Python native exceptions (KeyError, etc.) are also caught
+ if (c.errType.qname == "sys::Err")
+ {
+ w("Exception")
+ }
+ else
+ {
+ // For specific error types, use the Fantom type
+ // Also catch corresponding Python native exceptions where applicable
+ curPod := m.curType?.pod?.name
+ errPod := c.errType.pod.name
+ errName := PyUtil.escapeTypeName(c.errType.name)
+
+ // Map Fantom exceptions to Python native exceptions
+ // These need to catch both Fantom and Python versions
+ pyNative := pythonNativeException(c.errType.qname)
+ if (pyNative != null)
+ {
+ // Catch both Fantom and Python exceptions: except (sys.IndexErr, IndexError)
+ w("(")
+ if (errPod == "sys" && curPod != "sys")
+ w("sys.")
+ w(errName)
+ w(", ")
+ w(pyNative)
+ w(")")
+ }
+ else
+ {
+ if (errPod == "sys" && curPod != "sys")
+ w("sys.")
+ w(errName)
+ }
+ }
+ }
+ else
+ {
+ w(" Exception")
+ }
+ if (c.errVariable != null)
+ {
+ w(" as ").w(escapeName(c.errVariable))
+ }
+ colon
+ // Wrap native Python exceptions to ensure they have .trace() method
+ // This is needed because except Exception catches native exceptions
+ // that don't have Fantom Err methods
+ if (c.errVariable != null && (c.errType == null || c.errType.qname == "sys::Err"))
+ {
+ indent
+ w(escapeName(c.errVariable)).w(" = sys.Err.wrap(").w(escapeName(c.errVariable)).w(")").eos
+ unindent
+ }
+ block(c.block, true) // isCatchBlock=true for catch variable handling
+ }
+
+ if (s.finallyBlock != null)
+ {
+ w("finally")
+ colon
+ block(s.finallyBlock)
+ }
+ }
+
+ private Void switchStmt(SwitchStmt s)
+ {
+ // Python doesn't have switch, use if/elif/else
+ // IMPORTANT: Evaluate condition once to avoid side effects being repeated
+ // (e.g., switch(i++) must only increment i once)
+ switchVarId := m.nextSwitchVarId
+ w("_switch_${switchVarId} = ")
+ expr(s.condition)
+ eos
+
+ first := true
+ s.cases.each |c|
+ {
+ if (first)
+ {
+ w("if ")
+ first = false
+ }
+ else
+ {
+ w("elif ")
+ }
+
+ // Match any of the case values against the cached condition
+ c.cases.each |e, i|
+ {
+ if (i > 0) w(" or ")
+ w("(")
+ w("_switch_${switchVarId}")
+ w(" == ")
+ expr(e)
+ w(")")
+ }
+ colon
+ block(c.block)
+ }
+
+ if (s.defaultBlock != null)
+ {
+ if (!first) w("else")
+ else w("if True")
+ colon
+ block(s.defaultBlock)
+ }
+ }
+
+ ** Map Fantom exception types to corresponding Python native exceptions
+ ** Returns the Python exception name if there's a mapping, null otherwise
+ private Str? pythonNativeException(Str qname)
+ {
+ switch (qname)
+ {
+ case "sys::IndexErr": return "IndexError"
+ case "sys::ArgErr": return "ValueError"
+ case "sys::IOErr": return "IOError"
+ case "sys::UnknownKeyErr": return "KeyError"
+ default: return null
+ }
+ }
+}
diff --git a/src/fanc/fan/py/PyTypePrinter.fan b/src/fanc/fan/py/PyTypePrinter.fan
new file mode 100644
index 000000000..2aab3969e
--- /dev/null
+++ b/src/fanc/fan/py/PyTypePrinter.fan
@@ -0,0 +1,2339 @@
+//
+// Copyright (c) 2025, Brian Frank and Andy Frank
+// Licensed under the Academic Free License version 3.0
+//
+// History:
+// 25 Feb 2026 Trevor Adelman Creation
+//
+
+using compiler
+
+**
+** PyTypePrinter generates Python class files from Fantom TypeDefs.
+**
+** Output Structure (per file):
+** 1. Header comment (auto-generated notice)
+** 2. Imports:
+** - sys path setup
+** - typing imports (Optional, Callable, etc.)
+** - pod namespace imports (from fan import sys)
+** - base class and mixin direct imports
+** - exception types for catch clauses
+** 3. Class definition:
+** - Static fields (class-level storage + lazy getters)
+** - Constructor (__init__ + factory methods like make())
+** - Field accessors (combined getter/setter pattern)
+** - Methods (instance and static)
+** - Python operator methods (__add__, __getitem__, etc.)
+** 4. Type metadata registration (for reflection)
+**
+** Key Design Decisions:
+** - Enums use factory pattern with _make_enum() and lazy vals()
+** - Static fields use _static_init() with re-entry guard
+** - Named constructors use _ctorName_body() pattern
+** - See design.md in this directory for full documentation
+**
+class PyTypePrinter : PyPrinter
+{
+ new make(OutStream out) : super(out) {}
+
+ ** Print a complete type definition
+ Void type(TypeDef t)
+ {
+ m.curType = t
+
+ // File header
+ header(t)
+
+ // Imports
+ imports(t)
+
+ // Class definition
+ classDef(t)
+
+ // Type metadata registration (for reflection)
+ typeMetadata(t)
+
+ // Generate if __name__ == "__main__" block at module level for main() methods
+ // This MUST be after the class definition ends (unindented to module level)
+ mainMethod := t.methodDefs.find |method| { isMain(method) }
+ if (mainMethod != null)
+ pyMain(mainMethod)
+
+ m.curType = null
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Header
+//////////////////////////////////////////////////////////////////////////
+
+ private Void header(TypeDef t)
+ {
+ w("#").nl
+ w("# ${t.qname}").nl
+ w("# Auto-generated by fanc py - do not edit").nl
+ w("#").nl
+ // Import global sentinel from sys for field getter/setter disambiguation
+ w("from fan.sys.ObjUtil import _UNSET").nl
+ nl
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Imports
+//////////////////////////////////////////////////////////////////////////
+
+ private Void imports(TypeDef t)
+ {
+ // System path setup
+ w("import sys as sys_module").nl
+ w("sys_module.path.insert(0, '.')").nl
+ nl
+
+ // Type hint imports (Python 3.9+ uses native types, but we use typing for compatibility)
+ w("from typing import Optional, Callable, List as TypingList, Dict as TypingDict").nl
+ nl
+
+ // For types NOT in sys pod: Import sys pod as namespace (lazy loader)
+ // For types IN sys pod: Import Type directly (needed for metadata registration)
+ if (t.pod.name != "sys")
+ {
+ w("from fan import sys").nl
+ }
+ else
+ {
+ // For sys pod types, import Type directly for metadata registration
+ if (t.qname != "sys::Type")
+ w("from fan.sys.Type import Type").nl
+
+ // For sys pod enums, import List directly (used in vals() method)
+ if (t.isEnum)
+ w("from fan.sys.List import List").nl
+ }
+
+ // Import base class directly (needed for Python class definition)
+ if (t.qname != "sys::Obj")
+ {
+ if (t.base != null && !t.base.isObj)
+ {
+ podPath := PyUtil.podImport(t.base.pod.name)
+ w("from ${podPath}.${t.base.name} import ${t.base.name}").nl
+ }
+ else
+ {
+ w("from fan.sys.Obj import Obj").nl
+ }
+ }
+
+ // Import ObjUtil directly (used everywhere)
+ w("from fan.sys.ObjUtil import ObjUtil").nl
+
+ // Import mixins directly (needed for Python class definition)
+ t.mixins.each |m|
+ {
+ if (m.qname == "sys::Obj") return // Skip Obj
+ podPath := PyUtil.podImport(m.pod.name)
+ w("from ${podPath}.${m.name} import ${m.name}").nl
+ }
+
+ // Import dependent pods as namespaces
+ t.pod.depends.each |depend|
+ {
+ podName := depend.name
+ if (podName == "sys") return // Already imported above (or skipped for sys pod)
+ if (podName == t.pod.name) return // Skip same pod
+ if (podName.startsWith("[")) return // Skip FFI
+ if (PyUtil.isJavaFfi(podName)) return
+
+ // Escape reserved pod names (e.g., "def" -> "def_")
+ escapedPod := PyUtil.escapePodName(podName)
+ w("from fan import ${escapedPod}").nl
+ }
+
+ // Exception types used in catch clauses need explicit imports
+ // because Python's except clause needs the class in local scope
+ samePodCatchTypes := collectSamePodCatchTypes(t)
+ samePodCatchTypes.each |typeName|
+ {
+ if (typeName == t.name) return // Skip self-import
+ podPath := PyUtil.podImport(t.pod.name)
+ w("from ${podPath}.${typeName} import ${typeName}").nl
+ }
+
+ // Cross-pod exception types used in catch clauses also need explicit imports
+ crossPodCatchTypes := collectCrossPodCatchTypes(t)
+ crossPodCatchTypes.each |typeNames, podName|
+ {
+ podPath := PyUtil.podImport(podName)
+ typeNames.each |typeName|
+ {
+ w("from ${podPath}.${typeName} import ${typeName}").nl
+ }
+ }
+
+ nl
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Import Scanning
+//////////////////////////////////////////////////////////////////////////
+
+ // These methods scan the AST to collect type references for imports.
+ // Python requires explicit imports (unlike JS which bundles everything).
+ // Key patterns handled:
+ // - Cross-pod exception types used in catch clauses
+ // - Same-pod exception types (Python except clause needs local scope)
+
+ ** Collect cross-pod exception types used in catch clauses
+ ** Returns a map of podName -> list of type names
+ private Str:Str[] collectCrossPodCatchTypes(TypeDef t)
+ {
+ result := Str:Str[][:]
+
+ // Scan all methods for catch clauses with cross-pod exception types
+ t.methodDefs.each |m|
+ {
+ if (m.code != null)
+ scanBlockForCrossPodCatchTypes(m.code, t.pod.name, result)
+ }
+
+ return result
+ }
+
+ ** Scan a block for cross-pod catch types
+ private Void scanBlockForCrossPodCatchTypes(Block block, Str curPodName, Str:Str[] result)
+ {
+ block.stmts.each |stmt|
+ {
+ if (stmt.id == StmtId.tryStmt)
+ {
+ tryStmt := stmt as TryStmt
+ tryStmt.catches.each |c|
+ {
+ if (c.errType != null)
+ {
+ errPod := c.errType.pod.name
+ errName := c.errType.name
+ // Cross-pod: not sys (already imported) and not same pod
+ if (errPod != "sys" && errPod != curPodName)
+ {
+ if (result[errPod] == null) result[errPod] = Str[,]
+ if (!result[errPod].contains(errName))
+ result[errPod].add(errName)
+ }
+ }
+ }
+ // Recurse into try block
+ scanBlockForCrossPodCatchTypes(tryStmt.block, curPodName, result)
+ tryStmt.catches.each |c| { scanBlockForCrossPodCatchTypes(c.block, curPodName, result) }
+ if (tryStmt.finallyBlock != null)
+ scanBlockForCrossPodCatchTypes(tryStmt.finallyBlock, curPodName, result)
+ }
+ else if (stmt.id == StmtId.ifStmt)
+ {
+ ifStmt := stmt as IfStmt
+ scanBlockForCrossPodCatchTypes(ifStmt.trueBlock, curPodName, result)
+ if (ifStmt.falseBlock != null) scanBlockForCrossPodCatchTypes(ifStmt.falseBlock, curPodName, result)
+ }
+ else if (stmt.id == StmtId.forStmt)
+ {
+ forStmt := stmt as ForStmt
+ if (forStmt.block != null) scanBlockForCrossPodCatchTypes(forStmt.block, curPodName, result)
+ }
+ else if (stmt.id == StmtId.whileStmt)
+ {
+ whileStmt := stmt as WhileStmt
+ scanBlockForCrossPodCatchTypes(whileStmt.block, curPodName, result)
+ }
+ else if (stmt.id == StmtId.switchStmt)
+ {
+ switchStmt := stmt as SwitchStmt
+ switchStmt.cases.each |c| { scanBlockForCrossPodCatchTypes(c.block, curPodName, result) }
+ if (switchStmt.defaultBlock != null) scanBlockForCrossPodCatchTypes(switchStmt.defaultBlock, curPodName, result)
+ }
+ }
+ }
+
+ ** Collect same-pod exception types used in catch clauses
+ ** These need explicit imports because they're used at statement level
+ private Str[] collectSamePodCatchTypes(TypeDef t)
+ {
+ result := Str[,]
+
+ // Scan all methods for catch clauses
+ t.methodDefs.each |m|
+ {
+ if (m.code != null)
+ scanBlockForCatchTypes(m.code, t.pod.name, result)
+ }
+
+ return result
+ }
+
+ ** Scan a block for same-pod catch types
+ private Void scanBlockForCatchTypes(Block block, Str podName, Str[] result)
+ {
+ block.stmts.each |stmt|
+ {
+ if (stmt.id == StmtId.tryStmt)
+ {
+ tryStmt := stmt as TryStmt
+ tryStmt.catches.each |c|
+ {
+ if (c.errType != null && c.errType.pod.name == podName)
+ {
+ typeName := c.errType.name
+ if (!result.contains(typeName))
+ result.add(typeName)
+ }
+ }
+ // Recurse into try block
+ scanBlockForCatchTypes(tryStmt.block, podName, result)
+ tryStmt.catches.each |c| { scanBlockForCatchTypes(c.block, podName, result) }
+ if (tryStmt.finallyBlock != null)
+ scanBlockForCatchTypes(tryStmt.finallyBlock, podName, result)
+ }
+ else if (stmt.id == StmtId.ifStmt)
+ {
+ ifStmt := stmt as IfStmt
+ scanBlockForCatchTypes(ifStmt.trueBlock, podName, result)
+ if (ifStmt.falseBlock != null) scanBlockForCatchTypes(ifStmt.falseBlock, podName, result)
+ }
+ else if (stmt.id == StmtId.forStmt)
+ {
+ forStmt := stmt as ForStmt
+ if (forStmt.block != null) scanBlockForCatchTypes(forStmt.block, podName, result)
+ }
+ else if (stmt.id == StmtId.whileStmt)
+ {
+ whileStmt := stmt as WhileStmt
+ scanBlockForCatchTypes(whileStmt.block, podName, result)
+ }
+ else if (stmt.id == StmtId.switchStmt)
+ {
+ switchStmt := stmt as SwitchStmt
+ switchStmt.cases.each |c| { scanBlockForCatchTypes(c.block, podName, result) }
+ if (switchStmt.defaultBlock != null) scanBlockForCatchTypes(switchStmt.defaultBlock, podName, result)
+ }
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Class
+//////////////////////////////////////////////////////////////////////////
+
+ private Void classDef(TypeDef t)
+ {
+ // class ClassName(Mixin1, Mixin2, BaseClass):
+ // Python supports multiple inheritance - include all mixins plus base class
+ w("class ${PyUtil.escapeTypeName(t.name)}")
+ w("(")
+
+ bases := Str[,]
+
+ // Collect all mixins that aren't inherited by other mixins
+ // This prevents Python MRO conflicts like class X(Dict, LibDepend)
+ // where LibDepend already extends Dict
+ mixinsToInclude := CType[,]
+ t.mixins.each |m|
+ {
+ if (m.qname == "sys::Obj") return // Skip Obj
+
+ // Check if this mixin is inherited by another mixin in the list
+ alreadyInherited := t.mixins.any |other|
+ {
+ if (other.qname == m.qname) return false // Skip self
+ // Check if 'other' mixin extends 'm'
+ return other.mixins.any |mm| { mm.qname == m.qname }
+ }
+
+ if (!alreadyInherited)
+ mixinsToInclude.add(m)
+ }
+
+ // Add filtered mixins
+ mixinsToInclude.each |m|
+ {
+ bases.add(m.name)
+ }
+
+ // Add base class if it's not Obj and not already inherited via mixins
+ baseAlreadyInherited := t.base != null && !t.base.isObj &&
+ mixinsToInclude.any |m| {
+ if (m.base?.qname == t.base.qname) return true
+ return m.mixins.any |mm| { mm.qname == t.base.qname }
+ }
+
+ if (t.base != null && !t.base.isObj && !baseAlreadyInherited)
+ bases.add(t.base.name)
+ else if (bases.isEmpty)
+ bases.add("Obj")
+
+ // Write the bases
+ w(bases.join(", "))
+ w(")")
+ colon
+
+ indent
+
+ // Check if this is an enum type
+ if (t.isEnum)
+ {
+ enumClassDef(t)
+ }
+ else
+ {
+ // Static fields (class-level attributes) - must come first
+ staticFields(t)
+
+ // Constructor
+ ctor(t)
+
+ // Field getters/setters (instance fields only)
+ t.fieldDefs.each |f|
+ {
+ if (f.isStatic) return
+ fieldAccessors(f)
+ }
+
+ // Methods - track which field accessors we've already emitted
+ emittedAccessors := Str:Bool[:]
+ t.methodDefs.each |m|
+ {
+ if (m.isCtor) return
+ if (m.isInstanceInit) return
+ if (m.isStaticInit) return
+ // Allow once helper methods (synthetic but needed) - they end with $Once
+ // Allow checkFields$ methods - they validate non-nullable fields were set
+ if (m.isSynthetic && !m.name.endsWith("\$Once") && !m.name.startsWith("checkFields\$")) return
+ // Skip abstract methods in mixins - they must be implemented by concrete class
+ // Python MRO would find the mixin's pass-returning method before the base class implementation
+ if (m.isAbstract && t.isMixin) return
+
+ // For field accessors, emit combined getter/setter only once
+ if (m.isFieldAccessor)
+ {
+ fieldName := m.accessorFor.name
+ if (emittedAccessors[fieldName] == true) return // Already emitted
+ emittedAccessors[fieldName] = true
+ combinedFieldAccessor(m.accessorFor)
+ return
+ }
+
+ method(m)
+ }
+
+ // Generate Python operator methods for Fantom operator methods
+ operatorMethods(t)
+
+ // If no methods, add pass
+ if (t.methodDefs.isEmpty)
+ pass
+ }
+
+ unindent
+ nl
+ }
+
+ ** Generate Python operator methods (__add__, __sub__, etc.)
+ ** for Fantom methods with @Operator facet
+ private Void operatorMethods(TypeDef t)
+ {
+ // Map Fantom operator methods to Python special methods
+ operatorMap := Str:Str[
+ "plus": "__add__",
+ "minus": "__sub__",
+ "mult": "__mul__",
+ "div": "__truediv__",
+ "mod": "__mod__",
+ "negate": "__neg__",
+ "get": "__getitem__",
+ "set": "__setitem__"
+ ]
+
+ t.methodDefs.each |m|
+ {
+ // Skip methods without @Operator facet
+ hasOperator := m.facets?.any |f| { f.type.qname == "sys::Operator" } ?: false
+ if (!hasOperator) return
+
+ pyMethod := operatorMap[m.name]
+ if (pyMethod == null) return
+
+ // Generate Python operator method
+ // Handle different parameter counts:
+ // - 0 params: __neg__ (negate)
+ // - 1 param: __add__, __sub__, __getitem__, etc.
+ // - 2 params: __setitem__ (set)
+ nl
+ w("def ${pyMethod}(self")
+ if (m.params.size == 1)
+ {
+ w(", other")
+ }
+ else if (m.params.size == 2)
+ {
+ // For __setitem__, Python uses (key, value)
+ w(", key, value")
+ }
+ w(")").colon
+ indent
+ if (m.params.size == 0)
+ {
+ w("return self.${escapeName(m.name)}()").eos
+ }
+ else if (m.params.size == 1)
+ {
+ w("return self.${escapeName(m.name)}(other)").eos
+ }
+ else if (m.params.size == 2)
+ {
+ w("return self.${escapeName(m.name)}(key, value)").eos
+ }
+ unindent
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Enum
+//////////////////////////////////////////////////////////////////////////
+
+ ** Generate enum class definition
+ private Void enumClassDef(TypeDef t)
+ {
+ enumFields := t.fieldDefs.findAll |f| { f.enumDef != null }
+ .sort |a, b| { a.enumDef.ordinal <=> b.enumDef.ordinal }
+
+ // For enums, only generate static fields for non-enum fields (like once methods)
+ // Skip the standard _static_init() for enums since vals()/_make_enum() handles enum initialization
+ enumStaticFields(t)
+
+ // Private cache field for vals
+ nl
+ w("_vals = None").nl
+
+ // Static accessor for each enum value: A(), B(), C()
+ enumFields.each |f|
+ {
+ def := f.enumDef
+ nl
+ w("@staticmethod").nl
+ w("def ${escapeName(def.name)}()").colon
+ indent
+ w("return ${PyUtil.escapeTypeName(t.name)}.vals().get(${def.ordinal})").eos
+ unindent
+ }
+
+ // vals() method - creates and caches enum instances
+ nl
+ w("@staticmethod").nl
+ w("def vals()").colon
+ indent
+ w("if ${PyUtil.escapeTypeName(t.name)}._vals is None").colon
+ indent
+ // Add sys. prefix only when NOT inside the sys pod
+ prefix := t.pod.name != "sys" ? "sys." : ""
+ w("${PyUtil.escapeTypeName(t.name)}._vals = ${prefix}List.to_immutable(${prefix}List.from_list([").nl
+ indent
+ enumFields.each |f, i|
+ {
+ def := f.enumDef
+ w("${PyUtil.escapeTypeName(t.name)}._make_enum(${def.ordinal}, ${def.name.toCode}")
+ if (!def.ctorArgs.isEmpty)
+ {
+ def.ctorArgs.each |arg|
+ {
+ w(", ")
+ PyExprPrinter(this).expr(arg)
+ }
+ }
+ w(")")
+ if (i < enumFields.size - 1) w(",")
+ nl
+ }
+ unindent
+ w("]))").eos
+ unindent
+ w("return ${PyUtil.escapeTypeName(t.name)}._vals").eos
+ unindent
+
+ // from_str() method
+ nl
+ w("@staticmethod").nl
+ w("def from_str(name, checked=True)").colon
+ indent
+ w("for v in ${PyUtil.escapeTypeName(t.name)}.vals()").colon
+ indent
+ w("if v.name() == name").colon
+ indent
+ w("return v").eos
+ unindent
+ unindent
+ w("if checked").colon
+ indent
+ // Add sys. prefix only when NOT inside the sys pod
+ parseErrPrefix := t.pod.name != "sys" ? "sys." : ""
+ w("raise ${parseErrPrefix}ParseErr.make(\"Unknown enum: \" + name)").eos
+ unindent
+ w("return None").eos
+ unindent
+
+ // _make_enum() internal factory
+ nl
+ w("@staticmethod").nl
+ w("def _make_enum(_ordinal, _name")
+ // Add ctor params if enum has custom constructor (skip first 2: ordinal, name)
+ ctorMethod := t.methodDefs.find |m| { m.isCtor }
+ if (ctorMethod != null && ctorMethod.params.size > 2)
+ {
+ ctorMethod.params[2..-1].each |p|
+ {
+ w(", ")
+ w(escapeName(p.name))
+ // Add default value if param has one
+ if (p.hasDefault)
+ {
+ w("=")
+ PyExprPrinter(this).expr(p->def)
+ }
+ }
+ }
+ w(")").colon
+ indent
+ w("inst = object.__new__(${PyUtil.escapeTypeName(t.name)})").eos
+ w("inst._ordinal = _ordinal").eos
+ w("inst._name = _name").eos
+ // Initialize custom fields from ctor params (skip first 2: ordinal, name)
+ if (ctorMethod != null && ctorMethod.params.size > 2)
+ {
+ ctorMethod.params[2..-1].each |p|
+ {
+ w("inst._${escapeName(p.name)} = ${escapeName(p.name)}").eos
+ }
+ }
+ // Run constructor body to compute derived fields
+ if (ctorMethod != null && ctorMethod.code != null && !ctorMethod.code.stmts.isEmpty)
+ {
+ // Need to bind 'self' for the constructor body
+ w("self = inst").eos
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(ctorMethod.code)
+ ctorMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ // Skip synthetic return statements
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr == null || ret.isSynthetic) return
+ }
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+ w("return inst").eos
+ unindent
+
+ // ordinal() method
+ nl
+ w("def ordinal(self)").colon
+ indent
+ w("return self._ordinal").eos
+ unindent
+
+ // name() method
+ nl
+ w("def name(self)").colon
+ indent
+ w("return self._name").eos
+ unindent
+
+ // to_str() returns the name
+ nl
+ w("def to_str(self)").colon
+ indent
+ w("return self._name").eos
+ unindent
+
+ // __str__ for Python
+ nl
+ w("def __str__(self)").colon
+ indent
+ w("return self._name").eos
+ unindent
+
+ // equals - compare by ordinal for enum equality
+ // NOTE: Using ordinal comparison because enum values may be stored/retrieved
+ // from different contexts (e.g., LogRec._level vs LogLevel.debug())
+ nl
+ w("def equals(self, other)").colon
+ indent
+ w("if not isinstance(other, ${PyUtil.escapeTypeName(t.name)})").colon
+ indent
+ w("return False").eos
+ unindent
+ w("return self._ordinal == other._ordinal").eos
+ unindent
+
+ // compare by ordinal
+ nl
+ w("def compare(self, other)").colon
+ indent
+ w("return self._ordinal - other._ordinal").eos
+ unindent
+
+ // Python comparison operators
+ nl
+ w("def __lt__(self, other)").colon
+ indent
+ w("return self._ordinal < other._ordinal").eos
+ unindent
+
+ nl
+ w("def __le__(self, other)").colon
+ indent
+ w("return self._ordinal <= other._ordinal").eos
+ unindent
+
+ nl
+ w("def __gt__(self, other)").colon
+ indent
+ w("return self._ordinal > other._ordinal").eos
+ unindent
+
+ nl
+ w("def __ge__(self, other)").colon
+ indent
+ w("return self._ordinal >= other._ordinal").eos
+ unindent
+
+ nl
+ w("def __eq__(self, other)").colon
+ indent
+ w("if not isinstance(other, ${PyUtil.escapeTypeName(t.name)})").colon
+ indent
+ w("return False").eos
+ unindent
+ w("return self._ordinal == other._ordinal").eos
+ unindent
+
+ nl
+ w("def __hash__(self)").colon
+ indent
+ w("return hash(self._ordinal)").eos
+ unindent
+
+ // Field getters for custom fields (skip enum values and vals field)
+ t.fieldDefs.each |f|
+ {
+ if (f.enumDef != null) return // Skip enum value fields (A, B, C)
+ if (f.name == "vals") return // Skip vals - we generate our own static vals()
+ if (f.isStatic) return // Skip other static fields
+ fieldAccessors(f)
+ }
+
+ // Additional methods (like negOrdinal in EnumAbc)
+ // Also allows $Once helper methods for once methods on enums
+ t.methodDefs.each |m|
+ {
+ if (m.isCtor) return
+ if (m.isInstanceInit) return
+ if (m.isStaticInit) return
+ // Allow once helper methods (synthetic but needed) - they end with $Once
+ // Allow checkFields$ methods - they validate non-nullable fields were set
+ if (m.isSynthetic && !m.name.endsWith("\$Once") && !m.name.startsWith("checkFields\$")) return
+ if (m.name == "fromStr") return // Already generated
+
+ method(m)
+ }
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Constructor
+//////////////////////////////////////////////////////////////////////////
+
+ ** Generate Python constructor code from Fantom constructors.
+ **
+ ** Python Pattern:
+ ** - Static factory methods: make(), makeFrom(), etc. (all ctors)
+ ** - Single __init__(): uses primary ctor's signature
+ ** - Named ctor bodies: _makeFoo_body() for non-primary ctors
+ ** - Field init helper: _ctor_init() for named ctors to share field setup
+ **
+ ** Fantom -> Python mapping:
+ ** - `new make(a, b)` -> `make(a, b)` factory + `__init__(self, a, b)`
+ ** - `new makeFoo(x)` -> `makeFoo(x)` factory + `_makeFoo_body(self, x)`
+ ** - `static new make()` -> `make()` factory only (no __init__ body)
+ ** - Constructor chaining (: super, : this) handled in body methods
+ **
+ private Void ctor(TypeDef t)
+ {
+ // Find ALL constructors
+ ctorMethods := t.methodDefs.findAll |m| { m.isCtor }
+
+ // Separate static ctors (factories) from instance ctors
+ // Static ctor: `static new make(...)` - just a factory, body should NOT go in __init__
+ // Instance ctor: `new make(...)` - body goes in __init__
+ instanceCtors := ctorMethods.findAll |m| { !m.isStatic }
+ staticCtors := ctorMethods.findAll |m| { m.isStatic }
+
+ // If no constructors, use a simple default
+ if (ctorMethods.isEmpty)
+ {
+ // Generate static make() factory method with no params
+ nl
+ w("@staticmethod").nl
+ w("def make()").colon
+ indent
+ w("return ${PyUtil.escapeTypeName(t.name)}()").eos
+ unindent
+
+ // Generate __init__ constructor
+ // For mixins, use *args, **kwargs to support cooperative multiple inheritance
+ nl
+ if (t.isMixin)
+ {
+ w("def __init__(self, *args, **kwargs)").colon
+ indent
+ w("super().__init__(*args, **kwargs)").eos
+ }
+ else
+ {
+ w("def __init__(self)").colon
+ indent
+ w("super().__init__()").eos
+ }
+ // Instance field initialization
+ t.fieldDefs.each |f|
+ {
+ if (f.isStatic) return
+ fieldInit(f)
+ }
+ unindent
+ nl
+ return
+ }
+
+ // Find the primary constructor for __init__:
+ // - Prefer instance ctor named "make", else first instance ctor
+ // - If no instance ctors, use first static ctor but __init__ will have minimal body
+ primaryCtor := instanceCtors.find |m| { m.name == "make" } ?: instanceCtors.first
+ hasInstanceCtor := primaryCtor != null
+ if (primaryCtor == null)
+ primaryCtor = staticCtors.find |m| { m.name == "make" } ?: staticCtors.first
+
+ // Generate static factory methods for ALL constructors
+ ctorMethods.each |ctorMethod|
+ {
+ ctorName := escapeName(ctorMethod.name) // "make", "make_def", etc. (snake_case)
+ isStaticFactory := ctorMethod.isStatic // static new make(...) vs new make(...)
+
+ nl
+ w("@staticmethod").nl
+ w("def ${ctorName}(")
+ ctorMethod.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ w(escapeName(p.name))
+ if (p.hasDefault)
+ {
+ w("=None")
+ }
+ }
+ w(")")
+ colon
+ indent
+
+ // For static factories: run the factory body code (they call other ctors internally)
+ // For instance ctors: create instance and run body
+ if (isStaticFactory)
+ {
+ // Static factory - run the body code which creates/returns instance
+ // The body will call other ctors like makeImpl or makeSegs
+ emitDefaultParamChecks(ctorMethod)
+ if (ctorMethod.code != null && !ctorMethod.code.stmts.isEmpty)
+ {
+ // Mark static context
+ this.m.inStaticContext = true
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(ctorMethod.code)
+ ctorMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ this.m.inStaticContext = false
+ }
+ else
+ {
+ pass
+ }
+ }
+ else if (ctorName != "make")
+ {
+ // Named instance constructor (not "make") - create instance and run ctor body
+ w("inst = object.__new__(${PyUtil.escapeTypeName(t.name)})").eos
+
+ // Check if this ctor chains to this.make() - if so, skip _ctor_init
+ // because __init__() will handle field initialization
+ chainsToThisMake := ctorMethod.ctorChain != null &&
+ (ctorMethod.ctorChain.target == null || ctorMethod.ctorChain.target.id == ExprId.thisExpr) &&
+ ctorMethod.ctorChain.method.name == "make"
+
+ if (!chainsToThisMake)
+ {
+ w("inst._ctor_init()").eos // Initialize fields only if not chaining to make()
+ }
+
+ // Call the instance body method with params
+ w("inst._${ctorName}_body(")
+ ctorMethod.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ w(escapeName(p.name))
+ }
+ w(")").eos
+ w("return inst").eos
+ }
+ else
+ {
+ // Primary instance make() just delegates to __init__
+ w("return ${PyUtil.escapeTypeName(t.name)}(")
+ ctorMethod.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ w(escapeName(p.name))
+ }
+ w(")").eos
+ }
+ unindent
+ }
+
+ // Generate instance body methods for named constructors
+ ctorMethods.each |ctorMethod|
+ {
+ if (ctorMethod.name == "make") return // Primary ctor uses __init__
+
+ nl
+ w("def _${escapeName(ctorMethod.name)}_body(self")
+ ctorMethod.params.each |p|
+ {
+ w(", ")
+ w(escapeName(p.name))
+ if (p.hasDefault)
+ {
+ w("=None")
+ }
+ }
+ w(")").colon
+ indent
+
+ // Emit default parameter value checks at start of body method
+ // This ensures parameters like `unit := hr` get their defaults applied
+ emitDefaultParamChecks(ctorMethod)
+
+ emittedStmts := false
+
+ // Handle constructor chaining (: this.make(...) or : super(...))
+ if (ctorMethod.ctorChain != null)
+ {
+ chain := ctorMethod.ctorChain
+ // Check if chaining to this or super
+ isThisChain := chain.target == null || chain.target.id == ExprId.thisExpr
+ isSuperChain := chain.target != null && chain.target.id == ExprId.superExpr
+
+ if (isThisChain)
+ {
+ // this.make(...) - call another constructor on this type
+ chainCtorName := chain.method.name
+ if (chainCtorName == "make")
+ {
+ // Call __init__ directly to chain to primary constructor
+ w("self.__init__(")
+ chain.args.each |arg, i|
+ {
+ if (i > 0) w(", ")
+ PyExprPrinter(this).expr(arg)
+ }
+ w(")").eos
+ }
+ else
+ {
+ // Chain to another named constructor's body
+ w("self._${escapeName(chainCtorName)}_body(")
+ chain.args.each |arg, i|
+ {
+ if (i > 0) w(", ")
+ PyExprPrinter(this).expr(arg)
+ }
+ w(")").eos
+ }
+ }
+ else if (isSuperChain)
+ {
+ // super(...) - call parent constructor with chain arguments
+ // This re-calls parent's __init__ with actual arguments to set up parent fields
+ chainCtorName := chain.method.name
+ if (chainCtorName == "make")
+ {
+ // Call parent's __init__ with arguments
+ w("super().__init__(")
+ chain.args.each |arg, i|
+ {
+ if (i > 0) w(", ")
+ PyExprPrinter(this).expr(arg)
+ }
+ w(")").eos
+ }
+ else
+ {
+ // Call parent's named constructor body
+ w("super()._${escapeName(chainCtorName)}_body(")
+ chain.args.each |arg, i|
+ {
+ if (i > 0) w(", ")
+ PyExprPrinter(this).expr(arg)
+ }
+ w(")").eos
+ }
+ }
+ emittedStmts = true
+ }
+
+ if (ctorMethod.code != null && !ctorMethod.code.stmts.isEmpty)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(ctorMethod.code)
+ ctorMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ // Skip synthetic return statements
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr == null || ret.isSynthetic) return
+ }
+ // Skip synthetic instance init calls and enterCtor/exitCtor (field-not-set checks)
+ if (s.id == StmtId.expr)
+ {
+ exprStmt := s as ExprStmt
+ if (exprStmt.expr.id == ExprId.call)
+ {
+ call := exprStmt.expr as CallExpr
+ if (call.method.name.contains("\$init\$")) return
+ // Skip Fantom's enterCtor/exitCtor field-not-set checking (not needed in Python)
+ if (call.method.name == "enterCtor" || call.method.name == "exitCtor") return
+ }
+ }
+ stmtPrinter.stmt(s)
+ emittedStmts = true
+ }
+ this.m.clearClosures()
+ }
+ if (!emittedStmts)
+ {
+ pass
+ }
+ unindent
+ }
+
+ // Generate _ctor_init helper for field initialization (used by named ctors)
+ // This method ONLY initializes THIS class's fields - it does NOT call parent.
+ // Named constructor body methods (like _makeResolved_body) are responsible
+ // for calling the appropriate parent constructor with proper arguments.
+ if (ctorMethods.size > 1 || (ctorMethods.size == 1 && ctorMethods.first.name != "make"))
+ {
+ nl
+ w("def _ctor_init(self)").colon
+ indent
+
+ // Check if parent will have _ctor_init (multiple ctors or named ctors)
+ parentHasMultipleCtors := t.base != null && !t.base.isObj && t.base.ctors.size > 1
+ parentHasNamedCtors := t.base != null && !t.base.isObj &&
+ t.base.ctors.any |ctor| { ctor.name != "make" }
+ parentWillHaveCtorInit := parentHasMultipleCtors || parentHasNamedCtors
+
+ hasFieldsToInit := t.fieldDefs.any |f| { !f.isStatic }
+ hasInstanceInit := t.methodDefs.any |m| { m.isInstanceInit && m.code != null }
+
+ if (parentWillHaveCtorInit)
+ {
+ // Parent has _ctor_init, so we can call it to initialize parent fields
+ w("super()._ctor_init()").eos
+ }
+ else if (!hasFieldsToInit && !hasInstanceInit)
+ {
+ // No parent call, no fields, no instance init - need pass
+ pass
+ }
+ // Otherwise, DO NOT call parent - the named constructor body will handle
+ // the parent chain with proper arguments
+
+ // Instance field initialization (THIS class's fields only)
+ t.fieldDefs.each |f|
+ {
+ if (f.isStatic) return
+ fieldInit(f)
+ }
+ // Instance init block
+ instanceInitMethod := t.methodDefs.find |m| { m.isInstanceInit }
+ if (instanceInitMethod != null && instanceInitMethod.code != null)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(instanceInitMethod.code)
+ instanceInitMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+ unindent
+ }
+
+ // Generate __init__ constructor (uses primary ctor's signature)
+ // For mixins, use *args, **kwargs to support cooperative multiple inheritance
+ nl
+ if (t.isMixin)
+ {
+ w("def __init__(self, *args, **kwargs)")
+ }
+ else
+ {
+ w("def __init__(self")
+
+ // Only emit params if we have an actual instance constructor
+ // Static factories (static new) don't define instance constructor params
+ if (hasInstanceCtor)
+ {
+ // In Python, once we emit a default parameter, ALL following params must have defaults
+ // Find the first index where we should start emitting defaults:
+ // - explicit hasDefault, OR
+ // - nullable type AND all following params also have defaults or are nullable
+ firstDefaultIdx := primaryCtor.params.size
+ for (i := primaryCtor.params.size - 1; i >= 0; i--)
+ {
+ p := primaryCtor.params[i]
+ if (p.hasDefault || p.type.isNullable)
+ firstDefaultIdx = i
+ else
+ break // Found a required param, stop
+ }
+
+ primaryCtor.params.each |p, i|
+ {
+ w(", ")
+ w(escapeName(p.name))
+ // Only add =None if at or after firstDefaultIdx
+ if (i >= firstDefaultIdx)
+ {
+ w("=None")
+ }
+ }
+ }
+ w(")")
+ }
+ colon
+
+ indent
+
+ // Emit default parameter value checks at start of constructor body
+ // Only do this if we have an actual instance constructor with params
+ if (hasInstanceCtor)
+ emitDefaultParamChecks(primaryCtor)
+
+ // Call super().__init__() with constructor chain arguments
+ // For mixins, pass through *args, **kwargs to support cooperative multiple inheritance
+ if (t.isMixin)
+ {
+ w("super().__init__(*args, **kwargs)").eos
+ }
+ else
+ {
+ w("super().__init__(")
+ // Only pass chain args to super().__init__() if chain target is super (not this)
+ // For this.makeFields(...) chains, the args are for the sibling constructor, not parent
+ if (hasInstanceCtor && primaryCtor.ctorChain != null)
+ {
+ chain := primaryCtor.ctorChain
+ isSuperChain := chain.target != null && chain.target.id == ExprId.superExpr
+ if (isSuperChain)
+ {
+ chain.args.each |arg, i|
+ {
+ if (i > 0) w(", ")
+ PyExprPrinter(this).expr(arg)
+ }
+ }
+ }
+ w(")").eos
+ }
+
+ // Instance field initialization
+ t.fieldDefs.each |f|
+ {
+ if (f.isStatic) return
+ fieldInit(f)
+ }
+
+ // Instance init block
+ instanceInitMethod := t.methodDefs.find |m| { m.isInstanceInit }
+ if (instanceInitMethod != null && instanceInitMethod.code != null)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(instanceInitMethod.code)
+ instanceInitMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ // Skip synthetic return statements (same filtering as primary ctor body)
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr == null || ret.isSynthetic) return
+ }
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+
+ // Primary constructor body
+ // ONLY emit body if primaryCtor is an instance ctor (not a static factory)
+ // Static factories (static new make(...)) should NOT have their body in __init__
+ // because they return newly constructed instances, not modify `self`
+ if (hasInstanceCtor && primaryCtor.code != null)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(primaryCtor.code)
+
+ primaryCtor.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ // Skip synthetic return statements
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr == null || ret.isSynthetic) return
+ // Skip return statements that return construction calls (factory pattern)
+ // These are static factory returns, not instance initialization
+ if (ret.expr.id == ExprId.call || ret.expr.id == ExprId.construction) return
+ }
+ // Skip synthetic instance init calls and enterCtor/exitCtor (field-not-set checks)
+ if (s.id == StmtId.expr)
+ {
+ exprStmt := s as ExprStmt
+ if (exprStmt.expr.id == ExprId.call)
+ {
+ call := exprStmt.expr as CallExpr
+ if (call.method.name.contains("\$init\$")) return
+ // Skip Fantom's enterCtor/exitCtor field-not-set checking (not needed in Python)
+ if (call.method.name == "enterCtor" || call.method.name == "exitCtor") return
+ }
+ }
+ stmtPrinter.stmt(s)
+ }
+ }
+
+ // Clear closure state
+ this.m.clearClosures()
+
+ unindent
+ nl
+ }
+
+ ** Write field storage initialization with type default value.
+ ** Matches ES compiler pattern: field storage gets type defaults (false, 0, null),
+ ** actual initialization happens in instance$init which has properly resolved expressions.
+ private Void fieldInit(FieldDef f)
+ {
+ w("self._${escapeName(f.name)} = ")
+ w(defaultVal(f.type))
+ eos
+ }
+
+ private Void fieldAccessors(FieldDef f)
+ {
+ // Skip if field has custom get/set accessors
+ // Custom accessors are generated via combinedFieldAccessor() call below
+ if (f.hasGet || f.hasSet) return
+
+ name := escapeName(f.name)
+ // Return type reflects Fantom's actual type: non-nullable Str -> 'str',
+ // nullable Str? -> 'Optional[str]'. Setter returns the value so both
+ // code paths have a matching return type ("set is always just a get too").
+ retHint := pyTypeHint(f.type)
+
+ // Generate combined getter/setter: def fieldName(self, _val_=_UNSET) -> T:
+ // This is required because PyExprPrinter.assign() uses method call syntax
+ // for field assignments on transpiled types: target.fieldName(value)
+ nl
+ w("def ${name}(self, _val_=_UNSET) -> ${retHint}").colon
+ indent
+ w("if _val_ is _UNSET").colon
+ indent
+ w("return self._${name}").eos
+ unindent
+ w("else").colon
+ indent
+ w("self._${name} = _val_").eos
+ w("return _val_").eos
+ unindent
+ unindent
+ }
+
+ ** Default value for uninitialized fields.
+ ** In Fantom:
+ ** - Nullable types (?) always default to null
+ ** - Non-nullable primitives (Int, Float, Bool) have type defaults (0, 0.0, false)
+ ** - Non-nullable reference types (Str, List, etc.) start as null, must be initialized
+ private Str defaultVal(CType t)
+ {
+ // Nullable types always default to None
+ if (t.isNullable) return "None"
+
+ sig := t.signature
+ // Non-nullable primitives have type-specific defaults
+ if (sig == "sys::Bool") return "False"
+ if (sig == "sys::Int") return "0"
+ if (sig == "sys::Float") return "0.0"
+
+ // Non-nullable reference types (Str, List, etc.) start as None
+ // They must be initialized by constructor - Fantom's type system enforces this
+ return "None"
+ }
+
+ ** Type-specific default for static field getter fallback
+ ** Used only when static field getter finds None after _static_init()
+ private Str typeDefaultVal(CType t)
+ {
+ if (t.isNullable) return "None"
+ sig := t.signature
+ if (sig == "sys::Bool") return "False"
+ if (sig == "sys::Int") return "0"
+ if (sig == "sys::Float") return "0.0"
+ if (sig == "sys::Str") return "\"\""
+ return "None"
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Static Fields
+//////////////////////////////////////////////////////////////////////////
+
+ ** Generate static fields for enum types
+ ** Handles once storage fields, regular computed static fields, but skips vals and enum values
+ ** since those are handled by the enum-specific code generation
+ private Void enumStaticFields(TypeDef t)
+ {
+ // Collect once storage field names
+ onceStorageFieldNames := Str:Bool[:]
+ t.methodDefs.each |m|
+ {
+ if (m.isOnce)
+ onceStorageFieldNames["${m.name}\$Store"] = true
+ }
+
+ // Find static fields for enums (once storage fields AND regular computed static fields)
+ staticFieldDefs := t.fieldDefs.findAll |f|
+ {
+ if (!f.isStatic) return false
+ if (f.enumDef != null) return false // Skip enum value fields
+ if (f.name == "vals") return false // Skip vals - handled by enum code
+ // Include once storage fields
+ if (f.isOnce) return true
+ if (onceStorageFieldNames.containsKey(f.name)) return true
+ // Also include regular static fields (like 'keywords' map)
+ if (!f.isSynthetic) return true
+ return false
+ }
+ if (staticFieldDefs.isEmpty) return
+
+ // Check if there's a staticInit method
+ staticInitMethod := t.methodDefs.find |m| { m.isStaticInit }
+
+ // Generate class-level storage for each static field
+ nl
+ staticFieldDefs.each |f|
+ {
+ isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name)
+ if (isOnceStorage)
+ w("_${escapeName(f.name)} = \"_once_\"").nl
+ else
+ w("_${escapeName(f.name)} = None").nl
+ }
+
+ // Generate static getter for each static field
+ staticFieldDefs.each |f|
+ {
+ isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name)
+ staticFieldGetter(t, f, staticInitMethod != null && !isOnceStorage, isOnceStorage)
+ }
+
+ // Generate _static_init() method if there's a staticInit block or fields need init
+ if (staticInitMethod != null || staticFieldDefs.any |f| { f.init != null && !f.isOnce && !onceStorageFieldNames.containsKey(f.name) })
+ {
+ enumStaticInit(t, staticInitMethod, staticFieldDefs, onceStorageFieldNames)
+ }
+ }
+
+ ** Generate _static_init() for enum types - handles computed static fields
+ private Void enumStaticInit(TypeDef t, MethodDef? staticInitMethod, FieldDef[] staticFieldDefs, Str:Bool onceStorageFieldNames)
+ {
+ typeName := PyUtil.escapeTypeName(t.name)
+
+ nl
+ w("@staticmethod").nl
+ w("def _static_init()").colon
+ indent
+
+ // Add re-entry guard
+ w("if hasattr(${typeName}, '_static_init_in_progress') and ${typeName}._static_init_in_progress").colon
+ indent
+ w("return").eos
+ unindent
+ w("${typeName}._static_init_in_progress = True").eos
+
+ m.inStaticContext = true
+
+ // Collect enum field names for filtering
+ enumFieldNames := Str:Bool[:]
+ t.fieldDefs.each |f|
+ {
+ if (f.enumDef != null)
+ enumFieldNames[f.name] = true
+ }
+
+ if (staticInitMethod != null && staticInitMethod.code != null)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(staticInitMethod.code)
+ staticInitMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ // Skip synthetic return
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr == null) return
+ }
+ // Skip enum value field assignments
+ if (isEnumFieldAssignment(s, enumFieldNames))
+ return
+ // Skip once field assignments (they use sentinel pattern)
+ if (isOnceFieldAssignment(s, onceStorageFieldNames))
+ return
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+ else
+ {
+ // No static block - initialize fields with inline initializers
+ staticFieldDefs.each |f|
+ {
+ if (f.init == null) return
+ if (f.isOnce || onceStorageFieldNames.containsKey(f.name)) return
+ name := escapeName(f.name)
+ w("if ${typeName}._${name} is None").colon
+ indent
+ w("${typeName}._${name} = ")
+ PyExprPrinter(this).expr(f.init)
+ eos
+ unindent
+ }
+ }
+
+ w("${typeName}._static_init_in_progress = False").eos
+ m.inStaticContext = false
+
+ unindent
+ }
+
+ ** Check if a statement assigns to a once storage field
+ private Bool isOnceFieldAssignment(Stmt s, Str:Bool onceFieldNames)
+ {
+ if (s.id != StmtId.expr) return false
+ exprStmt := s as ExprStmt
+ if (exprStmt.expr.id != ExprId.assign) return false
+
+ assignExpr := exprStmt.expr as BinaryExpr
+ if (assignExpr.lhs.id != ExprId.field) return false
+
+ fieldExpr := assignExpr.lhs as FieldExpr
+ if (!fieldExpr.field.isStatic) return false
+
+ return onceFieldNames.containsKey(fieldExpr.field.name)
+ }
+
+ ** Generate static fields with class-level storage and lazy-init getters
+ ** Follows the JavaScript transpiler pattern from JsType.writeStaticField()
+ private Void staticFields(TypeDef t)
+ {
+ // Collect names of once storage fields from once methods
+ // When Fantom compiles a `once` method, it creates:
+ // 1. A storage field: methodName$Store (synthetic)
+ // 2. A helper method: methodName$Once (synthetic, contains original body)
+ // 3. The original method modified to check storage and call helper
+ // We identify once storage fields by finding methods with isOnce=true
+ onceStorageFieldNames := Str:Bool[:]
+ t.methodDefs.each |m|
+ {
+ if (m.isOnce)
+ onceStorageFieldNames["${m.name}\$Store"] = true
+ }
+
+ // Find all static fields:
+ // - Non-synthetic fields (normal static fields)
+ // - Synthetic fields that are once storage (identified by method analysis above)
+ // - Fields with isOnce flag set (compiler may also set this)
+ staticFieldDefs := t.fieldDefs.findAll |f|
+ {
+ if (!f.isStatic) return false
+ if (f.enumDef != null) return false // Skip enum value fields - handled by vals()/_make_enum()
+ if (!f.isSynthetic) return true // Normal static field
+ if (f.isOnce) return true // Compiler marked as once
+ if (onceStorageFieldNames.containsKey(f.name)) return true // Backing storage for once method
+ return false
+ }
+ if (staticFieldDefs.isEmpty) return
+
+ // Check if there's a staticInit method
+ staticInitMethod := t.methodDefs.find |m| { m.isStaticInit }
+
+ // Generate class-level storage for each static field
+ nl
+ staticFieldDefs.each |f|
+ {
+ // Once fields use "_once_" as sentinel value, regular fields use None
+ isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name)
+ if (isOnceStorage)
+ w("_${escapeName(f.name)} = \"_once_\"").nl
+ else
+ w("_${escapeName(f.name)} = None").nl
+ }
+
+ // Generate static getter for each static field
+ staticFieldDefs.each |f|
+ {
+ // Determine if this is a once storage field (synthetic backing for once method)
+ isOnceStorage := f.isOnce || onceStorageFieldNames.containsKey(f.name)
+ staticFieldGetter(t, f, staticInitMethod != null, isOnceStorage)
+ }
+
+ // Generate _static_init() method if there's a staticInit block or fields need init
+ if (staticInitMethod != null || staticFieldDefs.any |f| { f.init != null })
+ {
+ staticInit(t, staticInitMethod, staticFieldDefs)
+ }
+ }
+
+ ** Generate static getter method for a static field
+ ** isOnceStorage: true if this field is backing storage for a once method
+ private Void staticFieldGetter(TypeDef t, FieldDef f, Bool hasStaticInit, Bool isOnceStorage)
+ {
+ name := escapeName(f.name)
+ typeName := PyUtil.escapeTypeName(t.name)
+
+ nl
+ w("@staticmethod").nl
+ w("def ${name}()").colon
+ indent
+
+ // Special handling for 'once' fields (either compiler-marked or identified as once storage)
+ // Once fields use "_once_" as sentinel and call the $Once helper method
+ if (isOnceStorage)
+ {
+ // The field name is like "specRef$Store", helper method is "specRef$Once"
+ // We need to extract the base name and generate the helper call
+ // Field name: specRef_Store (escaped from specRef$Store)
+ // Helper method: specRef_Once (escaped from specRef$Once)
+ baseName := f.name
+ if (baseName.endsWith("\$Store"))
+ baseName = baseName[0..<-6] // Remove "$Store" suffix
+ helperName := escapeName(baseName + "\$Once")
+
+ w("if ${typeName}._${name} == \"_once_\"").colon
+ indent
+ w("${typeName}._${name} = ${typeName}.${helperName}()").eos
+ unindent
+ w("return ${typeName}._${name}").eos
+ }
+ else
+ {
+ // Regular static field - check if uninitialized, then initialize
+ w("if ${typeName}._${name} is None").colon
+ indent
+ if (hasStaticInit || f.init != null)
+ {
+ w("${typeName}._static_init()").eos
+ }
+ // If still None after static init, use type-specific default value
+ w("if ${typeName}._${name} is None").colon
+ indent
+ w("${typeName}._${name} = ${typeDefaultVal(f.type)}").eos
+ unindent
+ unindent
+
+ w("return ${typeName}._${name}").eos
+ }
+ unindent
+ }
+
+ ** Generate _static_init() method that initializes all static fields
+ ** Follows Fantom's source order: static blocks run before fields declared after them
+ private Void staticInit(TypeDef t, MethodDef? staticInitMethod, FieldDef[] staticFieldDefs)
+ {
+ typeName := PyUtil.escapeTypeName(t.name)
+
+ nl
+ w("@staticmethod").nl
+ w("def _static_init()").colon
+ indent
+
+ // Add re-entry guard to prevent infinite recursion
+ // This handles circular dependencies between static fields
+ w("if hasattr(${typeName}, '_static_init_in_progress') and ${typeName}._static_init_in_progress").colon
+ indent
+ w("return").eos
+ unindent
+ w("${typeName}._static_init_in_progress = True").eos
+
+ // Mark that we're in a static context (no 'self' available)
+ m.inStaticContext = true
+
+ // Collect enum field names for filtering (only for enum types)
+ enumFieldNames := Str:Bool[:]
+ if (t.isEnum)
+ {
+ t.fieldDefs.each |f|
+ {
+ if (f.enumDef != null)
+ enumFieldNames[f.name] = true
+ }
+ }
+
+ // If there's a static init method, it contains EVERYTHING (Fantom compiler combines
+ // all static initialization into one method). Just emit it, skipping the final return
+ // and enum value assignments (which are handled by vals()/_make_enum()).
+ if (staticInitMethod != null && staticInitMethod.code != null)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(staticInitMethod.code)
+ staticInitMethod.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ // Skip synthetic return at end - we'll handle cleanup after
+ if (s.id == StmtId.returnStmt)
+ {
+ ret := s as ReturnStmt
+ if (ret.expr == null) return // Skip void return
+ }
+ // Skip enum value field assignments - they're handled by vals()/_make_enum()
+ if (t.isEnum && isEnumFieldAssignment(s, enumFieldNames))
+ return
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+ else
+ {
+ // No static block - just initialize fields with inline initializers
+ staticFieldDefs.each |f|
+ {
+ if (f.init == null) return
+ name := escapeName(f.name)
+ w("if ${typeName}._${name} is None").colon
+ indent
+ w("${typeName}._${name} = ")
+ PyExprPrinter(this).expr(f.init)
+ eos
+ unindent
+ }
+ }
+
+ // Clear the re-entry guard
+ w("${typeName}._static_init_in_progress = False").eos
+
+ // Reset static context flag
+ m.inStaticContext = false
+
+ unindent
+ }
+
+ ** Generate combined getter/setter method for fields with custom accessors
+ ** In Python, we combine them into one method with optional parameter
+ private Void combinedFieldAccessor(FieldDef f)
+ {
+ name := escapeName(f.name)
+ // Return type reflects Fantom's actual type. Setter returns the value
+ // so both code paths have a matching return type.
+ retHint := pyTypeHint(f.type)
+
+ // Generate: def fieldName(self, _val_=_UNSET) -> T:
+ // if _val_ is _UNSET:
+ // # getter body
+ // else:
+ // # setter body (returns _val_)
+ nl
+ w("def ${name}(self, _val_=_UNSET) -> ${retHint}").colon
+ indent
+ nl
+
+ w("if _val_ is _UNSET").colon
+ indent
+ // Getter body
+ if (f.get != null && f.get.code != null)
+ {
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(f.get.code)
+ f.get.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+ else
+ {
+ w("return self._${name}").eos
+ }
+ unindent
+
+ w("else").colon
+ indent
+ // Setter body - need to replace 'it' param with '_val_'
+ if (f.set != null && f.set.code != null)
+ {
+ // Map 'it' to '_val_' for the setter body
+ w("it = _val_").eos
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(f.set.code)
+ f.set.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ stmtPrinter.stmt(s)
+ }
+ this.m.clearClosures()
+ }
+ else
+ {
+ w("self._${name} = _val_").eos
+ }
+ w("return _val_").eos
+ unindent
+
+ unindent
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Methods
+//////////////////////////////////////////////////////////////////////////
+
+ private Void method(MethodDef m)
+ {
+ this.m.curMethod = m
+
+ // Set static context flag for static methods
+ // This ensures calls like self.make() become ClassName.make()
+ if (m.isStatic)
+ this.m.inStaticContext = true
+
+ nl
+
+ // Static decorator
+ if (m.isStatic)
+ w("@staticmethod").nl
+
+ // def method_name(self, params...) -> ReturnType:
+ w("def ${escapeName(m.name)}(")
+ if (!m.isStatic) w("self")
+
+ m.params.each |p, i|
+ {
+ if (i > 0 || !m.isStatic) w(", ")
+ w(escapeName(p.name))
+ w(": ${pyTypeHint(p.type)}") // Type hint for parameter
+ if (p.hasDefault)
+ {
+ w(" = None") // Default params simplified for bootstrap
+ }
+ }
+ w(") -> ${pyTypeHint(m.returns)}") // Return type hint
+ colon
+
+ // Method body
+ if (m.code == null || m.code.stmts.isEmpty)
+ {
+ indent
+ // Even empty methods need default parameter handling
+ // Count how many default checks will actually be emitted
+ // Must match the logic in emitDefaultParamChecks
+ paramNames := Str[,]
+ m.params.each |p| { paramNames.add(p.name) }
+ checksEmitted := m.params.any |p|
+ {
+ if (!p.hasDefault) return false
+ defExpr := p->def as Expr
+ isNullDefault := defExpr != null && defExpr.id == ExprId.nullLiteral
+ // Skip nullable params with non-null defaults unless they reference other params
+ if (p.type.isNullable && !isNullDefault && !exprReferencesParams(defExpr, paramNames))
+ return false
+ return true
+ }
+ emitDefaultParamChecks(m)
+ if (!checksEmitted)
+ pass
+ unindent
+ }
+ else
+ {
+ indent
+
+ // Emit default parameter value checks at start of method body
+ // This follows the JavaScript transpiler pattern from JsType.doWriteMethod()
+ emitDefaultParamChecks(m)
+
+ // Scan method for closures to track usage
+ stmtPrinter := PyStmtPrinter(this)
+ stmtPrinter.scanMethodForClosures(m.code)
+
+ // Emit statements (closures emitted lazily)
+ m.code.stmts.each |s, idx|
+ {
+ this.m.stmtIndex = idx
+ stmtPrinter.stmt(s)
+ }
+
+ unindent
+ }
+
+ // Clear closure state for next method
+ this.m.clearClosures()
+
+ // NOTE: pyMain is now called from type() method after class ends
+ // to ensure if __name__ block is at module level, not inside class
+
+ // Reset static context flag
+ if (m.isStatic)
+ this.m.inStaticContext = false
+
+ this.m.curMethod = null
+ }
+
+ ** Emit default parameter value checks at start of method body
+ ** Follows JS transpiler pattern: if (param === undefined) param = defaultExpr;
+ ** For Python: if param is None: param = defaultExpr
+ **
+ ** IMPORTANT: For nullable params (Type?) with non-null defaults, we ONLY emit the check
+ ** if the default expression references another parameter (like `val.typeof`).
+ ** This preserves the "passing null" semantic for methods that use null to mean "no value"
+ ** (e.g., Duration? timeout := 30sec where null means no timeout).
+ private Void emitDefaultParamChecks(MethodDef m)
+ {
+ // Collect param names for reference checking
+ paramNames := Str[,]
+ m.params.each |p| { paramNames.add(p.name) }
+
+ m.params.each |p|
+ {
+ if (!p.hasDefault) return
+
+ defExpr := p->def as Expr
+ isNullDefault := defExpr != null && defExpr.id == ExprId.nullLiteral
+
+ // For nullable params with non-null defaults, only emit check if default
+ // references another param (like val.typeof). Otherwise, skip to preserve
+ // the "passing null means null" semantic.
+ if (p.type.isNullable && !isNullDefault)
+ {
+ if (!exprReferencesParams(defExpr, paramNames))
+ return // Skip - let body code handle null
+ }
+
+ name := escapeName(p.name)
+
+ // Generate: if param is None: param = defaultValue
+ w("if ${name} is None").colon
+ indent
+ w("${name} = ")
+ // p.def_ is the default value expression - transpile it
+ PyExprPrinter(this).expr(p->def)
+ eos
+ unindent
+ }
+ }
+
+ ** Check if an expression references any of the given parameter names
+ ** Used to detect defaults like `val.typeof` that depend on other params
+ private Bool exprReferencesParams(Expr? e, Str[] paramNames)
+ {
+ if (e == null) return false
+
+ // Check if this is a local variable reference to a param
+ if (e.id == ExprId.localVar)
+ {
+ localVar := e as LocalVarExpr
+ return paramNames.contains(localVar.var.name)
+ }
+
+ // Check call target and args
+ if (e.id == ExprId.call)
+ {
+ call := e as CallExpr
+ if (call.target != null && exprReferencesParams(call.target, paramNames))
+ return true
+ return call.args.any |arg| { exprReferencesParams(arg, paramNames) }
+ }
+
+ // Check binary expr operands
+ if (e is BinaryExpr)
+ {
+ bin := e as BinaryExpr
+ return exprReferencesParams(bin.lhs, paramNames) || exprReferencesParams(bin.rhs, paramNames)
+ }
+
+ // Check unary expr
+ if (e is UnaryExpr)
+ {
+ unary := e as UnaryExpr
+ return exprReferencesParams(unary.operand, paramNames)
+ }
+
+ // Check ternary
+ if (e is TernaryExpr)
+ {
+ ternary := e as TernaryExpr
+ return exprReferencesParams(ternary.condition, paramNames) ||
+ exprReferencesParams(ternary.trueExpr, paramNames) ||
+ exprReferencesParams(ternary.falseExpr, paramNames)
+ }
+
+ // Check shortcut expr
+ if (e is ShortcutExpr)
+ {
+ shortcut := e as ShortcutExpr
+ if (exprReferencesParams(shortcut.target, paramNames))
+ return true
+ return shortcut.args.any |arg| { exprReferencesParams(arg, paramNames) }
+ }
+
+ // Check coerce/type check
+ if (e.id == ExprId.coerce || e.id == ExprId.isExpr || e.id == ExprId.asExpr)
+ {
+ typeCheck := e as TypeCheckExpr
+ return exprReferencesParams(typeCheck.target, paramNames)
+ }
+
+ return false
+ }
+
+ ** Check if this is a main(Str[] args) method
+ private Bool isMain(MethodDef m)
+ {
+ m.name == "main" && m.params.size == 1 && m.params[0].type.isList
+ }
+
+ ** Generate Python main block
+ private Void pyMain(MethodDef m)
+ {
+ nl
+ nl
+ w("if __name__ == \"__main__\":").nl
+ indent
+ typeName := PyUtil.escapeTypeName(m.parent.name)
+ w("import sys as sys_mod").nl
+ w("from fan.sys.List import List").nl
+ w("args = List.from_literal(sys_mod.argv[1:], 'sys::Str')").nl
+ w("exit_code = ${typeName}.main(args)").nl
+ w("sys_mod.exit(exit_code if exit_code is not None else 0)").nl
+ unindent
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Type Metadata (Reflection)
+//////////////////////////////////////////////////////////////////////////
+
+ ** Generate type metadata registration for reflection
+ ** This is similar to how JsPod.writeTypeInfo() works for JavaScript
+ private Void typeMetadata(TypeDef t)
+ {
+ // Skip synthetic types
+ if (t.isSynthetic) return
+
+ // Check if we have any slots to register
+ hasSlots := t.fieldDefs.any |f| { !f.isSynthetic } ||
+ t.methodDefs.any |m| { !m.isSynthetic && !m.isInstanceInit && !m.isStaticInit }
+
+ // Always need to emit tf_() for type-level metadata (facets, mixins, base type)
+ // even if there are no slots (e.g., mixins with only inherited members)
+ hasFacets := t.facets != null && !t.facets.isEmpty
+ hasMixins := !t.mixins.isEmpty && t.mixins.any |m| { m.qname != "sys::Obj" }
+ hasBase := t.base != null && !t.base.isObj
+
+ // Skip only if there's nothing to register
+ if (!hasSlots && !hasFacets && !hasMixins && !hasBase) return
+
+ nl
+ w("# Type metadata registration for reflection").nl
+ w("from fan.sys.Param import Param").nl
+ w("from fan.sys.Slot import FConst").nl
+ // For sys pod types, Type is already imported directly (no sys. prefix)
+ // For other pods, use sys.Type.find() via namespace import
+ if (t.pod.name == "sys")
+ w("_t = Type.find('${t.qname}')").nl
+ else
+ w("_t = sys.Type.find('${t.qname}')").nl
+
+ // Calculate type flags
+ typeFlags := typeFlags(t)
+
+ // Build mixin list
+ mixinList := Str[,]
+ t.mixins.each |m|
+ {
+ if (m.qname != "sys::Obj")
+ mixinList.add("'${m.qname}'")
+ }
+ mixinsStr := "[" + mixinList.join(", ") + "]"
+
+ // Register type-level facets with flags, mixins, and base type
+ typeFacets := facetDict(t.facets)
+ // Enums implicitly have @Serializable{simple=true}
+ if (t.isEnum)
+ {
+ if (typeFacets == "{}")
+ typeFacets = "{'sys::Serializable': {'simple': True}}"
+ else if (!typeFacets.contains("sys::Serializable"))
+ typeFacets = typeFacets[0..-2] + ", 'sys::Serializable': {'simple': True}}"
+ }
+ // Determine base type (non-Obj)
+ baseStr := "None"
+ if (t.base != null && !t.base.isObj)
+ baseStr = "'${t.base.qname}'"
+ // Always emit tf_() with flags, mixins, and base
+ w("_t.tf_(${typeFacets}, ${typeFlags}, ${mixinsStr}, ${baseStr})").nl
+
+ // Register fields (including enum value fields)
+ t.fieldDefs.each |f|
+ {
+ if (f.isSynthetic) return
+
+ flags := fieldFlags(f)
+ typeSig := PyUtil.sanitizeJavaFfi(f.type.signature)
+ fieldFacets := "{}"
+
+ // Enum value fields get special flag handling and may have facets
+ if (f.enumDef != null)
+ {
+ // Enum values are public static const using canonical FConst values
+ enumFlags := FConst.Public.or(FConst.Static).or(FConst.Const).or(FConst.Enum)
+ fieldFacets = facetDict(f.enumDef.facets)
+ // Use original Fantom name (f.name) for enum fields - NOT escapeName()
+ // Enum field names like "A", "B", "C" must stay uppercase for reflection
+ // Field.name() should return "A" not "a"
+ w("_t.af_('${f.name}', ${enumFlags}, '${typeSig}', ${fieldFacets})").nl
+ }
+ else
+ {
+ fieldFacets = facetDict(f.facets)
+ setterFlags := setterFlags(f)
+ // Use escapeName() for regular fields - converts to snake_case to match Python storage
+ // The Python code stores fields as _const_x not _constX
+ // Only emit setter flags if they differ from field flags
+ if (setterFlags != flags)
+ w("_t.af_('${escapeName(f.name)}', ${flags}, '${typeSig}', ${fieldFacets}, ${setterFlags})").nl
+ else
+ w("_t.af_('${escapeName(f.name)}', ${flags}, '${typeSig}', ${fieldFacets})").nl
+ }
+ }
+
+ // Register methods
+ t.methodDefs.each |m|
+ {
+ if (m.isSynthetic) return
+ if (m.isInstanceInit) return
+ if (m.isStaticInit) return
+ if (m.isFieldAccessor) return
+
+ flags := methodFlags(m)
+ retSig := PyUtil.sanitizeJavaFfi(m.returns.signature)
+ methodFacets := facetDict(m.facets)
+
+ // Build params list
+ // For sys pod types, Type is already imported directly
+ // For other pods, use sys.Type.find()
+ typeFind := (t.pod.name == "sys") ? "Type.find" : "sys.Type.find"
+ if (m.params.isEmpty)
+ {
+ w("_t.am_('${m.name}', ${flags}, '${retSig}', [], ${methodFacets})").nl
+ }
+ else
+ {
+ w("_t.am_('${m.name}', ${flags}, '${retSig}', [")
+ m.params.each |p, i|
+ {
+ if (i > 0) w(", ")
+ pType := PyUtil.sanitizeJavaFfi(p.type.signature)
+ hasDefault := p.hasDefault ? "True" : "False"
+ w("Param('${escapeName(p.name)}', ${typeFind}('${pType}'), ${hasDefault})")
+ }
+ w("], ${methodFacets})").nl
+ }
+ }
+
+ // Register implicit default constructor if no explicit (non-synthetic) ctors defined
+ // The Fantom compiler adds a synthetic default ctor to methodDefs, so we check for non-synthetic
+ // Types without explicit constructors get an implicit make() that needs reflection registration
+ explicitCtors := t.methodDefs.findAll |m| { m.isCtor && !m.isSynthetic }
+ if (explicitCtors.isEmpty && !t.isEnum && !t.isMixin)
+ {
+ // Implicit make() is public static constructor returning This
+ implicitCtorFlags := FConst.Public.or(FConst.Ctor).or(FConst.Static)
+ w("_t.am_('make', ${implicitCtorFlags}, '${t.qname}', [], {})").nl
+ }
+ }
+
+ ** Serialize facets to Python dict format: {'sys::Serializable': {'simple': True}}
+ private Str facetDict(FacetDef[]? facets)
+ {
+ if (facets == null || facets.isEmpty) return "{}"
+
+ s := StrBuf()
+ s.addChar('{')
+ facets.each |f, i|
+ {
+ if (i > 0) s.add(", ")
+ s.addChar('\'').add(f.type.qname).add("': ")
+
+ // Build the facet value dict
+ if (f.names.isEmpty)
+ {
+ s.add("{}")
+ }
+ else
+ {
+ s.addChar('{')
+ f.names.each |name, j|
+ {
+ if (j > 0) s.add(", ")
+ s.addChar('\'').add(name).add("': ")
+ // Serialize the value - convert to Python literal
+ val := f.vals[j]
+ s.add(exprToPython(val))
+ }
+ s.addChar('}')
+ }
+ }
+ s.addChar('}')
+ return s.toStr
+ }
+
+ ** Convert a Fantom expression to a Python literal string
+ private Str exprToPython(Expr e)
+ {
+ if (e is LiteralExpr)
+ {
+ lit := e as LiteralExpr
+ val := lit.val
+ if (val == null) return "None"
+ if (val is Bool) return val == true ? "True" : "False"
+ if (val is Int) return val.toStr
+ if (val is Float) return val.toStr
+ if (val is Str) return val.toStr.toCode
+ return val.toStr.toCode
+ }
+ // Fallback - use Fantom's serialization
+ return e.serialize.toCode
+ }
+
+ ** Calculate field flags - uses canonical FConst values from compiler/fan/fcode/FConst.fan
+ private Int fieldFlags(FieldDef f)
+ {
+ flags := 0
+ if (f.isPublic) flags = flags.or(FConst.Public)
+ if (f.isPrivate) flags = flags.or(FConst.Private)
+ if (f.isProtected) flags = flags.or(FConst.Protected)
+ if (f.isInternal) flags = flags.or(FConst.Internal)
+ if (f.isStatic) flags = flags.or(FConst.Static)
+ if (f.isVirtual) flags = flags.or(FConst.Virtual)
+ if (f.isOverride) flags = flags.or(FConst.Override)
+ if (f.isConst) flags = flags.or(FConst.Const)
+ return flags
+ }
+
+ ** Calculate setter-specific flags - uses canonical FConst values
+ ** For fields like "Int x { private set }", the setter has different visibility
+ ** than the field itself. Returns setter flags if f.set exists, otherwise field flags.
+ private Int setterFlags(FieldDef f)
+ {
+ // If field has no setter accessor method, use field flags
+ if (f.set == null) return fieldFlags(f)
+
+ // Use the setter's flags directly - FConst values from compiler
+ setFlags := f.set.flags
+
+ // Check setter's protection using canonical FConst values
+ isSetterPublic := setFlags.and(FConst.Public) != 0
+ isSetterPrivate := setFlags.and(FConst.Private) != 0
+ isSetterProtected := setFlags.and(FConst.Protected) != 0
+ isSetterInternal := setFlags.and(FConst.Internal) != 0
+
+ // If setter has no protection flags, inherit from field
+ if (!isSetterPublic && !isSetterPrivate && !isSetterProtected && !isSetterInternal)
+ return fieldFlags(f)
+
+ // Build output flags using canonical FConst values
+ flags := 0
+ if (isSetterPublic) flags = flags.or(FConst.Public)
+ if (isSetterPrivate) flags = flags.or(FConst.Private)
+ if (isSetterProtected) flags = flags.or(FConst.Protected)
+ if (isSetterInternal) flags = flags.or(FConst.Internal)
+
+ // Non-protection flags from field
+ if (f.isStatic) flags = flags.or(FConst.Static)
+ if (f.isVirtual) flags = flags.or(FConst.Virtual)
+ if (f.isOverride) flags = flags.or(FConst.Override)
+ if (f.isConst) flags = flags.or(FConst.Const)
+
+ return flags
+ }
+
+ ** Calculate method flags - uses canonical FConst values from compiler/fan/fcode/FConst.fan
+ private Int methodFlags(MethodDef m)
+ {
+ flags := 0
+ if (m.isPublic) flags = flags.or(FConst.Public)
+ if (m.isPrivate) flags = flags.or(FConst.Private)
+ if (m.isProtected) flags = flags.or(FConst.Protected)
+ if (m.isInternal) flags = flags.or(FConst.Internal)
+ if (m.isCtor) flags = flags.or(FConst.Ctor)
+ if (m.isStatic) flags = flags.or(FConst.Static)
+ if (m.isVirtual) flags = flags.or(FConst.Virtual)
+ if (m.isAbstract) flags = flags.or(FConst.Abstract)
+ if (m.isOverride) flags = flags.or(FConst.Override)
+ return flags
+ }
+
+ ** Calculate type flags - uses canonical FConst values from compiler/fan/fcode/FConst.fan
+ private Int typeFlags(TypeDef t)
+ {
+ flags := 0
+ if (t.isPublic) flags = flags.or(FConst.Public)
+ if (t.isInternal) flags = flags.or(FConst.Internal)
+ if (t.isAbstract) flags = flags.or(FConst.Abstract)
+ if (t.isConst) flags = flags.or(FConst.Const)
+ if (t.isFinal) flags = flags.or(FConst.Final)
+ if (t.isMixin) flags = flags.or(FConst.Mixin)
+ if (t.isEnum) flags = flags.or(FConst.Enum)
+ if (t.isFacet) flags = flags.or(FConst.Facet)
+ return flags
+ }
+
+ ** Check if a statement assigns to an enum value field
+ ** Used to filter out enum value initialization from _static_init() since
+ ** enum values are handled by vals()/_make_enum() instead
+ private Bool isEnumFieldAssignment(Stmt s, Str:Bool enumFieldNames)
+ {
+ // Check for expression statement with assignment
+ if (s.id != StmtId.expr) return false
+ exprStmt := s as ExprStmt
+ if (exprStmt.expr.id != ExprId.assign) return false
+
+ // Check if LHS is a field reference
+ assignExpr := exprStmt.expr as BinaryExpr
+ if (assignExpr.lhs.id != ExprId.field) return false
+
+ fieldExpr := assignExpr.lhs as FieldExpr
+ if (!fieldExpr.field.isStatic) return false
+
+ // Check if it's an enum field
+ return enumFieldNames.containsKey(fieldExpr.field.name)
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Python Type Hints
+//////////////////////////////////////////////////////////////////////////
+
+ ** Convert Fantom type to Python type hint string
+ ** Returns a forward-reference string in quotes like 'Optional[Unit]'
+ ** All type hints use forward references (strings) to avoid import order issues
+ Str pyTypeHint(CType t)
+ {
+ // Build the type hint string without quotes, then wrap in quotes at the end
+ inner := pyTypeHintInner(t)
+ // None is a special case - don't quote it
+ if (inner == "None") return "None"
+ return "'${inner}'"
+ }
+
+ ** Inner type hint builder - returns unquoted type string
+ private Str pyTypeHintInner(CType t)
+ {
+ // Handle nullable types -> Optional[T]
+ if (t.isNullable)
+ {
+ inner := pyTypeHintInner(t.toNonNullable)
+ return "Optional[${inner}]"
+ }
+
+ sig := t.signature
+
+ // Map Fantom primitives to Python built-in types
+ if (sig == "sys::Bool") return "bool"
+ if (sig == "sys::Int") return "int"
+ if (sig == "sys::Float") return "float"
+ if (sig == "sys::Str") return "str"
+ if (sig == "sys::Void") return "None"
+ if (sig == "sys::Obj") return "Obj"
+ if (sig == "sys::This") return "Self" // Python 3.11+ Self type
+
+ // Handle List[T] - sys::Obj?[] -> List[Obj]
+ if (t.isList)
+ {
+ // Get the value type (V in List)
+ listType := t as ListType
+ if (listType != null)
+ {
+ inner := pyTypeHintInner(listType.v)
+ return "List[${inner}]"
+ }
+ return "List"
+ }
+
+ // Handle Map[K,V] - [Str:Int] -> Dict[str, int]
+ if (t.isMap)
+ {
+ mapType := t as MapType
+ if (mapType != null)
+ {
+ k := pyTypeHintInner(mapType.k)
+ v := pyTypeHintInner(mapType.v)
+ return "Dict[${k}, ${v}]"
+ }
+ return "Dict"
+ }
+
+ // Handle Func types -> Callable
+ if (t.isFunc)
+ {
+ funcType := t as FuncType
+ if (funcType != null)
+ {
+ // Build Callable[[Param1, Param2], ReturnType]
+ params := StrBuf()
+ params.addChar('[')
+ funcType.params.each |p, i|
+ {
+ if (i > 0) params.add(", ")
+ params.add(pyTypeHintInner(p))
+ }
+ params.addChar(']')
+ retType := pyTypeHintInner(funcType.returns)
+ return "Callable[${params.toStr}, ${retType}]"
+ }
+ return "Callable"
+ }
+
+ // Default: use simple class name
+ return t.name
+ }
+
+}
diff --git a/src/fanc/fan/py/PyUtil.fan b/src/fanc/fan/py/PyUtil.fan
new file mode 100644
index 000000000..4bd849144
--- /dev/null
+++ b/src/fanc/fan/py/PyUtil.fan
@@ -0,0 +1,237 @@
+//
+// Copyright (c) 2025, Brian Frank and Andy Frank
+// Licensed under the Academic Free License version 3.0
+//
+// History:
+// 25 Feb 2026 Trevor Adelman Creation
+//
+
+using compiler
+
+**
+** Python transpiler utilities
+**
+class PyUtil
+{
+ ** Python reserved words that are used as pod names and need directory escaping
+ static const Str[] reservedPodNames := ["def", "class", "import", "from", "if", "else",
+ "for", "while", "try", "except", "finally",
+ "with", "as", "in", "is", "not", "and", "or",
+ "True", "False", "None", "lambda", "return",
+ "yield", "raise", "pass", "break", "continue",
+ "global", "nonlocal", "async", "await"]
+
+ ** Escape pod name if it conflicts with Python reserved words
+ static Str escapePodName(Str podName)
+ {
+ reservedPodNames.contains(podName) ? "${podName}_" : podName
+ }
+
+ ** Python keywords that cannot be used as class names or attribute accesses
+ static const Str[] reservedTypeNames := ["None", "True", "False"]
+
+ ** Escape type name if it conflicts with Python keywords
+ ** e.g., xeto::None -> None_ (because "class None" and ".None" are syntax errors)
+ static Str escapeTypeName(Str typeName)
+ {
+ reservedTypeNames.contains(typeName) ? "${typeName}_" : typeName
+ }
+
+ ** Get output file for a type
+ ** Uses fan/{podName}/ namespace to avoid Python built-in conflicts
+ static File typeFile(File outDir, TypeDef t)
+ {
+ escapedPod := escapePodName(t.pod.name)
+ escapedType := escapeTypeName(t.name)
+ return outDir + `fan/${escapedPod}/${escapedType}.py`
+ }
+
+ ** Get output directory for a pod
+ ** Uses fan/{podName}/ namespace to avoid Python built-in conflicts
+ static File podDir(File outDir, Str podName)
+ {
+ escapedPod := escapePodName(podName)
+ return outDir + `fan/${escapedPod}/`
+ }
+
+ ** Convert pod name to Python import path
+ ** e.g., "sys" -> "fan.sys", "testSys" -> "fan.testSys", "def" -> "fan.def_"
+ static Str podImport(Str podName)
+ {
+ escapedPod := escapePodName(podName)
+ return "fan.${escapedPod}"
+ }
+
+ ** Check if a type signature is a Java FFI type
+ ** e.g., "[java]java.lang.management::ThreadMXBean"
+ static Bool isJavaFfi(Str? name)
+ {
+ if (name == null) return false
+ return name.contains("[java]")
+ }
+
+ ** Sanitize Java FFI type references for Python
+ ** In JS transpiler, these become parseable but fail at runtime if invoked
+ ** For Python, we use a similar pattern: [java]x.y -> java_ffi_fail.x.y
+ static Str sanitizeJavaFfi(Str name)
+ {
+ if (name.contains(".[java].")) return name.replace(".[java].", ".")
+ if (name.contains("[java]")) return name.replace("[java]", "java_ffi_fail.")
+ return name
+ }
+
+ ** Python reserved words that need to be escaped
+ static const Str:Str reservedWords
+ static
+ {
+ m := Str:Str[:]
+ // Python keywords
+ [
+ "False", "None", "True", "and", "as", "assert", "async", "await",
+ "break", "class", "continue", "def", "del", "elif", "else", "except",
+ "finally", "for", "from", "global", "if", "import", "in", "is",
+ "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try",
+ "while", "with", "yield", "match", "case",
+ // Built-in functions that could conflict
+ "type", "hash", "id", "list", "map", "str", "int", "float", "bool",
+ "self",
+ // Additional builtins that conflict with Fantom method names
+ "abs", "all", "any", "min", "max", "pow", "round", "set", "dir",
+ "oct", "open", "vars", "print",
+ // Module name that conflicts with pod namespace import (from fan import sys)
+ "sys"
+ ].each |name| { m[name] = "${name}_" }
+ reservedWords = m.toImmutable
+ }
+
+ ** Convert camelCase to snake_case
+ ** Examples:
+ ** toStr -> to_str
+ ** isEmpty -> is_empty
+ ** XMLParser -> xml_parser
+ ** getHTTPResponse -> get_http_response
+ ** utf16BE -> utf16_be
+ ** toBase64Uri -> to_base64_uri
+ static Str toSnakeCase(Str name)
+ {
+ // Fast path: if all lowercase and no uppercase, return as-is
+ hasUpper := false
+ name.each |ch| { if (ch.isUpper) hasUpper = true }
+ if (!hasUpper) return name
+
+ buf := StrBuf()
+ prev := 0
+ name.each |ch, i|
+ {
+ if (ch.isUpper)
+ {
+ // Check if this is start of acronym or end of acronym
+ next := (i + 1 < name.size) ? name[i + 1] : 0
+ prevIsLower := prev.isLower
+ prevIsDigit := prev.isDigit
+ nextIsLower := next != 0 && next.isLower
+
+ // Add underscore before uppercase if:
+ // 1. Previous char was lowercase (camelCase boundary): toStr -> to_str
+ // 2. We're in an acronym and next char is lowercase (end of acronym): XMLParser -> xml_parser
+ // 3. Previous char was a digit (number to uppercase): utf16BE -> utf16_be
+ if (i > 0 && (prevIsLower || prevIsDigit || (prev.isUpper && nextIsLower)))
+ {
+ buf.addChar('_')
+ }
+ buf.addChar(ch.lower)
+ }
+ else
+ {
+ buf.addChar(ch)
+ }
+ prev = ch
+ }
+ return buf.toStr
+ }
+
+ ** Escape Python reserved words and invalid characters
+ ** Also converts camelCase to snake_case for Pythonic naming
+ static Str escapeName(Str name)
+ {
+ // First replace $ with _ (Fantom synthetic names use $)
+ escaped := name.replace("\$", "_")
+ // Convert camelCase to snake_case
+ snake := toSnakeCase(escaped)
+ // Then check for reserved words
+ return reservedWords.get(snake, snake)
+ }
+
+ ** Convert Fantom boolean literal to Python
+ static Str boolLiteral(Bool val)
+ {
+ val ? "True" : "False"
+ }
+
+ ** Convert Fantom null literal to Python
+ static Str nullLiteral()
+ {
+ "None"
+ }
+
+ ** Is this a native Python type (uses static method dispatch)
+ static Bool isPyNative(CType t)
+ {
+ t.isObj || t.isStr || t.isVal
+ }
+
+ ** Map of method qname to unary operators
+ static once Str:Str unaryOperators()
+ {
+ [
+ "sys::Bool.not": "not ",
+ "sys::Int.negate": "-",
+ "sys::Float.negate": "-",
+ ].toImmutable
+ }
+
+ ** Map of method qname to binary operators
+ static once Str:Str binaryOperators()
+ {
+ [
+ "sys::Str.plus": "+",
+
+ "sys::Int.plus": "+",
+ "sys::Int.minus": "-",
+ "sys::Int.mult": "*",
+ // Int.div intentionally NOT mapped - Python // has floor division semantics
+ // but Fantom uses truncated division (toward zero)
+ // Handled by ObjUtil.div() in PyExprPrinter.divOp()
+ // Int.mod intentionally NOT mapped - Python % has different semantics
+ // for negative numbers (floor division vs truncated division)
+ // Handled by ObjUtil.mod() in PyExprPrinter.modOp()
+ "sys::Int.plusFloat": "+",
+ "sys::Int.minusFloat": "-",
+ "sys::Int.multFloat": "*",
+ "sys::Int.divFloat": "/",
+
+ "sys::Float.plus": "+",
+ "sys::Float.minus": "-",
+ "sys::Float.mult": "*",
+ "sys::Float.div": "/",
+ "sys::Float.plusInt": "+",
+ "sys::Float.minusInt": "-",
+ "sys::Float.multInt": "*",
+ "sys::Float.divInt": "/",
+ ].toImmutable
+ }
+
+ ** The instance side method name for a constructor
+ static Str ctorImplName(CMethod x)
+ {
+ "${x.name}_init_"
+ }
+
+ ** Handle special method names
+ static Str methodName(CMethod x)
+ {
+ n := x.name
+ if (n.startsWith("instance\$init\$")) return "instance_init_"
+ return escapeName(n)
+ }
+}
diff --git a/src/fanc/fan/py/PythonCmd.fan b/src/fanc/fan/py/PythonCmd.fan
new file mode 100644
index 000000000..f11e2e28a
--- /dev/null
+++ b/src/fanc/fan/py/PythonCmd.fan
@@ -0,0 +1,469 @@
+//
+// Copyright (c) 2025, Brian Frank and Andy Frank
+// Licensed under the Academic Free License version 3.0
+//
+// History:
+// 25 Feb 2026 Trevor Adelman Creation
+//
+
+using build
+using compiler
+using util
+
+**
+** Python transpiler command
+**
+internal class PythonCmd : TranspileCmd
+{
+ override Str name() { "py" }
+
+ override Str summary() { "Transpile to Python" }
+
+ override Int usage(OutStream out := Env.cur.out)
+ {
+ ret := super.usage(out)
+ out.printLine("Examples:")
+ out.printLine(" fanc py foo // generate Python source for 'foo' pod and its depends")
+ return ret
+ }
+
+ override Void genPod(PodDef pod)
+ {
+ // Create fan/__init__.py namespace package if it doesn't exist
+ fanDir := outDir + `fan/`
+ fanDir.create
+ fanInit := fanDir + `__init__.py`
+ if (!fanInit.exists)
+ {
+ fanInit.out.printLine("# Fantom Python namespace package").printLine("# Auto-generated by fanc py").close
+ }
+
+ // Create lazy loader __init__.py for the pod package
+ initFile := PyUtil.podDir(outDir, pod.name) + `__init__.py`
+ initFile.parent.create
+ initOut := initFile.out
+
+ // Check for hand-written natives directory
+ nativeDir := findNativeDir(pod.name)
+
+ // Generate all types and collect names for lazy loader in one pass
+ typeNames := Str[,]
+ pod.typeDefs.each |t|
+ {
+ if (t.isSynthetic) return
+ genTypeWithNative(t, nativeDir)
+ typeNames.add(PyUtil.escapeTypeName(t.name))
+ }
+
+ // Also include extra native types (ObjUtil, etc.)
+ if (nativeDir != null)
+ {
+ nativeDir.list.each |f|
+ {
+ if (f.ext != "py") return
+ typeName := PyUtil.escapeTypeName(f.basename)
+ if (!typeNames.contains(typeName))
+ typeNames.add(typeName)
+ }
+ }
+
+ // Write lazy loader __init__.py
+ writeLazyLoader(initOut, pod.name, typeNames)
+ initOut.close
+
+ // Copy runtime files (fanx module) for sys pod
+ if (pod.name == "sys")
+ {
+ copyRuntime(pod.name)
+ }
+
+ // Copy any extra native files that don't have corresponding types
+ // (e.g., ObjUtil.py, SyntheticFile.py, etc.)
+ if (nativeDir != null)
+ {
+ copyExtraNatives(pod, nativeDir)
+ }
+
+ // Extract web assets (JS, CSS, resources) from the pre-built pod file
+ // Assets go into fan/_assets/ to keep them co-located with the serving code
+ extractAssets(pod.name)
+ }
+
+//////////////////////////////////////////////////////////////////////////
+// Asset Extraction
+//////////////////////////////////////////////////////////////////////////
+
+ **
+ ** Extract all web assets (JS, CSS, resources) from the pre-built pod file.
+ ** Assets are placed in fan/_assets/ following Python packaging best practices,
+ ** keeping static files co-located with the code that serves them (fan/web/FilePack.py).
+ **
+ ** Package Size Notes:
+ ** -------------------
+ ** Current extraction includes ALL JS files, resulting in ~10MB uncompressed assets.
+ ** For production releases, consider adding a flag to skip test JS files:
+ **
+ ** test*.js files (~2.6MB uncompressed):
+ ** - testSys.js (1.4MB) - Fantom sys tests for browser JS runtime
+ ** - testXeto.js, testHaystack.js, testAxon.js, testDomkit.js, testGraphics.js
+ **
+ ** These test JS files are for browser-based testing of the JavaScript runtime,
+ ** not for testing the Python transpilation. To reduce wheel size for production:
+ **
+ ** 1. Add a --production flag to fanc py
+ ** 2. In extractJs(), skip files where pod.name.startsWith("test")
+ ** 3. This would reduce the wheel from ~2.2MB to ~1.4MB compressed
+ **
+ private Void extractAssets(Str podName)
+ {
+ podObj := Pod.find(podName, false)
+ if (podObj == null) return
+
+ extractJs(podObj)
+ extractResources(podObj)
+ }
+
+ ** Extract JavaScript and source maps from pod
+ private Void extractJs(Pod pod)
+ {
+ // Place JS in fan/_assets/js/ (co-located with fan/web/FilePack.py)
+ jsDir := outDir + `fan/_assets/js/`
+ jsDir.create
+
+ // Try ES6 first (/js/{pod}.js), then legacy (/{pod}.js)
+ jsFile := pod.file(`/js/${pod.name}.js`, false)
+ ?: pod.file(`/${pod.name}.js`, false)
+ if (jsFile != null)
+ {
+ outFile := jsDir + `${pod.name}.js`
+ jsFile.copyTo(outFile, ["overwrite": true])
+ info(" Extracted ${pod.name}.js")
+ }
+
+ // Also extract fan.js for sys pod (ES6 module system bootstrap)
+ if (pod.name == "sys")
+ {
+ fanJs := pod.file(`/js/fan.js`, false)
+ if (fanJs != null)
+ {
+ outFile := jsDir + `fan.js`
+ fanJs.copyTo(outFile, ["overwrite": true])
+ info(" Extracted fan.js")
+ }
+ }
+
+ // Source map (try ES6 location first, then legacy)
+ mapFile := pod.file(`/js/${pod.name}.js.map`, false)
+ ?: pod.file(`/${pod.name}.js.map`, false)
+ if (mapFile != null)
+ {
+ outFile := jsDir + `${pod.name}.js.map`
+ mapFile.copyTo(outFile, ["overwrite": true])
+ }
+ }
+
+ ** Extract all resources (CSS, images, data files) from pod
+ private Void extractResources(Pod pod)
+ {
+ // Place resources in fan/_assets/res/{podName}/ to avoid collisions
+ // between pods that have files with the same name (e.g., style.css)
+ resDir := outDir + `fan/_assets/res/${pod.name}/`
+
+ // Get all files in the pod and filter for res/
+ // URIs are like: fan://podName/res/css/file.css
+ pod.files.each |file|
+ {
+ // Get the path part of the URI (e.g., "/res/css/domkit.css")
+ path := file.uri.pathStr
+ if (path.startsWith("/res/"))
+ {
+ // Preserve directory structure under res/{podName}/
+ relPath := path[5..-1] // Strip "/res/"
+ outFile := resDir + relPath.toUri
+ outFile.parent.create
+ file.copyTo(outFile, ["overwrite": true])
+
+ // Log CSS files specifically
+ if (file.ext == "css")
+ info(" Extracted ${pod.name}/${file.name}")
+ }
+ }
+ }
+
+ ** Copy native files that don't have corresponding type definitions
+ ** These are utility modules like ObjUtil.py that are hand-written only
+ private Void copyExtraNatives(PodDef pod, File nativeDir)
+ {
+ // Get set of type names that have type definitions (escaped for Python keyword safety)
+ typeNames := Str:Bool[:]
+ pod.typeDefs.each |t| { typeNames[PyUtil.escapeTypeName(t.name)] = true }
+
+ // Copy any .py files that don't have corresponding types
+ nativeDir.list.each |f|
+ {
+ if (f.ext != "py") return
+ typeName := f.basename
+
+ // Special handling for __init__.py: prepend native content to the
+ // generated lazy loader rather than overwriting it. The lazy loader
+ // contains the _types dict needed by Pod.types() for type discovery.
+ if (typeName == "__init__")
+ {
+ initFile := PyUtil.podDir(outDir, pod.name) + `__init__.py`
+ existingContent := initFile.readAllStr
+ nativeContent := f.readAllStr
+ out := initFile.out
+ try
+ {
+ out.print(nativeContent)
+ out.printLine("")
+ out.printLine("# ---- Generated lazy loader (do not edit below) ----")
+ out.printLine("")
+ out.print(existingContent)
+ }
+ finally out.close
+ return
+ }
+
+ if (typeNames[PyUtil.escapeTypeName(typeName)] != true)
+ {
+ // This is an extra native file - copy it directly
+ outFile := PyUtil.podDir(outDir, pod.name) + `${f.name}`
+ f.copyTo(outFile, ["overwrite": true])
+ }
+ }
+ }
+
+ ** Find the native Python directory for a pod
+ ** Uses pyFiles from compiler input (via dynamic access for bootstrap compatibility)
+ private File? findNativeDir(Str podName)
+ {
+ // Use pyFiles from compiler input (dynamic access for bootstrap compatibility)
+ try
+ {
+ input := compiler?.input
+ pyFiles := input?->pyFiles as Uri[]
+ if (pyFiles != null && !pyFiles.isEmpty)
+ {
+ baseDir := input.baseDir
+ // pyFiles are relative paths like `py/`
+ // Return the first one that exists
+ return pyFiles.eachWhile |uri|
+ {
+ dir := baseDir.plus(uri)
+ return dir.exists ? dir : null
+ }
+ }
+ }
+ catch (Err e) { /* pyFiles not available in bootstrap - use fallback */ }
+
+ // Fallback: look in standard locations
+ // Try py/fan/ first (sys pod pattern), then py/ (other pods)
+ File? result := null
+ Env.cur.path.each |path|
+ {
+ if (result != null) return
+
+ // Try py/fan/ (sys pod)
+ nativeDir := path + `src/${podName}/py/fan/`
+ if (nativeDir.exists)
+ {
+ result = nativeDir
+ return
+ }
+
+ // Try py/ directly (concurrent, util, etc.)
+ nativeDir = path + `src/${podName}/py/`
+ if (nativeDir.exists)
+ result = nativeDir
+ }
+ return result
+ }
+
+ ** Generate a type, merging with native if available
+ private Void genTypeWithNative(TypeDef t, File? nativeDir)
+ {
+ outFile := PyUtil.typeFile(outDir, t)
+ outFile.parent.create
+
+ // Check for hand-written native file
+ nativeFile := nativeDir != null ? nativeDir + `${t.name}.py` : null
+
+ if (nativeFile != null && nativeFile.exists)
+ {
+ // Merge: native file + transpiled metadata
+ mergeNativeWithMetadata(t, nativeFile, outFile)
+ }
+ else
+ {
+ // No native - use fully transpiled output
+ out := outFile.out
+ try
+ {
+ PyTypePrinter(out).type(t)
+ }
+ finally
+ {
+ out.close
+ }
+ }
+ }
+
+ ** Merge hand-written native file with transpiled metadata
+ private Void mergeNativeWithMetadata(TypeDef t, File nativeFile, File outFile)
+ {
+ // First generate the transpiled output to a buffer to extract metadata
+ buf := StrBuf()
+ PyTypePrinter(buf.out).type(t)
+ transpiled := buf.toStr
+
+ // Extract metadata section (# Type metadata... to end of file)
+ metadataStart := transpiled.index("# Type metadata")
+ metadata := metadataStart != null ? transpiled[metadataStart..-1] : null
+
+ // Write merged output
+ out := outFile.out
+ try
+ {
+ // Copy native file content
+ out.print(nativeFile.readAllStr)
+
+ // Append metadata if we have it
+ if (metadata != null)
+ {
+ out.printLine("")
+ // For sys pod: metadata uses bare Type.find() since Type is in the same pod
+ // For other pods: metadata uses sys.Type.find() via qualified import
+ if (t.pod.name == "sys")
+ out.printLine("from fan.sys.Type import Type")
+ else
+ out.printLine("import fan.sys as sys")
+ out.print(metadata)
+ }
+ }
+ finally
+ {
+ out.close
+ }
+ }
+
+ ** Copy runtime files (fanx module, etc.)
+ private Void copyRuntime(Str podName)
+ {
+ // Look for fanx module in the pod's source tree
+ Env.cur.path.each |path|
+ {
+ fanxDir := path + `src/${podName}/py/fanx/`
+ if (fanxDir.exists)
+ {
+ // Create output fanx directory
+ outFanxDir := outDir + `fanx/`
+ outFanxDir.create
+
+ // Copy all .py files from fanx module
+ fanxDir.list.each |f|
+ {
+ if (f.ext == "py")
+ {
+ outFile := outFanxDir + `${f.name}`
+ f.copyTo(outFile, ["overwrite": true])
+ }
+ }
+ return
+ }
+ }
+ }
+
+ ** Legacy method - kept for compatibility but not used
+ override Void genType(TypeDef t)
+ {
+ if (t.isSynthetic) return
+
+ outFile := PyUtil.typeFile(outDir, t)
+ outFile.parent.create
+
+ out := outFile.out
+ try
+ {
+ PyTypePrinter(out).type(t)
+ }
+ finally
+ {
+ out.close
+ }
+ }
+
+ ** Write lazy loader __init__.py for a pod
+ ** This creates a module that lazily imports types on first access,
+ ** avoiding circular import issues and reducing startup time.
+ ** Uses module-level __getattr__ (Python 3.7+) to preserve package semantics.
+ private Void writeLazyLoader(OutStream out, Str podName, Str[] typeNames)
+ {
+ // Build the _types dict entries
+ typesDict := StrBuf()
+ typeNames.each |name, i|
+ {
+ comma := i < typeNames.size - 1 ? "," : ""
+ typesDict.add(" '${name}': '${name}'${comma}\n")
+ }
+
+ // Template with {{POD}} and {{TYPES_DICT}} placeholders.
+ // Reads as the actual Python output for easy maintenance.
+ // Content is left-aligned because Str<|...|> preserves whitespace literally.
+ template :=
+ Str<|"""
+ {{POD}} pod - Lazy loader module
+ Auto-generated by fanc py
+
+ This module lazily imports types on first access to avoid circular imports
+ and reduce startup time. Access types via: from fan import {{POD}}; {{POD}}.TypeName
+ """
+
+ import importlib
+
+ # Cache for loaded types
+ _cache = {}
+ _loading = set() # Prevent circular import loops
+
+ # Known types in this pod
+ _types = {
+ {{TYPES_DICT}}}
+
+ def __getattr__(name):
+ """Module-level __getattr__ for lazy type loading (Python 3.7+)."""
+ if name.startswith('_'):
+ raise AttributeError(name)
+
+ # Check cache first
+ if name in _cache:
+ return _cache[name]
+
+ # Prevent circular imports
+ if name in _loading:
+ return None # Return None during circular load
+
+ # Check if this is a known type
+ if name not in _types:
+ raise AttributeError(f"module 'fan.{{POD}}' has no attribute '{name}'")
+
+ _loading.add(name)
+ try:
+ # Import the module
+ module = importlib.import_module(f'fan.{{POD}}.{name}')
+ # Get the class from the module
+ cls = getattr(module, name, None)
+ if cls is not None:
+ _cache[name] = cls
+ return cls
+ raise AttributeError(f"fan.{{POD}}.{name} has no class {name}")
+ finally:
+ _loading.discard(name)
+
+ def __dir__():
+ """Return list of available types for IDE completion."""
+ return list(_types.keys())
+ |>
+
+ out.print(template.replace("{{POD}}", podName).replace("{{TYPES_DICT}}", typesDict.toStr))
+ }
+}
diff --git a/src/fanc/fan/py/design.md b/src/fanc/fan/py/design.md
new file mode 100644
index 000000000..6f95ee9fe
--- /dev/null
+++ b/src/fanc/fan/py/design.md
@@ -0,0 +1,1074 @@
+This is a "living" document covering many aspects of the design and implementation
+for the mapping from Fantom to Python.
+
+---
+
+## Table of Contents
+
+1. [Getting Started](#getting-started) - Usage and build integration
+2. [Porting Native Code](#porting-native-code) - Adding Python natives to a pod
+3. [Design](#design) - How Fantom constructs map to Python
+ - [Python Classes](#python-classes)
+ - [Fields](#fields) / [Static Fields](#static-fields)
+ - [Enums](#enums)
+ - [Funcs and Closures](#funcs-and-closures)
+ - [Primitives](#primitives)
+ - [List and Map Architecture](#list-and-map-architecture)
+ - [Import Architecture](#import-architecture)
+ - [Type Metadata](#type-metadata-reflection)
+ - [Generated Code Conventions](#generated-code-conventions)
+ - [ObjUtil Helper Methods](#objutil-helper-methods)
+ - [Exception Mapping](#exception-mapping)
+4. [Naming](#naming) - Identifier conventions (snake_case, escaping)
+5. [Python-Specific Considerations](#python-specific-considerations) - GIL, overloading, type hints
+6. [Performance Optimizations](#performance-optimizations) - Runtime caching strategies
+
+---
+
+## How to Read This Document
+
+**If you want to understand what Python code gets generated:**
+- Start with [Design](#design) for the core patterns (classes, fields, closures)
+- See [Naming](#naming) for how identifiers are transformed (snake_case, escaping)
+- Check [Generated Code Conventions](#generated-code-conventions) for internal naming patterns
+
+**If you want to understand the runtime:**
+- [ObjUtil Helper Methods](#objutil-helper-methods) documents the contract between transpiled code and runtime
+- [List and Map Architecture](#list-and-map-architecture) explains collection design
+- [Performance Optimizations](#performance-optimizations) covers caching strategies
+
+**If you're debugging generated code:**
+- [Generated Code Conventions](#generated-code-conventions) explains patterns like `_closure_N`, `_switch_N`
+- [Import Architecture](#import-architecture) explains why imports are structured the way they are
+- [Exception Mapping](#exception-mapping) shows how catch clauses are generated
+
+**If you're adding Python support to a pod:**
+- Start with [Getting Started](#getting-started) for build integration
+- See [Porting Native Code](#porting-native-code) for the process
+- Reference [Design](#design) for patterns to follow
+
+**Key Insight:** The transpiler generates Python code that calls `ObjUtil` and `Func` methods.
+The [ObjUtil Helper Methods](#objutil-helper-methods) section documents this contract - if a
+method is listed there, the transpiler generates calls to it and the runtime must implement it.
+
+---
+
+# Getting Started
+
+The Python implementation of the `sys` pod is in `src/sys/py/fan/`. Unlike JavaScript
+which bundles into a single file, Python uses individual `.py` files that match Python's
+module import system.
+
+The Python transpiler lives in the `fanc` pod at `src/fanc/fan/py/`. It generates Python
+code from Fantom source and outputs to `gen/py/`.
+
+## fanc py (Python Transpiler)
+
+The Python transpiler is invoked via `fanc py `. It serves the same purpose as
+`compilerEs` for JavaScript, but emits Python 3 code.
+
+```bash
+# Transpile testSys pod to Python
+fanc py testSys
+
+# Transpile haystack pod
+fanc py haystack
+```
+
+Generated code is output to `/gen/py/fan//` (where `work_dir` is
+typically `fan_home`, determined by `Env.cur.workDir`).
+
+## Build Integration (pyDirs/pyFiles)
+
+The Python transpiler is integrated into Fantom's build system using the same pattern as
+JavaScript's `jsDirs`:
+
+### BuildPod.fan
+Pods declare native Python directories in their `build.fan`:
+
+```fantom
+class Build : BuildPod
+{
+ pyDirs = [`py/`] // Directory containing Python natives
+}
+```
+
+### CompilerInput.fan
+The `pyFiles` field passes resolved native directories to the compiler:
+
+```fantom
+class CompilerInput
+{
+ Uri[]? pyFiles // Resolved Python native directories
+}
+```
+
+### PythonCmd.fan
+The transpiler reads `pyFiles` from the compiler input:
+
+```fantom
+// Uses compiler.input.pyFiles to find native directory
+pyFiles := compiler?.input?.pyFiles
+```
+
+### Native Directory Locations
+
+| Pod | Native Directory | build.fan |
+|-----|------------------|-----------|
+| sys | `src/sys/py/fan/` | (special handling) |
+| concurrent | `src/concurrent/py/` | `pyDirs = [\`py/\`]` |
+| util | `src/util/py/` | `pyDirs = [\`py/\`]` |
+| crypto | `src/crypto/py/` | `pyDirs = [\`py/\`]` |
+
+The sys pod uses `py/fan/` to match its source structure. Other pods use `py/` directly.
+
+## Native Code Merging
+
+When transpiling, the Python transpiler:
+1. Checks if a hand-written native file exists in the pod's `py/` directory
+2. If found, copies the native file and appends type metadata from the transpiled output
+3. If not found, uses the fully transpiled output
+
+This allows hand-written runtime code (like `Actor.py`, `List.py`) to coexist with
+transpiled code, similar to how JavaScript natives work.
+
+## Standard Build (Packaging into Pods)
+
+To package Python natives into `.pod` files (for distribution):
+
+```bash
+fan src/sys/py/build.fan compile # Package natives into sys.pod
+fan src/concurrent/py/build.fan compile # Package into concurrent.pod
+fan src/util/py/build.fan compile # Package into util.pod
+```
+
+This packages Python natives inside `.pod` files at `/py/fan//`, matching the
+JavaScript pattern of `/esm/` and `/cjs/` directories.
+
+# Porting Native Code
+
+If you have a pod with native Python implementations, follow these steps:
+
+1. Create a `/py/` directory in the root of your pod (peer to any existing `/js/` or `/es/`)
+2. For `sys` pod specifically, create `/py/fan/` subdirectory (matches import path)
+3. Port native code into this directory following the patterns below
+4. Use the existing code in `sys/py/fan/` or `concurrent/py/` as reference
+
+Native files are named to match the Fantom type they implement (e.g., `List.py` for `sys::List`).
+
+## Native `__init__.py` Merging
+
+Each pod gets a generated `__init__.py` containing a lazy loader with a `_types` dict
+that `Pod.types()` uses for type discovery. Some pods also have a hand-written
+`__init__.py` in their native directory with module initialization code (import caching,
+eager imports, etc.).
+
+When a native `__init__.py` exists, the transpiler **merges** it with the generated
+lazy loader: native content is prepended, generated content is appended. This preserves
+both the native infrastructure and the `_types` registry.
+
+**Pods with native `__init__.py`:**
+
+| Pod | Purpose |
+|-----|---------|
+| sys | Import caching optimization, module class fix for submodule shadowing, eager base type imports |
+| concurrent | Eager imports of native types |
+| util | Eager imports of native types |
+| inet | Eager imports of native types |
+
+**Rules for native `__init__.py`:**
+- Keep module initialization code that genuinely needs to run at import time
+- Do NOT put monkey-patches for transpiled types here -- fix the transpiler or runtime instead
+- The `_types` dict and `__getattr__` lazy loader are always appended by the transpiler
+
+# Design
+
+This section details the design decisions and implementation patterns for Python code.
+
+## Python Classes
+
+All Fantom types are implemented as Python classes extending `Obj`.
+
+Fantom:
+```fantom
+class Foo { }
+```
+
+Python (simplified):
+```python
+from fan.sys.Obj import Obj
+
+class Foo(Obj):
+ def __init__(self):
+ super().__init__()
+```
+
+**Note:** The example above is simplified for clarity. Actual generated files include
+additional imports (`sys_module`, `ObjUtil`, pod namespace), a static `make()` factory
+method, and type metadata registration at the end. See [Import Architecture](#import-architecture)
+below for details on how imports are structured to avoid circular dependencies while
+maintaining a clean API.
+
+## Fields
+
+All Fantom fields are generated with private storage using the `_fieldName` convention.
+The compiler generates a combined getter/setter method using Python's optional parameter pattern.
+
+Fantom:
+```fantom
+class Foo
+{
+ Int a := 0
+ Int b := 1 { private set }
+ private Int c := 2
+}
+```
+
+Python:
+```python
+class Foo(Obj):
+ def __init__(self):
+ super().__init__()
+ self._a = 0
+ self._b = 1
+ self._c = 2
+
+ # Public getter/setter with type hints
+ def a(self, _val_: 'int' = None) -> 'int':
+ if _val_ is None:
+ return self._a
+ else:
+ self._a = _val_
+
+ # Public getter only
+ def b(self) -> 'int':
+ return self._b
+
+ # No method for _c (private getter/setter)
+```
+
+Usage:
+```python
+f = Foo()
+f.a(100) # Set
+print(f.a()) # Get: 100
+```
+
+Note: Unlike JavaScript's `#private` fields, Python uses the `_fieldName` convention by
+agreement rather than enforcement.
+
+## Static Fields
+
+Static fields use class-level storage with static getter methods. Lazy initialization
+follows the same pattern as JavaScript to handle circular dependencies.
+
+Fantom:
+```fantom
+class Foo
+{
+ static const Int max := 100
+}
+```
+
+Python:
+```python
+class Foo(Obj):
+ _max = None
+
+ @staticmethod
+ def max():
+ if Foo._max is None:
+ Foo._static_init()
+ return Foo._max
+
+ @staticmethod
+ def _static_init():
+ if hasattr(Foo, '_static_init_in_progress') and Foo._static_init_in_progress:
+ return
+ Foo._static_init_in_progress = True
+ Foo._max = 100
+ Foo._static_init_in_progress = False
+```
+
+## Enums
+
+Enums follow a factory pattern with lazy singleton initialization. Enum classes extend
+`sys::Enum` (not `Obj` directly).
+
+Fantom:
+```fantom
+enum class Color { red, green, blue }
+```
+
+Python:
+```python
+class Color(Enum):
+ _vals = None
+
+ @staticmethod
+ def red():
+ return Color.vals().get(0)
+
+ @staticmethod
+ def green():
+ return Color.vals().get(1)
+
+ @staticmethod
+ def blue():
+ return Color.vals().get(2)
+
+ @staticmethod
+ def vals():
+ if Color._vals is None:
+ # For non-sys pods, uses sys.List prefix
+ Color._vals = sys.List.to_immutable(sys.List.from_list([
+ Color._make_enum(0, "red"),
+ Color._make_enum(1, "green"),
+ Color._make_enum(2, "blue")
+ ]))
+ return Color._vals
+
+ @staticmethod
+ def _make_enum(_ordinal, _name):
+ inst = object.__new__(Color)
+ inst._ordinal = _ordinal
+ inst._name = _name
+ return inst
+
+ def ordinal(self):
+ return self._ordinal
+
+ def name(self):
+ return self._name
+```
+
+## Funcs and Closures
+
+Fantom closures are generated as Python lambdas wrapped in `Func.make_closure()` to provide
+Fantom's Func API (`bind()`, `params()`, `returns()`, etc.).
+
+Fantom:
+```fantom
+list.each |item, i| { echo(item) }
+```
+
+Python:
+```python
+List.each(list, Func.make_closure({
+ "returns": "sys::Void",
+ "params": [{"name": "item", "type": "sys::Obj"}, {"name": "i", "type": "sys::Int"}]
+}, (lambda item=None, i=None: print(item))))
+```
+
+For simple single-expression closures, the lambda is inlined. For multi-statement closures,
+a named function is emitted before the usage point.
+
+Unlike JavaScript where closures are invoked directly (`f(args)`), Python closures through
+`Func.make_closure()` are also callable directly - the wrapper implements `__call__`.
+
+### Closure Immutability
+
+When a closure needs to be made immutable (typically for Actor message passing), the runtime
+must create a "snapshot" of all captured values. This is handled by `Func.to_immutable()`.
+
+The transpiler analyzes each closure and sets an `immutable` case in the closure spec:
+- `"always"` - Closure captures only const types (already immutable)
+- `"never"` - Closure captures non-const types like `InStream` (cannot be made immutable)
+- `"maybe"` - Closure captures types that can be made immutable at runtime
+
+For the `"maybe"` case, `to_immutable()` uses Python's `types.CellType` (Python 3.8+) to
+create new closure cells with immutable copies of captured values:
+
+```python
+# Runtime code in Func.to_immutable()
+import types
+
+# Get the original function's closure cells
+for i, cell in enumerate(original_func.__closure__):
+ val = cell.cell_contents
+ immutable_val = ObjUtil.to_immutable(val) # Snapshot the value
+ new_cell = types.CellType(immutable_val) # Create new cell
+ immutable_cells.append(new_cell)
+
+# Create new function with new closure cells
+new_func = types.FunctionType(
+ original_func.__code__,
+ original_func.__globals__,
+ original_func.__name__,
+ original_func.__defaults__,
+ tuple(immutable_cells) # Attach new cells!
+)
+```
+
+This ensures that when a closure is sent to an Actor:
+1. Each captured variable gets its own frozen copy
+2. The original variables in the sending thread are unaffected
+3. No race conditions can occur on shared mutable state
+
+For closures using default parameter capture (`_outer=self` pattern), the same approach
+rebinds the `__defaults__` tuple with immutable copies.
+
+**Python version compatibility:** The `types.CellType` approach requires Python 3.8+. For
+older Python versions or closures without `__closure__`, the runtime falls back to rebinding
+`__defaults__` (for default parameter captures) or simply marking the closure as immutable
+if it has no captures. See `Func.to_immutable()` in `src/sys/py/fan/Func.py` for the full
+implementation.
+
+## Primitives
+
+Python's type system differs significantly from JavaScript's. Fantom primitives map as follows:
+
+| Fantom | Python | Notes |
+|--------|--------|-------|
+| `Int` | `int` | Python int is arbitrary precision |
+| `Float` | `float` | IEEE 754 double |
+| `Bool` | `bool` | `True`/`False` |
+| `Str` | `str` | Unicode string |
+| `Decimal` | `float` | Uses Python float (not decimal.Decimal) |
+
+Since Python primitives don't have methods, instance method calls on primitives are converted
+to static method calls:
+
+Fantom:
+```fantom
+x.toStr
+s.size
+```
+
+Python:
+```python
+Int.to_str(x)
+Str.size(s)
+```
+
+**Important:** `List` and `Map` are **NOT** primitives. They use normal instance method
+dispatch like any other Fantom class. This matches the JavaScript transpiler's design.
+
+The `ObjUtil` class provides dispatch for methods that may be called on any type (`equals`,
+`compare`, `hash`, `typeof`, etc.).
+
+## List and Map Architecture
+
+**List** extends `Obj` and implements Python's `MutableSequence` ABC:
+- Uses `self._values` for internal storage (not inheriting from Python list)
+- All methods are instance methods: `list.each(f)`, `list.map_(f)`, etc.
+- `isinstance(fantom_list, list)` returns `False`
+- Supports Python protocols: `len()`, `[]`, `in`, iteration
+
+**Map** extends `Obj` and implements Python's `MutableMapping` ABC:
+- Uses `self._map` for internal storage (not inheriting from Python dict)
+- All methods are instance methods: `map.get(k)`, `map.each(f)`, etc.
+- `isinstance(fantom_map, dict)` returns `False`
+- Supports Python protocols: `len()`, `[]`, `in`, iteration
+
+Fantom:
+```fantom
+list.each |item| { echo(item) }
+map.get("key")
+```
+
+Python:
+```python
+list.each(lambda item: print(item))
+map.get("key")
+```
+
+## Import Architecture
+
+Python's import system requires careful handling to avoid circular `ImportError` exceptions.
+The transpiler uses a **hybrid approach** combining lazy loader modules, namespace imports,
+and targeted direct imports.
+
+### Pod-Level Lazy Loaders
+
+Each pod gets a generated `__init__.py` that lazily imports types on first access:
+
+```python
+# fan/testSys/__init__.py (auto-generated)
+import importlib
+
+_cache = {}
+_loading = set() # Prevent circular import loops
+
+_types = {
+ 'BoolTest': 'BoolTest',
+ 'IntTest': 'IntTest',
+ # ... all types in pod
+}
+
+def __getattr__(name):
+ """Module-level __getattr__ for lazy type loading (Python 3.7+)."""
+ if name in _cache:
+ return _cache[name]
+ if name in _loading:
+ return None # Circular import protection
+
+ _loading.add(name)
+ try:
+ module = importlib.import_module(f'fan.testSys.{name}')
+ cls = getattr(module, name)
+ _cache[name] = cls
+ return cls
+ finally:
+ _loading.discard(name)
+```
+
+This allows code to reference types via `testSys.BoolTest` without importing all types
+at module initialization time.
+
+### Import Structure per File
+
+Each generated Python file has this import structure:
+
+```python
+# 1. System path setup
+import sys as sys_module
+sys_module.path.insert(0, '.')
+
+# 2. Type hints import
+from typing import Optional, Callable, List as TypingList, Dict as TypingDict
+
+# 3. Pod namespace import (for non-sys pods)
+from fan import sys
+
+# 4. Direct imports for class definition
+from fan.sys.Obj import Obj # Base class
+from fan.sys.ObjUtil import ObjUtil # Always needed
+from fan.somePod.SomeMixin import SomeMixin # Mixins
+
+# 5. Dependent pods as namespaces
+from fan import concurrent
+from fan import haystack
+
+# 6. Exception types for catch clauses (Python requires class in local scope)
+from fan.myPod.MyException import MyException
+```
+
+### Namespace-Qualified Type References
+
+In expressions, sys types are accessed via the `sys.` namespace prefix:
+
+```python
+# List literal
+sys.List.from_literal([1, 2, 3], 'sys::Int')
+
+# Map literal
+sys.Map.from_literal(['a'], [1], 'sys::Str', 'sys::Int')
+
+# Type literal
+sys.Type.find('sys::Bool')
+
+# Static method call
+sys.Int.from_str("42")
+```
+
+The transpiler automatically adds `sys.` prefix when:
+- The current type is NOT in the sys pod
+- The target type IS in the sys pod
+
+This eliminates the need for 30+ direct imports of sys types at the top of each file.
+
+### Same-Pod Type References
+
+For types in the same pod, dynamic imports avoid circular dependencies:
+
+```python
+# Direct reference would cause circular import:
+# from fan.testSys.ObjWrapper import ObjWrapper # BAD
+
+# Dynamic import at point of use:
+__import__('fan.testSys.ObjWrapper', fromlist=['ObjWrapper']).ObjWrapper
+```
+
+**Performance Note:** This pattern can result in millions of `__import__()` calls during
+heavy operations like xeto namespace creation. See [__import__() Caching](#__import__-caching)
+in Performance Optimizations for the runtime cache that makes this pattern efficient.
+
+### Cross-Pod Type References
+
+Cross-pod types use the namespace import pattern:
+
+```python
+# Pod imported as namespace at top of file
+from fan import haystack
+
+# Used in expressions
+haystack.Coord.make(lat, lng)
+```
+
+### Exception Types in Catch Clauses
+
+Python's `except` clause requires the exception class in local scope - namespace
+references don't work:
+
+```python
+# This DOES NOT work in Python:
+try:
+ ...
+except sys.ParseErr: # SyntaxError: invalid syntax
+ ...
+
+# Must have direct import:
+from fan.sys.ParseErr import ParseErr
+try:
+ ...
+except ParseErr: # Works
+ ...
+```
+
+The transpiler scans for catch clauses and generates direct imports for any exception
+types used. This is the one case where AST scanning is still required.
+
+### Why This Design?
+
+The import architecture balances several constraints:
+
+1. **Avoid circular imports** - Lazy loaders and dynamic imports prevent initialization loops
+2. **Minimize import lines** - Namespace imports instead of 30+ direct imports
+3. **Python language requirements** - Exception classes must be in local scope
+4. **Match JavaScript pattern** - Similar to JS pod bundling conceptually
+5. **Support reflection** - Type metadata uses lazy string resolution
+
+## Type Metadata (Reflection)
+
+Each transpiled type registers metadata for Fantom's reflection system using `af_()` for
+fields and `am_()` for methods. **Type signatures are stored as strings and lazily resolved
+on first access** to avoid circular imports during module initialization:
+
+```python
+# Type metadata registration - note: type signatures are STRINGS
+from fan.sys.Param import Param
+_t = Type.find('testSys::Foo')
+_t.af_('name', 1, 'sys::Str', {}) # 'sys::Str' is a string, not Type.find()
+_t.am_('doSomething', 1, 'sys::Void', [Param('arg', 'sys::Int', False)], {})
+
+# Type resolution happens lazily when reflection is used:
+# - Method.returns() resolves the return type string on first call
+# - Field.type() resolves the field type string on first call
+# - Param.type() resolves the param type string on first call
+```
+
+### Why Lazy Resolution?
+
+When Python imports a module, it executes all top-level code immediately. If `am_()` called
+`Type.find()` during module initialization, it would trigger cascading imports:
+
+```
+Str.py loads -> am_() calls Type.find('sys::Int[]') -> Int.py loads ->
+ am_() calls Type.find('sys::Float') -> Float.py loads ->
+ am_() calls Type.find('sys::Decimal') -> ... circular crash
+```
+
+By storing type signatures as strings and resolving them lazily on first access, the module
+initialization completes without triggering cross-type imports. This matches the JavaScript
+transpiler's approach and is critical for the runtime to load without circular import errors.
+
+## Generated Code Conventions
+
+The transpiler generates several internal names and patterns. These are implementation
+details but important for understanding generated code during debugging or review.
+
+### Variable Naming Patterns
+
+| Pattern | Purpose | Example |
+|---------|---------|---------|
+| `_closure_N` | Multi-statement closure functions | `def _closure_0(x=None): ...` |
+| `_switch_N` | Switch condition cache variable | `_switch_0 = condition` |
+| `_safe_` | Safe navigation lambda parameter | `(lambda _safe_: ... if _safe_ is not None else None)(target)` |
+| `_val_` | Field setter parameter | `def name(self, _val_=None):` |
+| `_old_x` | Post-increment/decrement temp | `((_old_x := x, x := x + 1, _old_x)[2])` |
+| `_self` | Outer self in multi-statement closures | `def _closure_0(..., _self=self):` |
+| `_outer` | Outer self in inline lambdas | `lambda x, _outer=self: ...` |
+
+### Sentinel Values
+
+| Value | Purpose | Location |
+|-------|---------|----------|
+| `"_once_"` | Uninitialized once field marker | `_fieldName_Store = "_once_"` |
+
+### Internal Methods
+
+| Method | Purpose |
+|--------|---------|
+| `_static_init()` | Initialize static fields (lazy) |
+| `_static_init_in_progress` | Re-entry guard for static init |
+| `_ctor_init()` | Initialize instance fields (for named ctors) |
+| `_make_enum(ordinal, name)` | Create enum instance |
+| `_ctorName_body(...)` | Named constructor body |
+
+### Why Closures Are Extracted
+
+Python lambdas cannot contain statements - only single expressions. When a Fantom closure
+contains multiple statements, local variable declarations, or control flow, the transpiler
+extracts it to a named `def` function emitted before the usage point:
+
+```python
+# Multi-statement closure: |x| { y := x + 1; return y * 2 }
+# Cannot be: lambda x: (y := x + 1, y * 2) # Invalid - walrus in tuple doesn't work right
+
+# Instead, extracted as:
+def _closure_0(x=None, _self=self):
+ y = x + 1
+ return y * 2
+
+# Used as:
+list.map_(_closure_0)
+```
+
+The closure is emitted immediately before the statement that first uses it, ensuring
+captured variables are in scope.
+
+## ObjUtil Helper Methods
+
+The transpiler generates calls to `ObjUtil` methods for operations that don't map directly
+to Python syntax or require Fantom-specific semantics. These must be implemented in the
+`sys` runtime (PR2).
+
+### Comparison Methods
+
+| Method | Purpose | Fantom | Generated Python |
+|--------|---------|--------|------------------|
+| `ObjUtil.equals(a, b)` | NaN-aware equality | `a == b` | `ObjUtil.equals(a, b)` |
+| `ObjUtil.compare(a, b)` | Fantom comparison | `a <=> b` | `ObjUtil.compare(a, b)` |
+| `ObjUtil.compare_lt(a, b)` | Less than | `a < b` | `ObjUtil.compare_lt(a, b)` |
+| `ObjUtil.compare_le(a, b)` | Less or equal | `a <= b` | `ObjUtil.compare_le(a, b)` |
+| `ObjUtil.compare_gt(a, b)` | Greater than | `a > b` | `ObjUtil.compare_gt(a, b)` |
+| `ObjUtil.compare_ge(a, b)` | Greater or equal | `a >= b` | `ObjUtil.compare_ge(a, b)` |
+| `ObjUtil.compare_ne(a, b)` | Not equal | `a != b` | `ObjUtil.compare_ne(a, b)` |
+| `ObjUtil.same(a, b)` | Identity comparison | `a === b` | `ObjUtil.same(a, b)` |
+
+**Why not Python operators?** Fantom's `==` uses `equals()` which handles NaN correctly
+(NaN == NaN is true in Fantom). Python's `is` operator has interning issues with literals.
+
+### Arithmetic Methods
+
+| Method | Purpose | Why Needed |
+|--------|---------|------------|
+| `ObjUtil.div(a, b)` | Truncated integer division | Python `//` uses floor division (rounds toward -inf), Fantom uses truncated (toward zero) |
+| `ObjUtil.mod(a, b)` | Truncated modulo | Same floor vs truncated difference |
+
+Example: `-7 / 4` in Fantom = -1 (truncated), but `-7 // 4` in Python = -2 (floor).
+
+### Type Methods
+
+| Method | Purpose |
+|--------|---------|
+| `ObjUtil.typeof(obj)` | Get Fantom Type of any object |
+| `ObjUtil.is_(obj, type)` | Fantom `is` type check |
+| `ObjUtil.as_(obj, type)` | Fantom `as` safe cast |
+| `ObjUtil.coerce(obj, type)` | Fantom type coercion |
+| `ObjUtil.to_immutable(obj)` | Make object immutable |
+| `ObjUtil.is_immutable(obj)` | Check if immutable |
+
+### Expression Helpers
+
+| Method | Purpose | Use Case |
+|--------|---------|----------|
+| `ObjUtil.setattr_return(obj, name, val)` | Assignment as expression | `return x = 5` -> `return ObjUtil.setattr_return(self, '_x', 5)` |
+| `ObjUtil.throw_(err)` | Raise as expression | Used in lambdas: `lambda: ObjUtil.throw_(Err())` |
+| `ObjUtil.trap(obj, name, args)` | Dynamic call | `obj->method(args)` -> `ObjUtil.trap(obj, 'method', [args])` |
+
+### Increment/Decrement Helpers
+
+| Method | Purpose |
+|--------|---------|
+| `ObjUtil.inc_field(obj, name)` | Pre-increment field: `++x` |
+| `ObjUtil.inc_field_post(obj, name)` | Post-increment field: `x++` |
+| `ObjUtil.dec_field(obj, name)` | Pre-decrement field: `--x` |
+| `ObjUtil.dec_field_post(obj, name)` | Post-decrement field: `x--` |
+| `ObjUtil.inc_index(container, key)` | Pre-increment index: `++list[i]` |
+| `ObjUtil.inc_index_post(container, key)` | Post-increment index: `list[i]++` |
+| `ObjUtil.dec_index(container, key)` | Pre-decrement index: `--list[i]` |
+| `ObjUtil.dec_index_post(container, key)` | Post-decrement index: `list[i]--` |
+
+### Closure Variable Wrapper
+
+| Method | Purpose |
+|--------|---------|
+| `ObjUtil.cvar(val)` | Wrap closure-captured variable for mutation |
+
+When a local variable is captured by a closure AND modified, the Fantom compiler generates
+a wrapper class. The transpiler converts these to `ObjUtil.cvar()` calls which return a
+mutable container object.
+
+## Exception Mapping
+
+Catch clauses map Fantom exceptions to Python native exceptions. When catching a Fantom
+exception type, the transpiler also catches the corresponding Python native exception
+to ensure interoperability.
+
+| Fantom Exception | Python Native | Generated Catch |
+|------------------|---------------|-----------------|
+| `sys::Err` | `Exception` | `except Exception` |
+| `sys::IndexErr` | `IndexError` | `except (IndexErr, IndexError)` |
+| `sys::ArgErr` | `ValueError` | `except (ArgErr, ValueError)` |
+| `sys::IOErr` | `IOError` | `except (IOErr, IOError)` |
+| `sys::UnknownKeyErr` | `KeyError` | `except (UnknownKeyErr, KeyError)` |
+
+### Exception Wrapping
+
+When catching `sys::Err` (which catches all Python exceptions), the runtime wraps native
+Python exceptions to ensure they have Fantom's `Err` API (`.trace()`, `.msg()`, etc.):
+
+```python
+try:
+ some_code()
+except Exception as err:
+ err = sys.Err.wrap(err) # Ensures .trace() method exists
+ # ... handle err
+```
+
+### Exceptions Not Yet Mapped
+
+The following Fantom exceptions don't have Python native equivalents and are caught
+directly:
+
+- `sys::ParseErr` - No direct Python equivalent
+- `sys::CastErr` - Could map to `TypeError` but semantics differ
+- `sys::NullErr` - Could map to `TypeError` for None access
+- `sys::NotImmutableErr` - Fantom-specific
+- `sys::ReadonlyErr` - Fantom-specific
+- `sys::UnsupportedErr` - Could map to `NotImplementedError`
+
+# Naming
+
+All class names are preserved when going from Fantom to Python.
+
+## Snake_Case Conversion
+
+Method and field names are converted from camelCase to snake_case for a Pythonic API:
+
+| Fantom | Python |
+|--------|--------|
+| `toStr` | `to_str` |
+| `fromStr` | `from_str` |
+| `isEmpty` | `is_empty` |
+| `findAll` | `find_all` |
+| `containsKey` | `contains_key` |
+| `XMLParser` | `xml_parser` |
+| `utf16BE` | `utf16_be` |
+
+The conversion is handled by `PyUtil.toSnakeCase()` and applied through `escapeName()`.
+
+## Reserved Word Escaping
+
+Names that conflict with Python keywords or builtins get a trailing underscore `_`:
+
+**Keywords:** `class`, `def`, `return`, `if`, `else`, `for`, `while`, `import`, `from`,
+`is`, `in`, `not`, `and`, `or`, `True`, `False`, `None`, `lambda`, `global`, `nonlocal`,
+`pass`, `break`, `continue`, `raise`, `try`, `except`, `finally`, `with`, `as`, `assert`,
+`yield`, `del`, `elif`, `match`, `case`
+
+**Builtins:** `type`, `hash`, `id`, `list`, `map`, `str`, `int`, `float`, `bool`, `abs`,
+`all`, `any`, `min`, `max`, `pow`, `round`, `set`, `dir`, `oct`, `open`, `vars`, `print`
+
+```
+# Fantom
+Void class(Str is) { ... }
+Int hash() { ... }
+Bool any(|V| f) { ... }
+
+# Python
+def class_(self, is_):
+ ...
+def hash_(self):
+ ...
+def any_(self, f):
+ ...
+```
+
+Internal methods and fields used for compiler support use double-underscore prefix patterns
+like `_static_init`, `_ctor_init`, etc. These should be considered private implementation
+details.
+
+# Python-Specific Considerations
+
+## The GIL
+
+Python's Global Interpreter Lock (GIL) means true parallelism isn't possible with threads.
+The `concurrent` pod's Actor model implementation uses Python's `concurrent.futures` but
+won't achieve the same parallelism as JVM or JavaScript runtimes.
+
+**Free-Threaded Python (3.13+):** Starting with Python 3.13 (PEP 703), CPython offers an
+experimental free-threaded build that disables the GIL entirely. This can be enabled at
+runtime with `python3 -X gil=0` or by setting the `PYTHON_GIL=0` environment variable.
+The `concurrent` pod's implementation already uses proper threading primitives
+(`threading.Lock`, `threading.RLock`, `concurrent.futures.ThreadPoolExecutor`) throughout
+Actor, ActorPool, ConcurrentMap, and the Atomic types, so **no code changes are required**
+to benefit from true parallelism under the free-threaded build. CPU-bound actor message
+processing will achieve real multi-core parallelism when the GIL is disabled. Note that
+some third-party C extensions may not yet be compatible with free-threaded mode, so users
+should verify their full dependency chain before enabling it in production.
+
+## No Method Overloading
+
+Python doesn't support method overloading by signature. Fantom constructors with different
+signatures are handled via factory methods (`make`, `make1`, etc.) that all delegate to a
+single `__init__`.
+
+## Type Hints
+
+The transpiler generates Python type hints for all public APIs:
+
+```python
+def from_str(s: 'str', checked: 'bool' = None) -> 'Optional[Number]':
+ ...
+
+def unit(self, _val_: 'Optional[Unit]' = None) -> 'Optional[Unit]':
+ ...
+
+def to_float(self) -> 'float':
+ ...
+```
+
+Type mapping:
+| Fantom | Python Type Hint |
+|--------|------------------|
+| `Bool` | `'bool'` |
+| `Int` | `'int'` |
+| `Float` | `'float'` |
+| `Str` | `'str'` |
+| `Void` | `None` |
+| `Type?` | `'Optional[Type]'` |
+| `Str[]` | `'List[str]'` |
+| `[Str:Int]` | `'Dict[str, int]'` |
+| `|Int->Bool|` | `'Callable[[int], bool]'` |
+
+All type hints use forward references (strings) to avoid import order issues. Required
+imports are added to each generated file:
+
+```python
+from typing import Optional, Callable, List as TypingList, Dict as TypingDict
+```
+
+**Note:** Runtime type checks still use `ObjUtil.is_()` and `ObjUtil.as_()` - type hints
+are for IDE autocomplete and static analysis, not runtime enforcement.
+
+## None vs null
+
+Fantom's `null` maps directly to Python's `None`. Nullable types (`Str?`) don't have special
+representation - any variable can hold `None`.
+
+# Performance Optimizations
+
+Unlike JavaScript where the V8 JIT compiler handles most performance concerns, Python's
+interpreted nature requires explicit runtime optimizations. These were identified through
+profiling (`cProfile`) of the test suites and xeto namespace creation.
+
+The optimizations follow a consistent pattern: identify hot paths through profiling,
+cache computed results keyed by immutable signatures, and trade small amounts of
+persistent memory (~100KB total) for significant CPU savings.
+
+## Type.find() Cache-First Pattern
+
+`Type.find()` is called millions of times during test execution (19M+ calls in testXeto).
+The optimization ensures cache hits are as fast as possible:
+
+```python
+@staticmethod
+def find(qname, checked=True):
+ # CRITICAL: Check cache FIRST before any imports
+ # This saves ~0.6us per call (8.3x faster than doing import first)
+ cached = Type._cache.get(qname)
+ if cached is not None:
+ return cached
+
+ # Imports only needed for cache misses and error handling
+ from .Err import ArgErr, UnknownTypeErr, UnknownPodErr
+ # ... rest of lookup logic
+```
+
+**Why this matters:** Python's `from .Err import ...` statement has ~0.6us overhead per call,
+even when the modules are already in `sys.modules`. Moving the cache check before imports
+reduces cache hit time from 0.66us to 0.12us (5.4x faster).
+
+**Memory impact:** Zero - just reordered existing code.
+
+## Func.make_closure() Param Caching
+
+Closures are created millions of times per test run (4.2M+ in testXeto). Each closure
+specification includes parameter metadata that previously created new `Param` objects
+every time:
+
+```python
+# Module-level cache
+_param_cache = {} # (name, type_sig) -> Param
+
+@staticmethod
+def make_closure(spec, func):
+ # ... parse spec ...
+ for p in spec.get('params', []):
+ name = p['name']
+ type_sig = p['type']
+ cache_key = (name, type_sig)
+
+ # Check cache first
+ cached = Func._param_cache.get(cache_key)
+ if cached is not None:
+ params.append(cached)
+ continue
+
+ # Create and cache on miss
+ param = Param(name, Type.find(type_sig), False)
+ Func._param_cache[cache_key] = param
+ params.append(param)
+```
+
+**Why this matters:** Closures with the same parameter signature (e.g., `|Int->Bool|`)
+share cached `Param` objects instead of creating 4.2M short-lived objects.
+
+**Memory impact:** ~50-100 KB (100-200 unique signatures × ~500 bytes/entry).
+Actually reduces GC pressure by avoiding millions of allocations/deallocations.
+
+## __import__() Caching
+
+The transpiler generates `__import__('fan.pod.Type', fromlist=['Type']).Type` for same-pod
+type references to avoid circular imports (see [Import Architecture](#import-architecture)).
+During heavy operations like xeto namespace creation, this results in 3.6 million
+`__import__()` calls.
+
+**Implementation:** `src/sys/py/fan/__init__.py`
+
+The optimization intercepts `__import__()` for `fan.*` modules:
+
+```python
+# Pseudocode - see __init__.py for full implementation
+cache_key = (module_name, tuple(fromlist))
+if cache_key in _fan_import_cache:
+ return _fan_import_cache[cache_key]
+result = original_import(...)
+_fan_import_cache[cache_key] = result
+return result
+```
+
+**Why this matters:** Python's `__import__()` has overhead even for cached modules.
+Caching the fully-resolved result (module + fromlist attribute) eliminates repeated
+lookups. Xeto namespace creation drops from ~25s to ~6s (75% faster).
+
+**Memory impact:** ~10KB (245 unique module/fromlist combinations).
+
+**Design notes:**
+- Cache stored in `builtins` to survive module reloading during test runs
+- Only caches `fan.*` modules with explicit `fromlist` (transpiler pattern)
+- Triggered early via `import fan.sys` in `haystack/__init__.py`
+
+## Performance Results
+
+These optimizations together deliver 28-32% speedup on testXeto:
+
+| Test Suite | Before | After | Improvement |
+|------------|--------|-------|-------------|
+| testXeto::AxonTest | 83.5s | 59.7s | 28% faster |
+| testXeto::ValidateTest | 45.8s | 31.3s | 32% faster |
+
+Xeto namespace creation specifically:
+
+| Operation | Before | After | Improvement |
+|-----------|--------|-------|-------------|
+| create_namespace(['sys','ph']) | ~25s | ~6s | 75% faster |
+
+**Design principles:**
+1. Optimize the hottest paths first (Type.find, closure creation, __import__)
+2. Cache objects keyed by immutable signatures, not call count
+3. Trade ~100KB memory for avoiding millions of allocations
+4. Keep caches bounded by unique signatures in codebase
diff --git a/src/fanc/fan/py/quickstart.md b/src/fanc/fan/py/quickstart.md
new file mode 100644
index 000000000..27a3f8cac
--- /dev/null
+++ b/src/fanc/fan/py/quickstart.md
@@ -0,0 +1,145 @@
+# Python Transpiler - Quick Start
+
+Build and test the Fantom-to-Python transpiler in 4 steps.
+
+**Requirements:** Fantom build environment, Python 3.12+
+
+---
+
+## Python Environment
+
+Python 3.12 or later is required. The `py` test runner discovers Python in this order:
+
+1. `FAN_PYTHON` environment variable (if set, uses that path directly)
+2. `uv python find` (if [uv](https://docs.astral.sh/uv/) is installed)
+3. `pyenv which python` (if pyenv is installed)
+4. `python3` on PATH (macOS/Linux) or `python` on PATH (Windows)
+
+If your default `python3` is 3.12+, no configuration is needed. Otherwise:
+
+```bash
+export FAN_PYTHON=/path/to/python3.12
+```
+
+The test runner checks the version at startup and prints a clear error if it's too old.
+
+---
+
+## Build and Run
+
+### 1. Build the transpiler
+
+```bash
+bin/fan src/fanc/build.fan
+```
+
+### 2. Build the test runner
+
+```bash
+bin/fan src/py/build.fan
+```
+
+### 3. Transpile pods to Python
+
+```bash
+bin/fanc py sys testSys util
+```
+
+Output goes to `gen/py/` under `Env.cur.workDir` (the first entry in your PathEnv path, or FAN_HOME if not using PathEnv). The transpiler automatically resolves dependencies (`concurrent` is pulled in by `testSys`).
+
+**Important:** Each `fanc py` invocation regenerates the output directory. Transpile all pods you need in a single command.
+
+**Note:** You may see `ERROR: invalid fanc.cmd nodeJs::JsCmd` -- this is non-fatal. It happens when `fanc` tries to register all transpiler commands and `nodeJs.pod` isn't built. The Python transpiler runs fine regardless.
+
+### 4. Run tests
+
+```bash
+bin/py test testSys::BoolTest
+```
+
+```
+-- Run: testSys::BoolTest.testIdentity
+ Pass: testSys::BoolTest.testIdentity [25]
+-- Run: testSys::BoolTest.testDefVal
+ Pass: testSys::BoolTest.testDefVal [2]
+...
+***
+*** All tests passed! [1 types, 7 methods, 111 verifies]
+***
+```
+
+Run a single method:
+```bash
+bin/py test testSys::BoolTest.testDefVal
+```
+
+Run an entire pod:
+```bash
+bin/py test testSys
+```
+
+---
+
+## Transpiling Additional Pods
+
+```bash
+# Foundation pods (with inet, web, crypto, email)
+bin/fanc py sys testSys util concurrent inet web crypto email
+
+# Run all testSys tests
+bin/py test testSys
+```
+
+The transpiler resolves and transpiles dependencies automatically.
+
+---
+
+## What Gets Generated
+
+`fanc py ` produces Python source in `gen/py/fan//`:
+
+- **Pure Fantom types** -- fully transpiled to Python classes
+- **Types with natives** -- hand-written Python file merged with transpiled metadata
+- **Extra natives** -- utility files (e.g., `ObjUtil.py`) copied directly
+- **Lazy loader `__init__.py`** -- module-level `__getattr__` for lazy imports
+
+Each pod with Python natives declares them in `build.fan`:
+```fantom
+pyDirs = [`py/`]
+```
+
+The transpiler reads these via `compiler.input.pyFiles` and merges them with transpiled output.
+
+---
+
+## File Layout
+
+```
+src/fanc/fan/py/ # Transpiler source
+ PythonCmd.fan # Entry point (fanc py )
+ PyTypePrinter.fan # Class/type generation
+ PyExprPrinter.fan # Expression generation
+ PyStmtPrinter.fan # Statement generation
+ PyPrinter.fan # Base printer
+ PyUtil.fan # Utilities and operator maps
+ design.md # Technical reference
+ quickstart.md # This file
+
+src/sys/py/fan/ # sys pod natives (79 files)
+src/sys/py/fanx/ # Serialization module (5 files)
+src/concurrent/py/ # concurrent pod natives (12 files)
+src/util/py/ # util pod natives (7 files)
+src/inet/py/ # inet pod natives (9 files)
+src/web/py/ # web pod natives (5 files)
+src/crypto/py/ # crypto pod natives (2 files)
+
+src/py/ # py pod -- Python CLI tools
+ fan/Main.fan # CLI entry point (command dispatch)
+ fan/PyCmd.fan # Base command (Python discovery, version check)
+ fan/cmd/TestCmd.fan # Test runner (util::TestRunner)
+ fan/cmd/FanCmd.fan # Run Fantom main programs (py fan )
+ fan/cmd/HelpCmd.fan # Help and command listing
+ fan/cmd/InitCmd.fan # Initialize Python environment
+```
+
+See `design.md` for the full technical reference on how Fantom constructs map to Python.