Skip to content

Commit fcc6d7a

Browse files
committed
[Python] Add decorateTemplateAttribute
1 parent 33c9095 commit fcc6d7a

File tree

8 files changed

+236
-38
lines changed

8 files changed

+236
-38
lines changed

src/Fable.Core/Fable.Core.Py.fs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,48 @@ module Py =
1616
[<Emit "$0">]
1717
abstract Instance: obj
1818

19+
/// <summary>
20+
/// Base class for creating F#-side decorator attributes that transform functions at compile time.
21+
/// This is similar to Python decorators but operates during Fable compilation, not at Python runtime.
22+
/// </summary>
23+
/// <remarks>
24+
/// <para>Inherit from this class and implement the Decorate method to wrap or transform functions.</para>
25+
/// <para>The decorated function is passed to Decorate, and the returned Callable replaces it.</para>
26+
/// <para>Example:</para>
27+
/// <code>
28+
/// type LogAttribute() =
29+
/// inherit Py.DecoratorAttribute()
30+
/// override _.Decorate(fn) =
31+
/// Py.argsFunc (fun args ->
32+
/// printfn "Calling function"
33+
/// fn.Invoke(args))
34+
/// </code>
35+
/// <para>Note: This does NOT emit Python @decorator syntax. For emitting Python decorators,
36+
/// use DecorateAttribute or DecorateTemplateAttribute instead.</para>
37+
/// </remarks>
1938
[<AbstractClass>]
2039
type DecoratorAttribute() =
2140
inherit Attribute()
2241
abstract Decorate: fn: Callable -> Callable
2342

43+
/// <summary>
44+
/// Base class for creating F#-side decorator attributes with access to reflection metadata.
45+
/// Like DecoratorAttribute, but the Decorate method also receives MethodInfo for the decorated member.
46+
/// </summary>
47+
/// <remarks>
48+
/// <para>Use this when your decorator needs information about the decorated method (name, parameters, etc.).</para>
49+
/// <para>Example:</para>
50+
/// <code>
51+
/// type LogWithNameAttribute() =
52+
/// inherit Py.ReflectedDecoratorAttribute()
53+
/// override _.Decorate(fn, info) =
54+
/// Py.argsFunc (fun args ->
55+
/// printfn "Calling %s" info.Name
56+
/// fn.Invoke(args))
57+
/// </code>
58+
/// <para>Note: This does NOT emit Python @decorator syntax. For emitting Python decorators,
59+
/// use DecorateAttribute or DecorateTemplateAttribute instead.</para>
60+
/// </remarks>
2461
[<AbstractClass>]
2562
type ReflectedDecoratorAttribute() =
2663
inherit Attribute()
@@ -49,7 +86,31 @@ module Py =
4986
/// Decorator with import but no parameters
5087
new(decorator: string, importFrom: string) = DecorateAttribute(decorator, importFrom, "")
5188

52-
/// Decorator with all parameters
89+
/// <summary>
90+
/// Marks a custom attribute class as a decorator template, enabling library authors to create
91+
/// ergonomic decorator attributes that users can apply without knowing the underlying Python syntax.
92+
/// </summary>
93+
/// <remarks>
94+
/// <para>Place this attribute on a custom attribute class. The template string uses {0}, {1}, etc.
95+
/// as placeholders for the custom attribute's constructor arguments.</para>
96+
/// <para>Example - defining a custom decorator attribute:</para>
97+
/// <code>
98+
/// [&lt;Py.DecorateTemplate("app.get(\"{0}\")")&gt;]
99+
/// type GetAttribute(path: string) = inherit Attribute()
100+
/// </code>
101+
/// <para>Example - using the custom decorator:</para>
102+
/// <code>
103+
/// [&lt;Get("/users")&gt;]
104+
/// static member get_users() = ...
105+
/// // Generates: @app.get("/users")
106+
/// </code>
107+
/// </remarks>
108+
[<AttributeUsage(AttributeTargets.Class)>]
109+
type DecorateTemplateAttribute(template: string) =
110+
inherit Attribute()
111+
/// Template with import specification
112+
new(template: string, importFrom: string) = DecorateTemplateAttribute(template)
113+
53114
/// <summary>
54115
/// Marks a static member to be emitted as a Python @classmethod instead of @staticmethod.
55116
/// </summary>

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2674,7 +2674,7 @@ let declareClassType
26742674

26752675
// Generate custom decorators from [<Decorate>] attributes
26762676
let customDecorators =
2677-
let decoratorInfos = Util.getDecoratorInfo ent.Attributes
2677+
let decoratorInfos = Util.getDecoratorInfo com ent.Attributes
26782678
Util.generateDecorators com ctx decoratorInfos
26792679

26802680
stmts
@@ -2908,7 +2908,7 @@ let transformAttachedProperty
29082908
// Instance properties use the traditional approach (properties style)
29092909
// Get custom decorators from Py.Decorate attributes
29102910
let customDecorators =
2911-
let decoratorInfos = Util.getDecoratorInfo info.Attributes
2911+
let decoratorInfos = Util.getDecoratorInfo com info.Attributes
29122912
Util.generateDecorators com ctx decoratorInfos
29132913

29142914
let decorators =
@@ -2960,7 +2960,7 @@ let transformAttachedMethod (com: IPythonCompiler) ctx (info: Fable.MemberFuncti
29602960

29612961
// Get custom decorators from Py.Decorate attributes
29622962
let customDecorators =
2963-
let decoratorInfos = Util.getDecoratorInfo info.Attributes
2963+
let decoratorInfos = Util.getDecoratorInfo com info.Attributes
29642964
Util.generateDecorators com ctx decoratorInfos
29652965

29662966
let decorators =
@@ -3723,6 +3723,9 @@ let rec transformDeclaration (com: IPythonCompiler) ctx (decl: Fable.Declaration
37233723
elif hasEraseAttribute && ent.IsInterface then
37243724
// Erased interfaces should not generate any code
37253725
[]
3726+
elif hasEraseAttribute then
3727+
// Erased classes (e.g., custom attribute types) should not generate any code
3728+
[]
37263729
else
37273730
// Check for PythonClass attribute and extract parameters
37283731
let classAttributes =

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,36 @@ module Util =
7070
)
7171
|> Option.defaultValue defaultParams
7272

73+
/// Tries to find a DecorateTemplateAttribute on the attribute's type
74+
/// and format the template with the attribute's constructor args
75+
let private tryGetTemplateDecoratorInfo (com: Fable.Compiler) (att: Fable.Attribute) =
76+
com.TryGetEntity(att.Entity)
77+
|> Option.bind (fun attEntity ->
78+
attEntity.Attributes
79+
|> Seq.tryFind (fun a -> a.Entity.FullName = Atts.pyDecorateTemplate)
80+
|> Option.bind (fun templateAtt ->
81+
match templateAtt.ConstructorArgs with
82+
| [ :? string as template ] ->
83+
Some
84+
{
85+
Decorator = String.Format(template, att.ConstructorArgs |> List.toArray)
86+
Parameters = ""
87+
ImportFrom = ""
88+
}
89+
| [ :? string as template; :? string as importFrom ] ->
90+
Some
91+
{
92+
Decorator = String.Format(template, att.ConstructorArgs |> List.toArray)
93+
Parameters = ""
94+
ImportFrom = importFrom
95+
}
96+
| _ -> None
97+
)
98+
)
99+
73100
/// Extracts decorator information from entity attributes
74101
/// Constructor args order: (decorator, importFrom, parameters)
75-
let getDecoratorInfo (atts: Fable.Attribute seq) =
102+
let getDecoratorInfo (com: Fable.Compiler) (atts: Fable.Attribute seq) =
76103
atts
77104
|> Seq.choose (fun att ->
78105
if att.Entity.FullName = Atts.pyDecorate then
@@ -100,7 +127,8 @@ module Util =
100127
}
101128
| _ -> None // Invalid decorator
102129
else
103-
None
130+
// Try to find a DecorateTemplateAttribute on this attribute's type
131+
tryGetTemplateDecoratorInfo com att
104132
)
105133
|> Seq.toList
106134

src/Fable.Transforms/Transforms.Util.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ module Atts =
124124
[<Literal>]
125125
let pyDecorate = "Fable.Core.Py.DecorateAttribute" // typeof<Fable.Core.Py.DecorateAttribute>.FullName
126126

127+
[<Literal>]
128+
let pyDecorateTemplate = "Fable.Core.Py.DecorateTemplateAttribute" // typeof<Fable.Core.Py.DecorateTemplateAttribute>.FullName
129+
127130
[<Literal>]
128131
let pyClassAttributes = "Fable.Core.Py.ClassAttributes" // typeof<Fable.Core.Py.ClassAttributes>.FullName
129132

tests/Python/TestPyInterop.fs

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,26 +283,26 @@ type NativeCode =
283283

284284
[<Fact>]
285285
let ``test importAll`` () =
286-
let nativeCode: NativeCode = importAll "./native_code.py"
286+
let nativeCode: NativeCode = importAll "./py/native_code.py"
287287
3 |> nativeCode.add5 |> equal 8
288288

289-
let add5 (x: int): int = importMember "./native_code.py"
289+
let add5 (x: int): int = importMember "./py/native_code.py"
290290

291291
[<Fact>]
292292
let ``test importMember`` () =
293293
add5 -1 |> equal 4
294294

295295
// Cannot use the same name as Fable will mangle the identifier
296-
let add7: int -> int = importMember "./native_code.py"
296+
let add7: int -> int = importMember "./py/native_code.py"
297297
add7 12 |> equal 19
298298

299-
let add5': int -> int = import "add5" "./native_code.py"
299+
let add5': int -> int = import "add5" "./py/native_code.py"
300300
add5' 12 |> equal 17
301301

302-
let multiply3 (x: int): int = importMember "./more_native_code.py"
302+
let multiply3 (x: int): int = importMember "./py/more_native_code.py"
303303
multiply3 9 |> equal 27
304304

305-
[<ImportAll("./native_code.py")>]
305+
[<ImportAll("./py/native_code.py")>]
306306
let nativeCode: NativeCode = nativeOnly
307307

308308
[<Fact>]
@@ -612,7 +612,7 @@ let ``test PropertiesUserWithInit`` () =
612612
// Test Py.Decorate without import (local variable decorator like FastAPI app.get)
613613

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

617617
[<AttachMembers>]
618618
type ClassWithLocalDecorator() =
@@ -722,4 +722,64 @@ let ``test fable_library version is local build`` () =
722722
// instead of the locally built fable-library
723723
equal "0.0.0" fableLibraryVersion
724724

725+
// Test DecorateTemplate attribute for creating custom decorator attributes
726+
// This demonstrates how library authors can create ergonomic FastAPI-style attributes
727+
728+
// Simulated FastAPI app instance (imported from native code)
729+
let app: obj = import "app" "./py/native_code.py"
730+
731+
// Custom decorator attributes using DecorateTemplate
732+
// Library authors define these once, users get clean syntax
733+
[<Erase; Py.DecorateTemplate("app.get(\"{0}\")")>]
734+
type GetAttribute(path: string) =
735+
inherit Attribute()
736+
737+
[<Erase; Py.DecorateTemplate("app.post(\"{0}\")")>]
738+
type PostAttribute(path: string) =
739+
inherit Attribute()
740+
741+
[<Erase; Py.DecorateTemplate("app.delete(\"{0}\")")>]
742+
type DeleteAttribute(path: string) =
743+
inherit Attribute()
744+
745+
// Users get clean, intuitive API similar to Python FastAPI:
746+
[<Py.ClassAttributes(style = Py.ClassAttributeStyle.Attributes, init = false)>]
747+
type TestAPI() =
748+
[<Get("/")>]
749+
static member root() = {| message = "Hello World" |}
750+
751+
[<Get("/items/{item_id}")>]
752+
static member get_item(item_id: int) = {| item_id = item_id |}
753+
754+
[<Post("/items")>]
755+
static member create_item(name: string) = {| name = name; created = true |}
756+
757+
[<Delete("/items/{item_id}")>]
758+
static member delete_item(item_id: int) = {| deleted = item_id |}
759+
760+
[<Fact>]
761+
let ``test DecorateTemplate generates FastAPI-style decorators`` () =
762+
// Test that @app.get("/") decorator is applied and works
763+
let result = TestAPI.root()
764+
result.["message"] |> equal "Hello World"
765+
766+
[<Fact>]
767+
let ``test DecorateTemplate with path parameter`` () =
768+
// Test that @app.get("/items/{item_id}") decorator works with path params
769+
let result = TestAPI.get_item(42)
770+
result.["item_id"] |> equal 42
771+
772+
[<Fact>]
773+
let ``test DecorateTemplate POST endpoint`` () =
774+
// Test that @app.post("/items") decorator works
775+
let result = TestAPI.create_item("test-item")
776+
result.["name"] |> equal "test-item"
777+
result.["created"] |> equal true
778+
779+
[<Fact>]
780+
let ``test DecorateTemplate DELETE endpoint`` () =
781+
// Test that @app.delete("/items/{item_id}") decorator works
782+
let result = TestAPI.delete_item(123)
783+
result.["deleted"] |> equal 123
784+
725785
#endif

tests/Python/native_code.py

Lines changed: 0 additions & 25 deletions
This file was deleted.

tests/Python/py/native_code.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from collections.abc import Callable
2+
from typing import ParamSpec, TypeVar
3+
4+
5+
P = ParamSpec("P")
6+
R = TypeVar("R")
7+
8+
9+
# Simulated FastAPI-like app for testing DecorateTemplate
10+
class MockFastAPI:
11+
"""A mock FastAPI app that simulates route decorators."""
12+
13+
def get(self, path: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
14+
"""Simulate @app.get(path) decorator."""
15+
16+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
17+
return func
18+
19+
return decorator
20+
21+
def post(self, path: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
22+
"""Simulate @app.post(path) decorator."""
23+
24+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
25+
return func
26+
27+
return decorator
28+
29+
def delete(self, path: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
30+
"""Simulate @app.delete(path) decorator."""
31+
32+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
33+
return func
34+
35+
return decorator
36+
37+
def put(self, path: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
38+
"""Simulate @app.put(path) decorator."""
39+
40+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
41+
return func
42+
43+
return decorator
44+
45+
46+
# Global app instance for tests
47+
app = MockFastAPI()
48+
49+
50+
def add5(x: int) -> int:
51+
return x + 5
52+
53+
54+
def add7(x: int) -> int:
55+
return x + 7
56+
57+
58+
def my_decorator(path: str) -> Callable[[Callable[P, R]], Callable[P, str]]:
59+
"""A decorator factory that simulates FastAPI's app.get/post pattern."""
60+
61+
def decorator(func: Callable[P, R]) -> Callable[P, str]:
62+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> str:
63+
result = func(*args, **kwargs)
64+
return f"decorated: {result}"
65+
66+
return wrapper
67+
68+
return decorator

0 commit comments

Comments
 (0)