Skip to content

Commit e176121

Browse files
authored
feat(python): class method attribute (#4287)
* feat(python): class method attribute * doc: Added changelog entries * tests: Add pydantic field validator
1 parent 52d9915 commit e176121

File tree

8 files changed

+162
-14
lines changed

8 files changed

+162
-14
lines changed

src/Fable.Cli/CHANGELOG.md

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

1414
### Added
1515

16+
* [Python] Add support for [<Py.Decorate>] attribute on methods (previously only worked on classes)
17+
* [Python] Add new [<Py.ClassMethod>] attribute to emit @classmethod instead of @staticmethod
1618
* [Python] Added support for Pydantic serialization of core numeric and array types (by @dbrattli)
1719

1820
## 5.0.0-alpha.18 - 2025-12-03

src/Fable.Compiler/CHANGELOG.md

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

1414
### Added
1515

16+
* [Python] Add support for [<Py.Decorate>] attribute on methods (previously only worked on classes)
17+
* [Python] Add new [<Py.ClassMethod>] attribute to emit @classmethod instead of @staticmethod
1618
* [Python] Added support for Pydantic serialization of core numeric and array types (by @dbrattli)
1719

1820
## 5.0.0-alpha.17 - 2025-12-03

src/Fable.Core/CHANGELOG.md

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

88
## Unreleased
99

10+
### Added
11+
12+
* [Python] Add support for [<Py.Decorate>] attribute on methods (previously only worked on classes)
13+
* [Python] Add new [<Py.ClassMethod>] attribute to emit @classmethod instead of @staticmethod
14+
1015
## 5.0.0-beta.2 - 2025-11-19
1116

1217
### Added

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ module Py =
2828
abstract Decorate: fn: Callable * info: Reflection.MethodInfo -> Callable
2929

3030
/// <summary>
31-
/// Adds Python decorators to generated classes, enabling integration with Python
32-
/// frameworks like dataclasses, attrs, functools, and any other decorator-based
31+
/// Adds Python decorators to generated classes or methods, enabling integration with Python
32+
/// frameworks like dataclasses, attrs, functools, Pydantic, and any other decorator-based
3333
/// libraries.
3434
/// </summary>
3535
/// <remarks>
@@ -38,15 +38,33 @@ module Py =
3838
/// <para>Multiple [&lt;Decorate&gt;] attributes are applied in reverse order
3939
/// (bottom to top), following Python's standard decorator stacking behavior.</para>
4040
/// <para>Examples:</para>
41-
/// <para>[&lt;Decorate("dataclasses.dataclass")&gt;] - Simple decorator</para>
41+
/// <para>[&lt;Decorate("dataclasses.dataclass")&gt;] - Simple class decorator</para>
4242
/// <para>[&lt;Decorate("functools.lru_cache", "maxsize=128")&gt;] - Decorator with
4343
/// parameters</para>
44+
/// <para>[&lt;Decorate("pydantic.field_validator", "'Name'")&gt;] - Method decorator for
45+
/// Pydantic validators</para>
4446
/// </remarks>
45-
[<AttributeUsage(AttributeTargets.Class, AllowMultiple = true)>]
47+
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Method, AllowMultiple = true)>]
4648
type DecorateAttribute(decorator: string) =
4749
inherit Attribute()
4850
new(decorator: string, parameters: string) = DecorateAttribute(decorator)
4951

52+
/// <summary>
53+
/// Marks a static member to be emitted as a Python @classmethod instead of @staticmethod.
54+
/// </summary>
55+
/// <remarks>
56+
/// <para>Use this attribute on static members when you need the first parameter to receive
57+
/// the class (cls) instead of being a regular static method.</para>
58+
/// <para>This is commonly needed for Pydantic validators and other Python frameworks that
59+
/// require @classmethod decorators.</para>
60+
/// <para>Example:</para>
61+
/// <para>[&lt;Py.ClassMethod&gt;]</para>
62+
/// <para>static member validate_name(cls, v: string) = ...</para>
63+
/// </remarks>
64+
[<AttributeUsage(AttributeTargets.Method)>]
65+
type ClassMethodAttribute() =
66+
inherit Attribute()
67+
5068
[<RequireQualifiedAccess>]
5169
type ClassAttributeStyle =
5270
// Translates to properties with instance attributes backing

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2906,8 +2906,14 @@ let transformAttachedProperty
29062906
[]
29072907
else
29082908
// Instance properties use the traditional approach (properties style)
2909+
// Get custom decorators from Py.Decorate attributes
2910+
let customDecorators =
2911+
let decoratorInfos = Util.getDecoratorInfo info.Attributes
2912+
Util.generateDecorators com ctx decoratorInfos
2913+
29092914
let decorators =
2910-
[
2915+
customDecorators
2916+
@ [
29112917
if isGetter then
29122918
Expression.name "property"
29132919
else
@@ -2947,11 +2953,26 @@ let transformAttachedMethod (com: IPythonCompiler) ctx (info: Fable.MemberFuncti
29472953

29482954
let isStatic = not info.IsInstance
29492955

2956+
// Check if this method has Py.ClassMethod attribute
2957+
let hasClassMethodAttribute =
2958+
info.Attributes
2959+
|> Seq.exists (fun att -> att.Entity.FullName = Atts.pyClassMethod)
2960+
2961+
// Get custom decorators from Py.Decorate attributes
2962+
let customDecorators =
2963+
let decoratorInfos = Util.getDecoratorInfo info.Attributes
2964+
Util.generateDecorators com ctx decoratorInfos
2965+
29502966
let decorators =
2951-
if isStatic then
2952-
[ Expression.name "staticmethod" ]
2953-
else
2954-
[]
2967+
// Custom decorators come first (outermost), then static/classmethod decorator
2968+
customDecorators
2969+
@ if isStatic then
2970+
if hasClassMethodAttribute then
2971+
[ Expression.name "classmethod" ]
2972+
else
2973+
[ Expression.name "staticmethod" ]
2974+
else
2975+
[]
29552976

29562977
let makeMethod name args body decorators returnType =
29572978
let key = memberFromName com ctx name |> nameFromKey com ctx

src/Fable.Transforms/Transforms.Util.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ module Atts =
127127
[<Literal>]
128128
let pyClassAttributes = "Fable.Core.Py.ClassAttributes" // typeof<Fable.Core.Py.ClassAttributes>.FullName
129129

130+
[<Literal>]
131+
let pyClassMethod = "Fable.Core.Py.ClassMethodAttribute" // typeof<Fable.Core.Py.ClassMethodAttribute>.FullName
132+
130133
[<Literal>]
131134
let dartIsConst = "Fable.Core.Dart.IsConstAttribute" // typeof<Fable.Core.Dart.IsConstAttribute>.FullName
132135

src/fable-library-py/tests/test_array.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,11 +1339,6 @@ def test_indexed():
13391339
# assert abs(result3 - 6.0) < 1e-10
13401340

13411341

1342-
# =============================================================================
1343-
# Pydantic Integration Tests
1344-
# =============================================================================
1345-
1346-
13471342
def test_pydantic_array_as_field_type():
13481343
"""Test that FSharpArray can be used directly as a Pydantic field type."""
13491344

tests/Python/TestPyInterop.fs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,24 @@ let ``test PydanticUser with byte and int16 types`` () =
495495
model.Int16Val |> equal -32768s
496496
model.UInt16Val |> equal 65535us
497497

498+
// Test Pydantic field_validator with @classmethod
499+
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
500+
type PydanticUserWithValidator(Name: string) =
501+
inherit BaseModel()
502+
member val Name: string = Name with get, set
503+
504+
[<Py.Decorate("pydantic.field_validator", "'Name'")>]
505+
[<Py.ClassMethod>]
506+
static member validate_name(cls: obj, v: string) : string =
507+
v.ToUpper()
508+
509+
[<Fact>]
510+
let ``test Pydantic field_validator with classmethod`` () =
511+
// Test that @field_validator and @classmethod decorators are applied correctly
512+
// The validator should transform the Name to uppercase
513+
let user = emitPyExpr<PydanticUserWithValidator> [] "PydanticUserWithValidator(Name='john')"
514+
user.Name |> equal "JOHN"
515+
498516
[<Py.Decorate("dataclasses.dataclass")>]
499517
[<Py.ClassAttributes(style=Py.ClassAttributeStyle.Attributes, init=false)>]
500518
type DecoratedUser() =
@@ -590,6 +608,90 @@ let ``test PropertiesUserWithInit`` () =
590608
user.Email |> equal (Some "[email protected]")
591609
user.Enabled |> equal true
592610

611+
// Test Py.Decorate on static methods
612+
613+
[<AttachMembers>]
614+
type ClassWithDecoratedStaticMethod() =
615+
member val Value: int = 0 with get, set
616+
617+
[<Py.Decorate("functools.lru_cache")>]
618+
static member cached_function(x: int) : int =
619+
x * 2
620+
621+
[<Fact>]
622+
let ``test Py.Decorate on static method`` () =
623+
// Test that @lru_cache decorator is applied to static method
624+
let result1 = ClassWithDecoratedStaticMethod.cached_function(5)
625+
let result2 = ClassWithDecoratedStaticMethod.cached_function(5)
626+
result1 |> equal 10
627+
result2 |> equal 10
628+
629+
[<AttachMembers>]
630+
type ClassWithDecoratedStaticMethodWithParams() =
631+
[<Py.Decorate("functools.lru_cache", "maxsize=32")>]
632+
static member cached_with_params(x: int) : int =
633+
x * 3
634+
635+
[<Fact>]
636+
let ``test Py.Decorate on static method with parameters`` () =
637+
// Test that decorator with parameters is applied to static method
638+
let result = ClassWithDecoratedStaticMethodWithParams.cached_with_params(4)
639+
result |> equal 12
640+
641+
[<AttachMembers>]
642+
type ClassWithClassMethod() =
643+
member val Name: string = "" with get, set
644+
645+
// Note: With @classmethod, Python automatically passes `cls` as first argument
646+
// So from F# we only pass the remaining arguments
647+
[<Py.ClassMethod>]
648+
static member create_instance(cls: obj, name: string) : ClassWithClassMethod =
649+
let instance = ClassWithClassMethod()
650+
instance.Name <- name
651+
instance
652+
653+
[<Fact>]
654+
let ``test Py.ClassMethod attribute`` () =
655+
// Test that @classmethod decorator is applied instead of @staticmethod
656+
// Note: cls is automatically passed by Python, so we only pass the name argument
657+
// We use Emit to call the method without the cls argument from F#
658+
let instance = emitPyExpr<ClassWithClassMethod> [] "ClassWithClassMethod.create_instance('TestName')"
659+
instance.Name |> equal "TestName"
660+
661+
662+
[<AttachMembers>]
663+
type ClassWithMultipleDecoratedMethods() =
664+
[<Py.Decorate("functools.lru_cache", "maxsize=16")>]
665+
static member method_a(x: int) : int = x * 2
666+
667+
[<Py.Decorate("functools.lru_cache", "maxsize=32")>]
668+
static member method_b(x: int) : int = x * 3
669+
670+
[<Fact>]
671+
let ``test multiple static methods with decorators`` () =
672+
// Test that decorators work on multiple static methods in same class
673+
let resultA = ClassWithMultipleDecoratedMethods.method_a(5)
674+
let resultB = ClassWithMultipleDecoratedMethods.method_b(5)
675+
resultA |> equal 10
676+
resultB |> equal 15
677+
678+
[<AttachMembers>]
679+
type ClassWithDecoratedInstanceMethod() =
680+
member val CallCount: int = 0 with get, set
681+
682+
[<Py.Decorate("functools.lru_cache", "maxsize=16")>]
683+
member this.cached_instance_method(x: int) : int =
684+
x * 4
685+
686+
[<Fact>]
687+
let ``test Py.Decorate on instance method`` () =
688+
// Test that decorator works on instance methods too
689+
let obj = ClassWithDecoratedInstanceMethod()
690+
let result1 = obj.cached_instance_method(3)
691+
let result2 = obj.cached_instance_method(3)
692+
result1 |> equal 12
693+
result2 |> equal 12
694+
593695
// Import fable_library version to verify we're using local build, not PyPI
594696
let fableLibraryVersion: string = import "__version__" "fable_library._version"
595697

0 commit comments

Comments
 (0)