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
4 changes: 4 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [Python] Fix regression `%A` format specifier to output booleans as lowercase `true`/`false` (by @dbrattli)

### Changed

* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)

## 5.0.0-alpha.19 - 2025-12-04

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [Python] Fix regression `%A` format specifier to output booleans as lowercase `true`/`false` (by @dbrattli)

### Changed

* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)

## 5.0.0-alpha.18 - 2025-12-04

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

## Unreleased

### Changed

* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)

## 5.0.0-beta.3 - 2025-12-04

### Added
Expand Down
20 changes: 10 additions & 10 deletions src/Fable.Core/Fable.Core.Py.fs
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,23 @@ module Py =
/// libraries.
/// </summary>
/// <remarks>
/// <para>The [&lt;Decorate&gt;] attribute is purely for Python interop and does NOT
/// affect F# compilation behavior.</para>
/// <para>The decorator is emitted verbatim. Use importFrom to specify the module to import from.</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 class decorator</para>
/// <para>[&lt;Decorate("functools.lru_cache", "maxsize=128")&gt;] - Decorator with
/// parameters</para>
/// <para>[&lt;Decorate("pydantic.field_validator", "'Name'")&gt;] - Method decorator for
/// Pydantic validators</para>
/// <para>[&lt;Decorate("dataclass", "dataclasses")&gt;] - imports dataclass from dataclasses</para>
/// <para>[&lt;Decorate("lru_cache", importFrom="functools", parameters="maxsize=128")&gt;] - with import and parameters</para>
/// <para>[&lt;Decorate("app.get(\"/\")")&gt;] - Local variable decorator with parameters (no import needed)</para>
/// </remarks>
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Method, AllowMultiple = true)>]
type DecorateAttribute(decorator: string) =
type DecorateAttribute(decorator: string, importFrom: string, parameters: string) =
inherit Attribute()
new(decorator: string, parameters: string) = DecorateAttribute(decorator)
/// Decorator without import or parameters
new(decorator: string) = DecorateAttribute(decorator, "", "")
/// Decorator with import but no parameters
new(decorator: string, importFrom: string) = DecorateAttribute(decorator, importFrom, "")

/// Decorator with all parameters
/// <summary>
/// Marks a static member to be emitted as a Python @classmethod instead of @staticmethod.
/// </summary>
Expand All @@ -65,7 +66,6 @@ module Py =
type ClassMethodAttribute() =
inherit Attribute()

[<RequireQualifiedAccess>]
type ClassAttributeStyle =
// Translates to properties with instance attributes backing
| Properties = 0
Expand Down
1 change: 1 addition & 0 deletions src/Fable.Transforms/Python/Fable2Python.Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type DecoratorInfo =
{
Decorator: string
Parameters: string
ImportFrom: string
}


Expand Down
40 changes: 19 additions & 21 deletions src/Fable.Transforms/Python/Fable2Python.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,8 @@ 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
/// Constructor args order: (decorator, importFrom, parameters)
let getDecoratorInfo (atts: Fable.Attribute seq) =
atts
|> Seq.choose (fun att ->
Expand All @@ -91,12 +82,21 @@ module Util =
{
Decorator = decorator
Parameters = ""
ImportFrom = ""
}
| [ :? string as decorator; :? string as parameters ] ->
| [ :? string as decorator; :? string as importFrom ] ->
Some
{
Decorator = decorator
Parameters = ""
ImportFrom = importFrom
}
| [ :? string as decorator; :? string as importFrom; :? string as parameters ] ->
Some
{
Decorator = decorator
Parameters = parameters
ImportFrom = importFrom
}
| _ -> None // Invalid decorator
else
Expand All @@ -105,24 +105,22 @@ module Util =
|> Seq.toList

/// Generates Python decorator expressions from DecoratorInfo
/// The decorator string is emitted verbatim. If ImportFrom is specified,
/// an import statement is generated automatically.
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
// Handle import if ImportFrom is specified
if not (String.IsNullOrEmpty info.ImportFrom) then
// This triggers the import: from {importFrom} import {decorator}
com.GetImportExpr(ctx, info.ImportFrom, info.Decorator) |> ignore

if String.IsNullOrEmpty info.Parameters then
// Simple decorator without parameters: @decorator
decoratorExpr
Expression.emit (info.Decorator, [])
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})", [])
Expression.emit ($"%s{info.Decorator}(%s{info.Parameters})", [])
)

let getIdentifier (_com: IPythonCompiler) (_ctx: Context) (name: string) =
Expand Down
56 changes: 38 additions & 18 deletions tests/Python/TestPyInterop.fs
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ type PydanticUserWithValidator(Name: string) =
inherit BaseModel()
member val Name: string = Name with get, set

[<Py.Decorate("pydantic.field_validator", "'Name'")>]
[<Py.Decorate("field_validator", importFrom="pydantic", parameters="'Name'")>]
[<Py.ClassMethod>]
static member validate_name(cls: obj, v: string) : string =
v.ToUpper()
Expand All @@ -513,7 +513,7 @@ let ``test Pydantic field_validator with classmethod`` () =
let user = emitPyExpr<PydanticUserWithValidator> [] "PydanticUserWithValidator(Name='john')"
user.Name |> equal "JOHN"

[<Py.Decorate("dataclasses.dataclass")>]
[<Py.Decorate("dataclass", importFrom="dataclasses")>]
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
type DecoratedUser() =
member val Name: string = "" with get, set
Expand All @@ -529,19 +529,20 @@ let ``test simple decorator without parameters`` () =
user.Name |> equal "Test User"
user.Age |> equal 25

[<Py.Decorate("functools.lru_cache", "maxsize=128")>]
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
type DecoratedCache() =
member val Value: string = "cached" with get, set
[<AttachMembers>]
type DecoratedCacheClass() =
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=128")>]
static member expensive_computation(x: int) : int =
x * x // Simulated expensive computation

[<Fact>]
let ``test decorator with parameters`` () =
// Test that decorator with parameters is applied correctly
let cache = DecoratedCache()
cache.Value |> equal "cached"
// Test that decorator with parameters is applied correctly to a method
let result = DecoratedCacheClass.expensive_computation(5)
result |> equal 25

[<Py.Decorate("dataclasses.dataclass")>]
[<Py.Decorate("functools.total_ordering")>]
[<Py.Decorate("dataclass", importFrom="dataclasses")>]
[<Py.Decorate("total_ordering", importFrom="functools")>]
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
type MultiDecoratedClass() =
member val Priority: int = 0 with get, set
Expand All @@ -560,7 +561,7 @@ let ``test multiple decorators applied in correct order`` () =
obj.Priority |> equal 1
obj.Name |> equal "test"

[<Py.Decorate("attrs.define", "auto_attribs=True, slots=True")>]
[<Py.Decorate("define", importFrom="attrs", parameters="auto_attribs=True, slots=True")>]
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
type AttrsDecoratedClass() =
member val Data: string = "attrs_data" with get, set
Expand All @@ -575,7 +576,7 @@ let ``test complex decorator parameters`` () =

// Test combining Decorate with existing F# features

[<Py.Decorate("dataclasses.dataclass")>]
[<Py.Decorate("dataclass", "dataclasses")>]
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
type InheritedDecoratedClass() =
inherit DecoratedUser()
Expand Down Expand Up @@ -608,13 +609,32 @@ let ``test PropertiesUserWithInit`` () =
user.Email |> equal (Some "[email protected]")
user.Enabled |> equal true

// Test Py.Decorate without import (local variable decorator like FastAPI app.get)

// Simulate a decorator factory (like FastAPI's app instance)
let my_decorator (path: string) : (obj -> obj) = import "my_decorator" "./native_code.py"

[<AttachMembers>]
type ClassWithLocalDecorator() =
// Use full decorator expression since my_decorator is a local variable (no import)
[<Py.Decorate("my_decorator(\"/test\")")>]
static member decorated_method() : string =
"result"

[<Fact>]
let ``test Py.Decorate without import (local variable)`` () =
// Test that decorator is emitted verbatim without auto-import
// This pattern is used for FastAPI: @app.get("/")
let result = ClassWithLocalDecorator.decorated_method()
result |> equal "decorated: result"

// Test Py.Decorate on static methods

[<AttachMembers>]
type ClassWithDecoratedStaticMethod() =
member val Value: int = 0 with get, set

[<Py.Decorate("functools.lru_cache")>]
[<Py.Decorate("lru_cache", importFrom="functools")>]
static member cached_function(x: int) : int =
x * 2

Expand All @@ -628,7 +648,7 @@ let ``test Py.Decorate on static method`` () =

[<AttachMembers>]
type ClassWithDecoratedStaticMethodWithParams() =
[<Py.Decorate("functools.lru_cache", "maxsize=32")>]
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=32")>]
static member cached_with_params(x: int) : int =
x * 3

Expand Down Expand Up @@ -661,10 +681,10 @@ let ``test Py.ClassMethod attribute`` () =

[<AttachMembers>]
type ClassWithMultipleDecoratedMethods() =
[<Py.Decorate("functools.lru_cache", "maxsize=16")>]
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=16")>]
static member method_a(x: int) : int = x * 2

[<Py.Decorate("functools.lru_cache", "maxsize=32")>]
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=32")>]
static member method_b(x: int) : int = x * 3

[<Fact>]
Expand All @@ -679,7 +699,7 @@ let ``test multiple static methods with decorators`` () =
type ClassWithDecoratedInstanceMethod() =
member val CallCount: int = 0 with get, set

[<Py.Decorate("functools.lru_cache", "maxsize=16")>]
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=16")>]
member this.cached_instance_method(x: int) : int =
x * 4

Expand Down
19 changes: 19 additions & 0 deletions tests/Python/native_code.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def add5(x: int) -> int:
return x + 5


def add7(x: int) -> int:
return x + 7


def my_decorator(path: str) -> Callable[[Callable[P, R]], Callable[P, str]]:
"""A decorator factory that simulates FastAPI's app.get/post pattern."""

def decorator(func: Callable[P, R]) -> Callable[P, str]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> str:
result = func(*args, **kwargs)
return f"decorated: {result}"

return wrapper

return decorator
4 changes: 2 additions & 2 deletions tests/Python/py/nullness.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ def maybe_null(value: str) -> str | None:
"""
if value == "ok":
return value
else:
return None

return None
Loading