Skip to content

Commit 15eb83e

Browse files
dbrattliclaude
andauthored
fix(python): make [<AttachMembers>] union static members work (#4634) (#4636)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff5df24 commit 15eb83e

6 files changed

Lines changed: 145 additions & 22 deletions

File tree

src/Fable.Transforms/FSharp2Fable.Util.fs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,21 @@ module Util =
22762276

22772277
let entityIdent (com: Compiler) (ent: Fable.EntityRef) = entityIdentWithSuffix com ent ""
22782278

2279+
/// In Python a union is emitted as a public type alias (`type Demo = Demo_A | Demo_B`)
2280+
/// plus a private base class (`_Demo`) that holds the runtime members: attached static
2281+
/// members and the `cases()` method used by reflection. The type alias is typing-only,
2282+
/// so a reference to the union as a runtime value must be redirected to the base class.
2283+
/// Handles the two shapes `entityIdent` produces: a bare identifier for a same-file
2284+
/// union and an internal class import for a union defined in another file. References
2285+
/// to external entities (`[<Import>]`, kind `UserImport`) are left untouched.
2286+
/// See Python/PYTHON-UNION.md.
2287+
let redirectUnionToPythonBaseClass (expr: Fable.Expr) =
2288+
match expr with
2289+
| Fable.IdentExpr ident -> Fable.IdentExpr { ident with Name = "_" + ident.Name }
2290+
| Fable.Import({ Kind = Fable.ClassImport _ } as info, typ, range) ->
2291+
Fable.Import({ info with Selector = "_" + info.Selector }, typ, range)
2292+
| expr -> expr
2293+
22792294
/// First checks if the entity is global or imported
22802295
let tryEntityIdentMaybeGlobalOrImported (com: Compiler) (ent: Fable.Entity) =
22812296
match tryGlobalOrImportedEntity com ent with
@@ -2740,7 +2755,14 @@ module Util =
27402755
&& not (isInline memb)
27412756
&& (isAttachMembersEntity com e || isPojoDefinedByConsArgsFSharpEntity e)
27422757
->
2743-
FsEnt.Ref e |> entityIdent com |> Some
2758+
let classExpr = FsEnt.Ref e |> entityIdent com
2759+
2760+
// In Python the union type alias carries no members, so attached static
2761+
// members must be accessed on the private base class.
2762+
if com.Options.Language = Python && e.IsFSharpUnion then
2763+
redirectUnionToPythonBaseClass classExpr |> Some
2764+
else
2765+
Some classExpr
27442766
| None -> None
27452767

27462768
match moduleOrClassExpr, callInfo.ThisArg with

src/Fable.Transforms/Python/Fable2Python.Transforms.fs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4622,14 +4622,24 @@ let getInitialValue
46224622
(className: string)
46234623
(getterMemb: Fable.MemberDecl)
46244624
(wrapInLambda: bool)
4625+
(isUnion: bool)
46254626
(fallback: Expression)
46264627
: (Expression * bool * string list * Statement list)
46274628
=
46284629
match getterMemb.Body with
46294630
| Fable.Value(kind, r) ->
46304631
// Use transformValue for literal values - never wrapped
46314632
let value, stmts = transformValue com ctx r kind
4632-
value, false, [], stmts
4633+
4634+
if isUnion && wrapInLambda then
4635+
// A union is emitted as a base class (`_Demo`) holding the static property
4636+
// followed by the case classes (`Demo_A`, ...). Constructing a case eagerly in
4637+
// the class body raises `NameError` because the case class doesn't exist yet.
4638+
// Defer with a lambda (StaticLazyProperty); this also matches F# getter
4639+
// semantics where the body is re-evaluated on each access.
4640+
Expression.lambda (Arguments.arguments [], value), true, [], stmts
4641+
else
4642+
value, false, [], stmts
46334643
| Fable.Get(Fable.IdentExpr _, Fable.FieldGet fieldInfo, _, _) when
46344644
fieldInfo.Name.EndsWith("@", System.StringComparison.Ordinal)
46354645
->
@@ -4697,11 +4707,21 @@ let transformStaticProperty
46974707

46984708
// Check if the property type references the current class (forward reference needed)
46994709
let typeAnnotation =
4700-
match propType with
4701-
| Fable.DeclaredType(entRef, _) when com.GetEntity(entRef).DisplayName = name ->
4710+
// The property type is self-referencing when it is the enclosing entity. Compare by
4711+
// FullName as well as DisplayName: for unions the descriptor lives on the base class
4712+
// and the type alias (`name`) is only defined afterwards, so a bare name would raise
4713+
// NameError at class-body evaluation.
4714+
let isSelfReference =
4715+
match propType with
4716+
| Fable.DeclaredType(entRef, _) ->
4717+
let e = com.GetEntity(entRef)
4718+
e.DisplayName = name || e.FullName = ent.FullName
4719+
| _ -> false
4720+
4721+
if isSelfReference then
47024722
// Use string forward reference for self-referencing types
47034723
Expression.stringConstant name
4704-
| _ ->
4724+
else
47054725
// Use normal type annotation
47064726
let ta, _ = Annotation.typeAnnotation com ctx None propType
47074727
ta
@@ -4728,7 +4748,7 @@ let transformStaticProperty
47284748
let fallback = Util.getDefaultValueForType com ctx getterMemb.Body.Type
47294749

47304750
let initialValue, isFactory, externalFields, initialValueStmts =
4731-
getInitialValue com ctx name getterMemb true fallback
4751+
getInitialValue com ctx name getterMemb true ent.IsFSharpUnion fallback
47324752

47334753
let propExpr = makeStaticProperty getterMemb.Body.Type [ initialValue ] isFactory
47344754

@@ -4745,7 +4765,7 @@ let transformStaticProperty
47454765
let fallback = Util.getDefaultValueForType com ctx getterMemb.Body.Type
47464766

47474767
let initialValue, isFactory, externalFields, initialValueStmts =
4748-
getInitialValue com ctx name getterMemb true fallback
4768+
getInitialValue com ctx name getterMemb true false fallback
47494769

47504770
// Check if setter has custom logic beyond simple assignment to the property itself
47514771
// For simple properties, we don't need setter functions since StaticProperty handles storage

src/Fable.Transforms/Python/PYTHON-UNION.md

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ class MyUnion_CaseB(_MyUnion):
4949

5050
@tagged_union(2)
5151
class MyUnion_CaseC(_MyUnion):
52-
x: float
53-
y: float
52+
x_: float
53+
y_: float
5454

5555

5656
# Type alias - THE public union type for annotations
@@ -115,6 +115,27 @@ class ModuleB_Result_Error(_ModuleB_Result):
115115

116116
The scoped name is derived from `FSharp2Fable.Helpers.getEntityDeclarationName`, which includes the module path to ensure unique names across the entire compilation.
117117

118+
### Case Field Naming: `toFieldSnakeCase`
119+
120+
Case class field annotations use `Naming.toFieldSnakeCase` (in `Prelude.fs`), the same
121+
convention as record fields:
122+
123+
- PascalCase names (including the compiler-generated `Item`, `Item1`, ...) convert to
124+
plain snake_case: `Item``item`, `MyField``my_field`.
125+
- camelCase/lowercase names convert to snake_case **with a trailing underscore**:
126+
`x``x_`, `name``name_`.
127+
128+
The suffix exists because the `Union` base class defines properties such as `name`. A
129+
field annotation that shadows an inherited property is treated by `@dataclass` as having
130+
a *default value* (the inherited property object found via `getattr`), which breaks
131+
construction with "non-default argument follows default argument" as soon as another
132+
field without a default follows (issue #4645, PR #4647).
133+
134+
This is safe because the dataclass field name is purely internal: union construction is
135+
positional (`Union_Case("v1", "v2")`) and compiled field access goes through the case
136+
class attributes generated with the same convention, while indexed access uses
137+
`self.fields[i]`.
138+
118139
### Library Types Keep Simple Names
119140

120141
F# core library types (`Result`, `FSharpChoice`) use simple case names without prefix for cleaner interop:
@@ -234,15 +255,15 @@ def make_union(uci: CaseInfo, values: Array[Any]) -> Any:
234255
u = MyUnion_CaseA(42)
235256
u = MyUnion_CaseC(1.0, 2.0)
236257

237-
# Field access - direct attributes with F# names
258+
# Field access - direct attributes (camelCase F# names get a '_' suffix, see Case Field Naming)
238259
print(u.item) # For CaseA/CaseB
239-
print(u.x, u.y) # For CaseC
260+
print(u.x_, u.y_) # For CaseC
240261

241262
# Pattern matching - __match_args__ automatic from dataclass
242263
match u:
243264
case MyUnion_CaseA(item=value):
244265
print(f"CaseA: {value}")
245-
case MyUnion_CaseC(x=x, y=y):
266+
case MyUnion_CaseC(x_=x, y_=y):
246267
print(f"CaseC: {x}, {y}")
247268

248269
# isinstance works with base class (underscore-prefixed)
@@ -319,15 +340,40 @@ In `Fable2Python.Annotation.fs`, the `makeEntityTypeAnnotation` function:
319340
1. If inside the same union base class (`ctx.EnclosingUnionBaseClass = Some name`): use base class name with underscore
320341
2. Otherwise: use type alias (strip underscore prefix)
321342

322-
### Reflection Constructor
343+
### Runtime References Target the Base Class
323344

324-
In `Replacements.fs`, `tryConstructor` adds underscore prefix for union types so reflection gets the base class:
345+
The type alias (`Demo`) is typing-only: at runtime it is a `TypeAliasType` that carries no
346+
members. Whenever the compiler needs to reference the union as a *runtime value*, the
347+
reference must be redirected to the private base class (`_Demo`). This redirect is
348+
centralized in one helper, `FSharp2Fable.Util.redirectUnionToPythonBaseClass`:
325349

326350
```fsharp
327-
| Some(IdentExpr ident) when ent.IsFSharpUnion ->
328-
Some(IdentExpr { ident with Name = "_" + ident.Name })
351+
let redirectUnionToPythonBaseClass (expr: Fable.Expr) =
352+
match expr with
353+
| Fable.IdentExpr ident -> Fable.IdentExpr { ident with Name = "_" + ident.Name }
354+
| Fable.Import({ Kind = Fable.ClassImport _ } as info, typ, range) ->
355+
Fable.Import({ info with Selector = "_" + info.Selector }, typ, range)
356+
| expr -> expr
329357
```
330358

359+
It handles both shapes a union entity reference can take — a bare identifier for a
360+
same-file union and an internal class import for a union defined in another file — and
361+
leaves external entities (`[<Import>]`, kind `UserImport`) untouched.
362+
363+
Current call sites:
364+
365+
1. **Reflection constructor**`tryConstructor` in `Python/Replacements.fs` redirects so
366+
reflection gets the base class (which has the `cases()` method).
367+
2. **Attached static members** — in `FSharp2Fable.Util.fs`, when resolving a call to a
368+
static member of an `[<AttachMembers>]` union (issue #4634). The static members live on
369+
the base class; accessing them on the type alias raises `AttributeError` at runtime.
370+
371+
Note that call site 2 lives in a *shared* compiler file (`FSharp2Fable.Util.fs`), gated on
372+
`com.Options.Language = Python` — it is the one place that still knows both the entity
373+
(`IsFSharpUnion`) and that the reference is for static member access. If the naming
374+
convention ever changes, update the helper and the emission sites in
375+
`Fable2Python.Transforms.fs` together.
376+
331377
## Files Modified
332378

333379
1. [src/fable-library-py/fable_library/union.py](src/fable-library-py/fable_library/union.py) - `@tagged_union` decorator
@@ -336,7 +382,8 @@ In `Replacements.fs`, `tryConstructor` adds underscore prefix for union types so
336382
4. [src/Fable.Transforms/Python/Fable2Python.Transforms.fs](src/Fable.Transforms/Python/Fable2Python.Transforms.fs) - `transformUnion`, context tracking
337383
5. [src/Fable.Transforms/Python/Fable2Python.Annotation.fs](src/Fable.Transforms/Python/Fable2Python.Annotation.fs) - Type annotation logic for union types
338384
6. [src/Fable.Transforms/Python/Fable2Python.Reflection.fs](src/Fable.Transforms/Python/Fable2Python.Reflection.fs) - Case constructor generation
339-
7. [src/Fable.Transforms/Python/Replacements.fs](src/Fable.Transforms/Python/Replacements.fs) - `tryConstructor` adds underscore for unions
385+
7. [src/Fable.Transforms/Python/Replacements.fs](src/Fable.Transforms/Python/Replacements.fs) - `tryConstructor` redirects unions to the base class
386+
8. [src/Fable.Transforms/FSharp2Fable.Util.fs](../FSharp2Fable.Util.fs) - `redirectUnionToPythonBaseClass` helper; static member access on `[<AttachMembers>]` unions (Python-gated)
340387

341388
## Comparison with Original Fable Design
342389

src/Fable.Transforms/Python/Replacements.fs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -775,11 +775,10 @@ let tryConstructor com (ent: Entity) =
775775
tryEntityIdent com (ent.FullName |> Naming.toPythonNaming)
776776
else
777777
match FSharp2Fable.Util.tryEntityIdentMaybeGlobalOrImported com ent with
778-
| Some(IdentExpr ident) when ent.IsFSharpUnion ->
779-
// For F# union types, the base class is prefixed with underscore (_UnionName)
780-
// This is needed for both reflection (base class has cases() method) and
781-
// type annotations (self inside base class methods)
782-
Some(IdentExpr { ident with Name = "_" + ident.Name })
778+
| Some expr when ent.IsFSharpUnion ->
779+
// The union runtime members (e.g. the cases() method used by reflection) live
780+
// on the private base class, not the type alias. See PYTHON-UNION.md.
781+
FSharp2Fable.Util.redirectUnionToPythonBaseClass expr |> Some
783782
| other -> other
784783

785784
let constructor com ent =

tests/Python/MiscTestsHelper.fs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,14 @@ type Vector2<[<Measure>] 'u> = Vector2 of x: float<'u> * y: float<'u> with
3131

3232
static member inline ( + ) (Vector2(ax, ay), Vector2(bx, by)) = Vector2(ax + bx, ay + by)
3333
static member inline ( * ) (scalar, Vector2(x, y)) = Vector2(scalar * x, scalar * y)
34+
35+
// Issue4634: an [<AttachMembers>] union with static members, defined in a separate
36+
// module/file so use sites reference it across modules (an Import, not a same-file
37+
// identifier). This exercises the cross-file path of the static-member-on-union fix.
38+
[<Fable.Core.AttachMembers>]
39+
type CrossModuleDemo =
40+
| A of string
41+
| B
42+
43+
static member propDefault = A "prop"
44+
static member methDefault() = A "meth"

tests/Python/TestNonRegression.fs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,30 @@ let ``test class name casing`` () =
182182
let x = Issue3811.flowchartDirection.tb' ()
183183
equal Issue3811.FlowchartDirection.TB x
184184

185+
module Issue4634 =
186+
// An attached static property on a union whose getter constructs a union case must
187+
// not be emitted eagerly in the base class body (the case classes don't exist yet),
188+
// and use sites must reach the static member on the base class, not the type alias.
189+
[<AttachMembers>]
190+
type Demo =
191+
| A of string
192+
| B
193+
194+
static member propDefault = A "prop"
195+
static member methDefault() = A "meth"
196+
197+
[<Fact>]
198+
let ``test attached static members on union work`` () =
199+
equal (Issue4634.A "prop") Issue4634.Demo.propDefault
200+
equal (Issue4634.A "meth") (Issue4634.Demo.methDefault())
201+
202+
[<Fact>]
203+
let ``test attached static members on union work across modules`` () =
204+
// CrossModuleDemo is defined in MiscTestsHelper.fs, so these use sites compile to an
205+
// import of the union rather than a same-file identifier — covering the cross-file path.
206+
equal (Fable.Tests.MiscTestsHelper.A "prop") Fable.Tests.MiscTestsHelper.CrossModuleDemo.propDefault
207+
equal (Fable.Tests.MiscTestsHelper.A "meth") (Fable.Tests.MiscTestsHelper.CrossModuleDemo.methDefault())
208+
185209
module Issue3972 =
186210
type IInterface =
187211
abstract member LOL : int

0 commit comments

Comments
 (0)