Skip to content

Commit 33c9095

Browse files
authored
[Python] Changed Decorate attribute to take importFrom parameter instead of auto-importing (#4290)
* [Python] Improve Decorate attribute * Update changelogs * Add decorator * Simplify code * Removed attribute on wrong element
1 parent 5fcf708 commit 33c9095

File tree

9 files changed

+101
-51
lines changed

9 files changed

+101
-51
lines changed

src/Fable.Cli/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

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

14+
### Changed
15+
16+
* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)
17+
1418
## 5.0.0-alpha.19 - 2025-12-04
1519

1620
### Fixed

src/Fable.Compiler/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

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

14+
### Changed
15+
16+
* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)
17+
1418
## 5.0.0-alpha.18 - 2025-12-04
1519

1620
### Fixed

src/Fable.Core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Changed
11+
12+
* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)
13+
1014
## 5.0.0-beta.3 - 2025-12-04
1115

1216
### Added

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,23 @@ module Py =
3333
/// libraries.
3434
/// </summary>
3535
/// <remarks>
36-
/// <para>The [&lt;Decorate&gt;] attribute is purely for Python interop and does NOT
37-
/// affect F# compilation behavior.</para>
36+
/// <para>The decorator is emitted verbatim. Use importFrom to specify the module to import from.</para>
3837
/// <para>Multiple [&lt;Decorate&gt;] attributes are applied in reverse order
3938
/// (bottom to top), following Python's standard decorator stacking behavior.</para>
4039
/// <para>Examples:</para>
41-
/// <para>[&lt;Decorate("dataclasses.dataclass")&gt;] - Simple class decorator</para>
42-
/// <para>[&lt;Decorate("functools.lru_cache", "maxsize=128")&gt;] - Decorator with
43-
/// parameters</para>
44-
/// <para>[&lt;Decorate("pydantic.field_validator", "'Name'")&gt;] - Method decorator for
45-
/// Pydantic validators</para>
40+
/// <para>[&lt;Decorate("dataclass", "dataclasses")&gt;] - imports dataclass from dataclasses</para>
41+
/// <para>[&lt;Decorate("lru_cache", importFrom="functools", parameters="maxsize=128")&gt;] - with import and parameters</para>
42+
/// <para>[&lt;Decorate("app.get(\"/\")")&gt;] - Local variable decorator with parameters (no import needed)</para>
4643
/// </remarks>
4744
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Method, AllowMultiple = true)>]
48-
type DecorateAttribute(decorator: string) =
45+
type DecorateAttribute(decorator: string, importFrom: string, parameters: string) =
4946
inherit Attribute()
50-
new(decorator: string, parameters: string) = DecorateAttribute(decorator)
47+
/// Decorator without import or parameters
48+
new(decorator: string) = DecorateAttribute(decorator, "", "")
49+
/// Decorator with import but no parameters
50+
new(decorator: string, importFrom: string) = DecorateAttribute(decorator, importFrom, "")
5151

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

68-
[<RequireQualifiedAccess>]
6969
type ClassAttributeStyle =
7070
// Translates to properties with instance attributes backing
7171
| Properties = 0

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type DecoratorInfo =
4242
{
4343
Decorator: string
4444
Parameters: string
45+
ImportFrom: string
4546
}
4647

4748

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

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,8 @@ module Util =
7070
)
7171
|> Option.defaultValue defaultParams
7272

73-
/// Parses a decorator string to extract module and function/class name
74-
let parseDecorator (decorator: string) =
75-
match decorator.Split('.') with
76-
| [| functionName |] -> None, functionName // No module, just function name
77-
| parts when parts.Length >= 2 ->
78-
let moduleName = parts.[0 .. (parts.Length - 2)] |> String.concat "."
79-
let functionName = parts.[parts.Length - 1]
80-
Some moduleName, functionName
81-
| _ -> None, decorator // Fallback
82-
8373
/// Extracts decorator information from entity attributes
74+
/// Constructor args order: (decorator, importFrom, parameters)
8475
let getDecoratorInfo (atts: Fable.Attribute seq) =
8576
atts
8677
|> Seq.choose (fun att ->
@@ -91,12 +82,21 @@ module Util =
9182
{
9283
Decorator = decorator
9384
Parameters = ""
85+
ImportFrom = ""
9486
}
95-
| [ :? string as decorator; :? string as parameters ] ->
87+
| [ :? string as decorator; :? string as importFrom ] ->
88+
Some
89+
{
90+
Decorator = decorator
91+
Parameters = ""
92+
ImportFrom = importFrom
93+
}
94+
| [ :? string as decorator; :? string as importFrom; :? string as parameters ] ->
9695
Some
9796
{
9897
Decorator = decorator
9998
Parameters = parameters
99+
ImportFrom = importFrom
100100
}
101101
| _ -> None // Invalid decorator
102102
else
@@ -105,24 +105,22 @@ module Util =
105105
|> Seq.toList
106106

107107
/// Generates Python decorator expressions from DecoratorInfo
108+
/// The decorator string is emitted verbatim. If ImportFrom is specified,
109+
/// an import statement is generated automatically.
108110
let generateDecorators (com: IPythonCompiler) (ctx: Context) (decoratorInfos: DecoratorInfo list) =
109111
decoratorInfos
110112
|> List.map (fun info ->
111-
let moduleName, functionName = parseDecorator info.Decorator
112-
113-
let decoratorExpr =
114-
match moduleName with
115-
| Some module_ -> com.GetImportExpr(ctx, module_, functionName)
116-
| None -> Expression.name functionName
113+
// Handle import if ImportFrom is specified
114+
if not (String.IsNullOrEmpty info.ImportFrom) then
115+
// This triggers the import: from {importFrom} import {decorator}
116+
com.GetImportExpr(ctx, info.ImportFrom, info.Decorator) |> ignore
117117

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

128126
let getIdentifier (_com: IPythonCompiler) (_ctx: Context) (name: string) =

tests/Python/TestPyInterop.fs

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ type PydanticUserWithValidator(Name: string) =
501501
inherit BaseModel()
502502
member val Name: string = Name with get, set
503503

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

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

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

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

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

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

576577
// Test combining Decorate with existing F# features
577578

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

612+
// Test Py.Decorate without import (local variable decorator like FastAPI app.get)
613+
614+
// Simulate a decorator factory (like FastAPI's app instance)
615+
let my_decorator (path: string) : (obj -> obj) = import "my_decorator" "./native_code.py"
616+
617+
[<AttachMembers>]
618+
type ClassWithLocalDecorator() =
619+
// Use full decorator expression since my_decorator is a local variable (no import)
620+
[<Py.Decorate("my_decorator(\"/test\")")>]
621+
static member decorated_method() : string =
622+
"result"
623+
624+
[<Fact>]
625+
let ``test Py.Decorate without import (local variable)`` () =
626+
// Test that decorator is emitted verbatim without auto-import
627+
// This pattern is used for FastAPI: @app.get("/")
628+
let result = ClassWithLocalDecorator.decorated_method()
629+
result |> equal "decorated: result"
630+
611631
// Test Py.Decorate on static methods
612632

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

617-
[<Py.Decorate("functools.lru_cache")>]
637+
[<Py.Decorate("lru_cache", importFrom="functools")>]
618638
static member cached_function(x: int) : int =
619639
x * 2
620640

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

629649
[<AttachMembers>]
630650
type ClassWithDecoratedStaticMethodWithParams() =
631-
[<Py.Decorate("functools.lru_cache", "maxsize=32")>]
651+
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=32")>]
632652
static member cached_with_params(x: int) : int =
633653
x * 3
634654

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

662682
[<AttachMembers>]
663683
type ClassWithMultipleDecoratedMethods() =
664-
[<Py.Decorate("functools.lru_cache", "maxsize=16")>]
684+
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=16")>]
665685
static member method_a(x: int) : int = x * 2
666686

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

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

682-
[<Py.Decorate("functools.lru_cache", "maxsize=16")>]
702+
[<Py.Decorate("lru_cache", importFrom="functools", parameters="maxsize=16")>]
683703
member this.cached_instance_method(x: int) : int =
684704
x * 4
685705

tests/Python/native_code.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
from typing import Callable, ParamSpec, TypeVar
2+
3+
P = ParamSpec("P")
4+
R = TypeVar("R")
5+
6+
17
def add5(x: int) -> int:
28
return x + 5
39

410

511
def add7(x: int) -> int:
612
return x + 7
13+
14+
15+
def my_decorator(path: str) -> Callable[[Callable[P, R]], Callable[P, str]]:
16+
"""A decorator factory that simulates FastAPI's app.get/post pattern."""
17+
18+
def decorator(func: Callable[P, R]) -> Callable[P, str]:
19+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> str:
20+
result = func(*args, **kwargs)
21+
return f"decorated: {result}"
22+
23+
return wrapper
24+
25+
return decorator

tests/Python/py/nullness.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@ def maybe_null(value: str) -> str | None:
1414
"""
1515
if value == "ok":
1616
return value
17-
else:
18-
return None
17+
18+
return None

0 commit comments

Comments
 (0)