Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ dev = [
"ruff>=0.11.6,<0.12",
"maturin>=1.8.3,<2",
"dunamai>=1.23.1,<2",
"pydantic>=2.11.7",
"attrs>=25.3.0",
]

[tool.hatch.build.targets.sdist]
Expand Down
1 change: 1 addition & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* [Python] Added `Decorate` attribute to add Python decorators to classes (by @dbrattli)
* [Python] Added `ClassAttributes` attribute to control Python class generation (@dbrattli)

### Fixed
Expand Down
1 change: 1 addition & 0 deletions src/Fable.Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* [Python] Added `Decorate` attribute to add Python decorators to classes (by @dbrattli)
* [Python] Added `ClassAttributes` attribute to control Python class generation (@dbrattli)

## 5.0.0-beta.1 - 2025-07-25
Expand Down
23 changes: 23 additions & 0 deletions src/Fable.Core/Fable.Core.Py.fs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ module Py =
abstract Decorate: fn: Callable * info: Reflection.MethodInfo -> Callable

/// <summary>
/// Adds Python decorators to generated classes, enabling integration with Python
/// frameworks like dataclasses, attrs, functools, and any other decorator-based
/// libraries.
/// </summary>
/// <remarks>
/// <para>The [&lt;Decorate&gt;] attribute is purely for Python interop and does NOT
/// affect F# compilation behavior.</para>
/// <para>Multiple [&lt;Decorate&gt;] attributes are applied in reverse order
/// (bottom to top), following Python's standard decorator stacking behavior.</para>
/// <para>Examples:</para>
/// <para>[&lt;Decorate("dataclasses.dataclass")&gt;] - Simple decorator</para>
/// <para>[&lt;Decorate("functools.lru_cache", "maxsize=128")&gt;] - Decorator with
/// parameters</para>
/// </remarks>
[<AttributeUsage(AttributeTargets.Class, AllowMultiple = true)>]
type DecorateAttribute(decorator: string) =
inherit Attribute()

new(decorator: string, parameters: string) = DecorateAttribute(decorator)

member val Decorator: string = decorator with get, set
member val Parameters: string = "" with get, set

/// Used on a class to provide Python-specific control over how F# types are transpiled to Python classes.
/// This attribute implies member attachment (similar to AttachMembers) while offering Python-specific parameters.
/// </summary>
Expand Down
51 changes: 7 additions & 44 deletions src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2604,14 +2604,20 @@ let declareClassType
else
[]

// Generate custom decorators from [<Decorate>] attributes
let customDecorators =
let decoratorInfos = Util.getDecoratorInfo ent.Attributes
Util.generateDecorators com ctx decoratorInfos

stmts
@ [
Statement.classDef (
name,
body = classBody,
bases = bases @ interfaces,
typeParams = typeParams,
keywords = keywords
keywords = keywords,
decoratorList = customDecorators
)
]

Expand Down Expand Up @@ -2794,49 +2800,6 @@ let generateStaticPropertySetter
| [ valueArg ] -> Arguments.arguments [ valueArg ]
| _ -> Arguments.arguments [ Arg.arg "value" ]

// Transform setter body to prevent infinite recursion
// Why: Without this, setter does "User.Name = value" → triggers metaclass → calls setter → infinite loop!
// How: Replace "ClassName.PropertyName = value" with "ClassName.PropertyName_0040 = value" (backing field)
// This bypasses the metaclass and descriptor, preventing recursion
// let transformedBody =
// setterBody
// |> List.map (fun stmt ->
// match stmt with
// | Statement.Assign {
// Targets = [ Expression.Attribute {
// Value = Expression.Name {
// Id = Identifier className_
// }
// Attr = Identifier propName
// } as target ]
// Value = value
// } when className_ = className && propName = propertyName ->
// // Replace property assignment with backing field assignment
// let backingFieldName = $"{propertyName}_0040" // Use backing field naming convention

// let backingFieldTarget =
// Expression.attribute (Expression.name className_, Identifier backingFieldName)

// Statement.assign ([ backingFieldTarget ], value)
// | _ -> stmt
// )

// Filter out any remaining assignments to backing fields that start with underscore
//let filteredBody =
// transformedBody
// |> List.filter (fun stmt ->
// match stmt with
// | Statement.Assign {
// Targets = [ Expression.Attribute {
// Value = Expression.Name {
// Id = Identifier className_
// }
// Attr = Identifier field
// } ]
// } when className_ = className && field.StartsWith("_") -> false // Remove backing field assignments
// | _ -> true
// )

Statement.functionDef (functionNameIdent, setterArgs, body = setterBody)

// Check if a class needs StaticProperty descriptor (has static properties)
Expand Down
7 changes: 7 additions & 0 deletions src/Fable.Transforms/Python/Fable2Python.Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ type FieldNamingKind =
| InstancePropertyBacking
| StaticProperty

/// Represents a Python decorator extracted from F# attributes
type DecoratorInfo =
{
Decorator: string
Parameters: string
}


/// Represents different styles of Python class generation
[<RequireQualifiedAccess>]
Expand Down
57 changes: 56 additions & 1 deletion src/Fable.Transforms/Python/Fable2Python.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,61 @@ module Util =
)
|> Option.defaultValue defaultParams

/// Parses a decorator string to extract module and function/class name
let parseDecorator (decorator: string) =
match decorator.Split('.') with
| [| functionName |] -> None, functionName // No module, just function name
| parts when parts.Length >= 2 ->
let moduleName = parts.[0 .. (parts.Length - 2)] |> String.concat "."
let functionName = parts.[parts.Length - 1]
Some moduleName, functionName
| _ -> None, decorator // Fallback

/// Extracts decorator information from entity attributes
let getDecoratorInfo (atts: Fable.Attribute seq) =
atts
|> Seq.choose (fun att ->
if att.Entity.FullName = Atts.pyDecorate then
match att.ConstructorArgs with
| [ :? string as decorator ] ->
Some
{
Decorator = decorator
Parameters = ""
}
| [ :? string as decorator; :? string as parameters ] ->
Some
{
Decorator = decorator
Parameters = parameters
}
| _ -> None // Invalid decorator
else
None
)
|> Seq.toList

/// Generates Python decorator expressions from DecoratorInfo
let generateDecorators (com: IPythonCompiler) (ctx: Context) (decoratorInfos: DecoratorInfo list) =
decoratorInfos
|> List.map (fun info ->
let moduleName, functionName = parseDecorator info.Decorator

let decoratorExpr =
match moduleName with
| Some module_ -> com.GetImportExpr(ctx, module_, functionName)
| None -> Expression.name functionName

if String.IsNullOrEmpty info.Parameters then
// Simple decorator without parameters: @decorator
decoratorExpr
else
// Decorator with parameters: @decorator(param1=value1, param2=value2)
// For parameters, we emit the full decorator call as raw Python code
// This preserves exact parameter syntax for maximum flexibility
Expression.emit ($"%s{functionName}(%s{info.Parameters})", [])
)

let getIdentifier (_com: IPythonCompiler) (_ctx: Context) (name: string) =
let name = Helpers.clean name
Identifier name
Expand Down Expand Up @@ -604,7 +659,7 @@ module Helpers =
else
""

Identifier($"{name}{deliminator}{idx}")
Identifier($"%s{name}%s{deliminator}%i{idx}")

/// Replaces all '$' and `.`with '_'
let clean (name: string) =
Expand Down
19 changes: 19 additions & 0 deletions src/Fable.Transforms/Python/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,25 @@ let bigints (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg:
| NativeInt
| UNativeInt -> None
| _ -> None
| None, ("FromString" | "FromInt32" | "op_Implicit") as (_, meth) ->
let inline toBigIntConstant bi =
NumberConstant(NumberValue.BigInt bi, NumberInfo.Empty) |> makeValue r |> Some

match args with
| [ Value(StringConstant value, _) ] when meth = "FromString" ->
System.Numerics.BigInteger.Parse value |> toBigIntConstant
| [ Value(NumberConstant(NumberValue.Int32 value, _), _) ] -> bigint value |> toBigIntConstant
| [ Value(NumberConstant(NumberValue.Int64 value, _), _) ] when meth = "op_Implicit" ->
bigint value |> toBigIntConstant
| _ ->
let methodName =
if meth = "op_Implicit" then
"fromInt32"
else
Naming.lowerFirst meth

Helper.LibCall(com, "BigInt", methodName, t, args, i.SignatureArgTypes, ?loc = r)
|> Some
| None, "DivRem" ->
Helper.LibCall(com, "big_int", "divRem", t, args, i.SignatureArgTypes, ?loc = r)
|> Some
Expand Down
3 changes: 3 additions & 0 deletions src/Fable.Transforms/Transforms.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ module Atts =
[<Literal>]
let pyReflectedDecorator = "Fable.Core.Py.ReflectedDecoratorAttribute" // typeof<Fable.Core.Py.ReflectedDecoratorAttribute>.FullName

[<Literal>]
let pyDecorate = "Fable.Core.Py.DecorateAttribute" // typeof<Fable.Core.Py.DecorateAttribute>.FullName

[<Literal>]
let pyClassAttributes = "Fable.Core.Py.ClassAttributes" // typeof<Fable.Core.Py.ClassAttributes>.FullName

Expand Down
108 changes: 108 additions & 0 deletions tests/Python/TestPyInterop.fs
Original file line number Diff line number Diff line change
Expand Up @@ -427,4 +427,112 @@ let ``test createEmpty works with interfaces`` () =
user.Name |> equal "Kaladin"
user.Age |> equal 20

// Test for Pydantic compatible classes

[<Erase>]
type Field<'T> = 'T

[<Import("Field", "pydantic")>]
let Field (description: string): Field<'T> = nativeOnly

[<Import("BaseModel", "pydantic")>]
type BaseModel () = class end

// Test PythonClass attribute with attributes style
[<Py.ClassAttributes(style="attributes", init=false)>]
type PydanticUser() =
inherit BaseModel()
member val Name: Field<string> = Field("Name") with get, set
member val Age: bigint = 10I with get, set
member val Email: string option = None with get, set

[<Fact>]
let ``test PydanticUser`` () =
let user = PydanticUser()
user.Name <- "Test User"
user.Age <- 25
user.Email <- Some "test@example.com"

user.Name |> equal "Test User"

[<Py.Decorate("dataclasses.dataclass")>]
[<Py.ClassAttributes(style="attributes", init=false)>]
type DecoratedUser() =
member val Name: string = "" with get, set
member val Age: int = 0 with get, set

[<Fact>]
let ``test simple decorator without parameters`` () =
// Test that @dataclass decorator is applied correctly
let user = DecoratedUser()
user.Name <- "Test User"
user.Age <- 25

user.Name |> equal "Test User"
user.Age |> equal 25

[<Py.Decorate("functools.lru_cache", "maxsize=128")>]
[<Py.ClassAttributes(style="attributes", init=false)>]
type DecoratedCache() =
member val Value: string = "cached" with get, set

[<Fact>]
let ``test decorator with parameters`` () =
// Test that decorator with parameters is applied correctly
let cache = DecoratedCache()
cache.Value |> equal "cached"

[<Py.Decorate("dataclasses.dataclass")>]
[<Py.Decorate("functools.total_ordering")>]
[<Py.ClassAttributes(style="attributes", init=false)>]
type MultiDecoratedClass() =
member val Priority: int = 0 with get, set
member val Name: string = "" with get, set

member this.__lt__(other: MultiDecoratedClass) =
this.Priority < other.Priority

[<Fact>]
let ``test multiple decorators applied in correct order`` () =
// Test that multiple decorators are applied bottom-to-top
let obj = MultiDecoratedClass()
obj.Priority <- 1
obj.Name <- "test"

obj.Priority |> equal 1
obj.Name |> equal "test"

[<Py.Decorate("attrs.define", "auto_attribs=True, slots=True")>]
[<Py.ClassAttributes(style="attributes", init=false)>]
type AttrsDecoratedClass() =
member val Data: string = "attrs_data" with get, set
member val Count: int = 42 with get, set

[<Fact>]
let ``test complex decorator parameters`` () =
// Test decorator with complex parameter syntax
let obj = AttrsDecoratedClass()
obj.Data |> equal "attrs_data"
obj.Count |> equal 42

// Test combining Decorate with existing F# features

[<Py.Decorate("dataclasses.dataclass")>]
[<Py.ClassAttributes(style="attributes", init=false)>]
type InheritedDecoratedClass() =
inherit DecoratedUser()
member val Email: string = "" with get, set

[<Fact>]
let ``test decorator with inheritance`` () =
// Test that decorators work with class inheritance
let obj = InheritedDecoratedClass()
obj.Name <- "Inherited"
obj.Age <- 30
obj.Email <- "test@example.com"

obj.Name |> equal "Inherited"
obj.Age |> equal 30
obj.Email |> equal "test@example.com"

#endif
Loading
Loading