From 0f6f66e422b883b3132f7b15b44551d130d49b16 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 8 Sep 2026 19:50:08 +0200 Subject: [PATCH] fix(python): preserve record field names in structured formatting --- .../Python/Fable2Python.Transforms.fs | 13 +++++++++++ src/fable-library-py/fable_library/record.py | 22 +++++++++++++------ tests/Python/TestRecordType.fs | 5 +++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 9685c83fbf..1c71ea9c0a 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3538,6 +3538,18 @@ let declareDataClassType = let name = com.GetIdentifier(ctx, entName) + // Record slots follow Python naming conventions, but structured formatting must + // retain the original F# field names. Keep the names on the generated class so + // the runtime can pair them with the slots without attempting a lossy conversion. + let fsharpFieldNames = + ent.FSharpFields + |> List.filter (fun field -> not field.IsStatic) + |> List.map (fun field -> Expression.stringConstant field.Name) + |> Expression.tuple + + let fsharpFieldNamesMember = + Statement.assign ([ Expression.name ("__fable_field_names__", Store) ], fsharpFieldNames) + // Generate field annotations from entity's FSharpFields to properly uncurry function types. // Record/dataclass fields store functions uncurried at runtime, so we need to uncurry // lambda types in the type annotations to match. @@ -3661,6 +3673,7 @@ let declareDataClassType let classBody = let body = [ + yield fsharpFieldNamesMember yield! staticFieldAnnotations yield! props yield! classMembers diff --git a/src/fable-library-py/fable_library/record.py b/src/fable-library-py/fable_library/record.py index 12313ed374..6a195f3ad2 100644 --- a/src/fable-library-py/fable_library/record.py +++ b/src/fable-library-py/fable_library/record.py @@ -79,15 +79,23 @@ def _field_to_string(value: object) -> str: def record_to_string(self: Record) -> str: - if hasattr(self, "__slots__"): - return ( - "{ " - + "\n ".join(map(lambda slot: slot + " = " + _field_to_string(getattr(self, slot)), self.__slots__)) - + " }" - ) - else: + if not hasattr(self, "__slots__"): return "{ " + "\n ".join(map(lambda kv: kv[0] + " = " + _field_to_string(kv[1]), self.__dict__.items())) + " }" + slots = self.__slots__ + # Newer generated records retain their source-level F# field names here. + # Fall back to slots so the current runtime remains compatible with code + # compiled before that metadata was emitted. + field_names = getattr(type(self), "__fable_field_names__", slots) + return ( + "{ " + + "\n ".join( + f"{field_name} = {_field_to_string(getattr(self, slot))}" + for slot, field_name in zip(slots, field_names, strict=True) + ) + + " }" + ) + def record_get_hashcode(self: Record) -> int: # Hash each field with `structural_hash` rather than hashing a Python tuple, diff --git a/tests/Python/TestRecordType.fs b/tests/Python/TestRecordType.fs index 0beec0b55f..8d9ef01395 100644 --- a/tests/Python/TestRecordType.fs +++ b/tests/Python/TestRecordType.fs @@ -61,6 +61,11 @@ type StructRecord = { Size: int; Seed: int; Path: int list } type StructRecordHolder = { Tag: int; Data: StructRecord } +[] +let ``test sprintf formats record fields with their F# names`` () = + sprintf "%A" { Size = 1; Seed = 2; Path = [] } + |> equal "{ Size = 1\n Seed = 2\n Path = [] }" + let updateStructRecord (data: StructRecord) (path: int list) = { data with Path = List.rev path } let holdStructRecord (data: StructRecord) (path: int list) =