Skip to content

Commit ff5df24

Browse files
dbrattliclaude
andauthored
fix(python): avoid union case field name collision with Union.name (#4647)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4d6b2c0 commit ff5df24

5 files changed

Lines changed: 31 additions & 15 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ let private transformRecordReflectionInfo com ctx r (ent: Fable.Entity) generics
4040

4141
let name =
4242
if Util.shouldUseRecordFieldNaming ent then
43-
fi.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean
43+
fi.Name |> Naming.toFieldSnakeCase |> Helpers.clean
4444
else
4545
fi.Name |> Naming.toSnakeCase |> Helpers.clean
4646

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3223,7 +3223,7 @@ let getUnionFieldsAsIdents (_com: IPythonCompiler) _ctx (_ent: Fable.Entity) =
32233223
let getEntityFieldsAsIdents (com: IPythonCompiler) (ent: Fable.Entity) =
32243224
let entityNamingConvention =
32253225
if shouldUseRecordFieldNaming ent then
3226-
Naming.toRecordFieldSnakeCase
3226+
Naming.toFieldSnakeCase
32273227
else
32283228
Naming.toPythonNaming
32293229

@@ -3248,7 +3248,7 @@ let getEntityFieldsAsProps (com: IPythonCompiler) ctx (ent: Fable.Entity) =
32483248
else
32493249
let namingConvention =
32503250
if shouldUseRecordFieldNaming ent then
3251-
Naming.toRecordFieldSnakeCase
3251+
Naming.toFieldSnakeCase
32523252
else
32533253
Naming.toPythonNaming
32543254

@@ -3290,7 +3290,7 @@ let declareDataClassType
32903290
consArgs.Args.[i].Arg
32913291
else
32923292
// Fallback to field name if consArgs doesn't have enough args
3293-
com.GetIdentifier(ctx, field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean)
3293+
com.GetIdentifier(ctx, field.Name |> Naming.toFieldSnakeCase |> Helpers.clean)
32943294

32953295
// Uncurry lambda types for field annotations since fields store uncurried functions
32963296
let fieldType =
@@ -3943,7 +3943,7 @@ let transformAttachedProperty
39433943
// Apply the same naming convention as record fields for record types
39443944
let propertyName =
39453945
//if shouldUseRecordFieldNaming ent then
3946-
// memb.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean
3946+
// memb.Name |> Naming.toFieldSnakeCase |> Helpers.clean
39473947
//else
39483948
memb.Name |> Naming.toPropertyNaming
39493949

@@ -4097,9 +4097,10 @@ let transformUnion (com: IPythonCompiler) ctx (ent: Fable.Entity) (entName: stri
40974097
let fieldAnnotations =
40984098
uci.UnionCaseFields
40994099
|> List.map (fun field ->
4100-
// Convert to snake_case and clean to remove invalid characters like apostrophes
4101-
// Handles: "Item" -> "item", "Item1" -> "item1", "MyField" -> "my_field"
4102-
let fieldName = field.Name |> Naming.toSnakeCase |> Helpers.clean
4100+
// toFieldSnakeCase appends '_' to camelCase names ("name" -> "name_") so a
4101+
// field can't collide with the inherited `Union.name` property, which @dataclass
4102+
// would treat as a default value ("non-default argument follows default"). See #4645.
4103+
let fieldName = field.Name |> Naming.toFieldSnakeCase |> Helpers.clean
41034104
// Uncurry lambda types for field annotations since union case fields
41044105
// store uncurried functions at runtime (same as record fields)
41054106
let fieldType =
@@ -4238,7 +4239,7 @@ let transformClassWithCompilerGeneratedConstructor
42384239
|> List.collecti (fun i field ->
42394240
let fieldName =
42404241
if shouldUseRecordFieldNaming ent then
4241-
field.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean
4242+
field.Name |> Naming.toFieldSnakeCase |> Helpers.clean
42424243
else
42434244
match Util.getFieldNamingKind com field.FieldType field.Name with
42444245
| InstancePropertyBacking -> field.Name |> Naming.toPropertyBackingFieldNaming
@@ -4680,7 +4681,7 @@ let transformStaticProperty
46804681
// printfn "transformStaticProperty: %A" propName
46814682
let propertyName =
46824683
if shouldUseRecordFieldNaming ent then
4683-
propName |> Naming.toRecordFieldSnakeCase |> Helpers.clean
4684+
propName |> Naming.toFieldSnakeCase |> Helpers.clean
46844685
else
46854686
propName |> Naming.toPropertyNaming
46864687

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,13 +463,13 @@ module Util =
463463

464464
name
465465

466-
/// Determines if we should use the special record field naming convention (toRecordFieldSnakeCase)
466+
/// Determines if we should use the special field naming convention (toFieldSnakeCase)
467467
/// for the given entity. Returns true for user-defined F# records, false for built-in types.
468468
let shouldUseRecordFieldNaming (ent: Fable.Entity) =
469469
ent.IsFSharpRecord
470470
&& not (ent.FullName.StartsWith("Microsoft.FSharp.Core", StringComparison.Ordinal))
471471

472-
/// Determines if we should use the special record field naming convention (toRecordFieldSnakeCase)
472+
/// Determines if we should use the special field naming convention (toFieldSnakeCase)
473473
/// for the given entity reference. Returns true for user-defined F# records, false for built-in types.
474474
let shouldUseRecordFieldNamingForRef (entityRef: Fable.EntityRef) (ent: Fable.Entity) =
475475
ent.IsFSharpRecord
@@ -510,7 +510,7 @@ module Util =
510510
| Fable.DeclaredType(entityRef, _) ->
511511
match com.TryGetEntity entityRef with
512512
| Some ent when shouldUseRecordFieldNamingForRef entityRef ent && isRecordField ent fieldName ->
513-
fieldName |> Naming.toRecordFieldSnakeCase |> Helpers.clean
513+
fieldName |> Naming.toFieldSnakeCase |> Helpers.clean
514514
| _ -> fieldName |> Naming.toPythonNaming // Fallback to Python naming for other types
515515
| _ -> fieldName |> Naming.toPropertyNaming
516516

src/Fable.Transforms/Python/Prelude.fs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ module Naming =
3333
let toSnakeCase (name: string) =
3434
Naming.applyCaseRule CaseRules.SnakeCase name
3535

36-
/// Convert F# record field name to snake_case with special handling for camelCase/PascalCase conflicts.
36+
/// Convert an F# field name (record or union case field) to snake_case with special handling
37+
/// for camelCase/PascalCase conflicts.
3738
/// - If the name is PascalCase, convert to snake_case without suffix.
3839
/// - If the name is camelCase, convert to snake_case and add '_' suffix to avoid conflict with PascalCase.
39-
let toRecordFieldSnakeCase (name: string) =
40+
let toFieldSnakeCase (name: string) =
4041
let snakeCase = Naming.applyCaseRule CaseRules.SnakeCase name
4142

4243
if name.Length > 0 && Char.IsLower(name.[0]) then

tests/Python/TestUnionType.fs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,17 @@ type S = S of string
211211
let ``test sprintf formats strings cases correctly`` () =
212212
let s = sprintf "%A" (S "1")
213213
s |> equal "S \"1\""
214+
215+
// See https://github.com/fable-compiler/Fable/issues/4645
216+
// A named union case field called `name` collided with the inherited `Union.name`
217+
// property, which Python's @dataclass treated as a default value.
218+
type NamedFieldUnion =
219+
| NamedCase of name: string * optional: string
220+
221+
[<Fact>]
222+
let ``test Named union case fields work when a field is called name`` () =
223+
let value = NamedCase("v1", "v2")
224+
match value with
225+
| NamedCase(name, optional) ->
226+
name |> equal "v1"
227+
optional |> equal "v2"

0 commit comments

Comments
 (0)