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

## Unreleased

### Added

* [Python] Add `[<Py.DecorateTemplate>]` attribute for creating custom decorator attributes (by @dbrattli)
* [Python] Add `[<Py.ClassAttributesTemplate>]` attribute for creating custom class attribute shortcuts (by @dbrattli)
* [Python] Add `[<Py.DataClass>]` as a built-in shorthand for `[<Py.ClassAttributes(style = Attributes, init = false)>]` (by @dbrattli)

### Fixed

* [Python] Fix regression `[<Erase>]` on class types not preventing them from being emitted to Python (by @dbrattli)
* [Python] Fix regression `%A` format specifier to output booleans as lowercase `true`/`false` (by @dbrattli)

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

## Unreleased

### Added

* [Python] Add `[<Py.DecorateTemplate>]` attribute for creating custom decorator attributes (by @dbrattli)
* [Python] Add `[<Py.ClassAttributesTemplate>]` attribute for creating custom class attribute shortcuts (by @dbrattli)
* [Python] Add `[<Py.DataClass>]` as a built-in shorthand for `[<Py.ClassAttributes(style = Attributes, init = false)>]` (by @dbrattli)

### Fixed

* [Python] Fix regression `[<Erase>]` on class types not preventing them from being emitted to Python (by @dbrattli)

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

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

## Unreleased

### Added

* [Python] Add `[<Py.DecorateTemplate>]` attribute for creating custom decorator attributes (by @dbrattli)
* [Python] Add `[<Py.ClassAttributesTemplate>]` attribute for creating custom class attribute shortcuts (by @dbrattli)
* [Python] Add `[<Py.DataClass>]` as a built-in shorthand for `[<Py.ClassAttributes(style = Attributes, init = false)>]` (by @dbrattli)

### Changed

* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)
Expand Down
117 changes: 113 additions & 4 deletions src/Fable.Core/Fable.Core.Py.fs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,48 @@ module Py =
[<Emit "$0">]
abstract Instance: obj

/// <summary>
/// Base class for creating F#-side decorator attributes that transform functions at compile time.
/// This is similar to Python decorators but operates during Fable compilation, not at Python runtime.
/// </summary>
/// <remarks>
/// <para>Inherit from this class and implement the Decorate method to wrap or transform functions.</para>
/// <para>The decorated function is passed to Decorate, and the returned Callable replaces it.</para>
/// <para>Example:</para>
/// <code>
/// type LogAttribute() =
/// inherit Py.DecoratorAttribute()
/// override _.Decorate(fn) =
/// Py.argsFunc (fun args ->
/// printfn "Calling function"
/// fn.Invoke(args))
/// </code>
/// <para>Note: This does NOT emit Python @decorator syntax. For emitting Python decorators,
/// use DecorateAttribute or DecorateTemplateAttribute instead.</para>
/// </remarks>
[<AbstractClass>]
type DecoratorAttribute() =
inherit Attribute()
abstract Decorate: fn: Callable -> Callable

/// <summary>
/// Base class for creating F#-side decorator attributes with access to reflection metadata.
/// Like DecoratorAttribute, but the Decorate method also receives MethodInfo for the decorated member.
/// </summary>
/// <remarks>
/// <para>Use this when your decorator needs information about the decorated method (name, parameters, etc.).</para>
/// <para>Example:</para>
/// <code>
/// type LogWithNameAttribute() =
/// inherit Py.ReflectedDecoratorAttribute()
/// override _.Decorate(fn, info) =
/// Py.argsFunc (fun args ->
/// printfn "Calling %s" info.Name
/// fn.Invoke(args))
/// </code>
/// <para>Note: This does NOT emit Python @decorator syntax. For emitting Python decorators,
/// use DecorateAttribute or DecorateTemplateAttribute instead.</para>
/// </remarks>
[<AbstractClass>]
type ReflectedDecoratorAttribute() =
inherit Attribute()
Expand Down Expand Up @@ -49,7 +86,32 @@ module Py =
/// Decorator with import but no parameters
new(decorator: string, importFrom: string) = DecorateAttribute(decorator, importFrom, "")

/// Decorator with all parameters
/// <summary>
/// Marks a custom attribute class as a decorator template, enabling library authors to create
/// ergonomic decorator attributes that users can apply without knowing the underlying Python syntax.
/// </summary>
/// <remarks>
/// <para>Place this attribute on a custom attribute class. The template string uses {0}, {1}, etc.
/// as placeholders for the custom attribute's constructor arguments.</para>
/// <para>Example - defining a custom decorator attribute:</para>
/// <code>
/// [&lt;Erase; Py.DecorateTemplate("app.get('{0}')")&gt;]
/// type GetAttribute(path: string) = inherit Attribute()
/// </code>
/// <para>Example - using the custom decorator:</para>
/// <code>
/// [&lt;Get("/users")&gt;]
/// static member get_users() = ...
/// // Generates: @app.get('/users')
/// </code>
/// <para>Use [&lt;Erase&gt;] to prevent the attribute type from being emitted to Python.</para>
/// </remarks>
[<AttributeUsage(AttributeTargets.Class)>]
type DecorateTemplateAttribute(template: string) =
inherit Attribute()
/// Template with import specification
new(template: string, importFrom: string) = DecorateTemplateAttribute(template)

/// <summary>
/// Marks a static member to be emitted as a Python @classmethod instead of @staticmethod.
/// </summary>
Expand All @@ -72,6 +134,7 @@ module Py =
// Translates to class attributes
| Attributes = 1

/// <summary>
/// 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 All @@ -80,12 +143,58 @@ module Py =
/// <para>Additional Python-specific parameters control the generated Python class style and features.</para>
/// </remarks>
[<AttributeUsage(AttributeTargets.Class)>]
type ClassAttributes() =
type ClassAttributesAttribute() =
inherit Attribute()

new(style: ClassAttributeStyle) = ClassAttributes()
new(style: ClassAttributeStyle) = ClassAttributesAttribute()

new(style: ClassAttributeStyle, init: bool) = ClassAttributesAttribute()

/// <summary>
/// Marks a custom attribute class as a class attributes template, enabling library authors
/// to create ergonomic class attributes that users can apply without knowing the underlying parameters.
/// </summary>
/// <remarks>
/// <para>Place this attribute on a custom attribute class to define the class generation style.</para>
/// <para>Example - defining a custom class attribute:</para>
/// <code>
/// [&lt;Erase; Py.ClassAttributesTemplate(Py.ClassAttributeStyle.Attributes, init = false)&gt;]
/// type BaseModelAttribute() = inherit Attribute()
/// </code>
/// <para>Example - using the custom attribute:</para>
/// <code>
/// [&lt;BaseModel&gt;]
/// type User(name: string, age: int) = ...
/// // Generates class with class-level attributes, no __init__
/// </code>
/// <para>Use [&lt;Erase&gt;] to prevent the attribute type from being emitted to Python.</para>
/// </remarks>
[<AttributeUsage(AttributeTargets.Class)>]
type ClassAttributesTemplateAttribute(style: ClassAttributeStyle, init: bool) =
inherit Attribute()
/// Template with Attributes style and init = false (common for Pydantic/dataclasses)
new(style: ClassAttributeStyle) = ClassAttributesTemplateAttribute(style, false)

new(style: ClassAttributeStyle, init: bool) = ClassAttributes()
/// <summary>
/// Shorthand for [&lt;Py.ClassAttributes(style = Attributes, init = false)&gt;].
/// Use this for Python dataclasses, Pydantic models, attrs classes, or any class
/// that needs class-level type annotations without a generated __init__.
/// </summary>
/// <remarks>
/// <para>Example:</para>
/// <code>
/// [&lt;Py.DataClass&gt;]
/// type User(name: string, age: int) = ...
/// // Generates:
/// // class User:
/// // name: str
/// // age: int
/// </code>
/// </remarks>
[<Erase>]
[<ClassAttributesTemplate(ClassAttributeStyle.Attributes, false)>]
type DataClassAttribute() =
inherit Attribute()

// Hack because currently Fable doesn't keep information about spread for anonymous functions
[<Emit("lambda *args: $0(args)")>]
Expand Down
18 changes: 15 additions & 3 deletions src/Fable.Transforms/FSharp2Fable.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2111,6 +2111,20 @@ module Util =
| _ -> false
)

/// Checks if an attribute is a Python class attribute, either directly via [<Py.ClassAttributes>]
/// or indirectly via a custom attribute marked with [<Py.ClassAttributesTemplate>].
let private isPyClassAttribute (att: FSharpAttribute) =
match att.AttributeType.TryFullName with
| Some Atts.pyClassAttributes -> true
| _ ->
// Check if the attribute type itself has ClassAttributesTemplate
att.AttributeType.Attributes
|> Seq.exists (fun a ->
match a.AttributeType.TryFullName with
| Some Atts.pyClassAttributesTemplate -> true
| _ -> false
)

let isAttachMembersEntity (com: Compiler) (ent: FSharpEntity) =
not (ent.IsFSharpModule || ent.IsInterface)
&& (
Expand All @@ -2119,11 +2133,9 @@ module Util =
|| // attach all members for Rust
ent.Attributes
|> Seq.exists (fun att ->
// Should we make sure the attribute is not an alias?
match att.AttributeType.TryFullName with
| Some Atts.attachMembers -> true
| Some Atts.pyClassAttributes -> true
| _ -> false
| _ -> isPyClassAttribute att
))

let isPojoDefinedByConsArgsEntity (entity: Fable.Entity) =
Expand Down
13 changes: 8 additions & 5 deletions src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2674,7 +2674,7 @@ let declareClassType

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

stmts
Expand Down Expand Up @@ -2908,7 +2908,7 @@ let transformAttachedProperty
// Instance properties use the traditional approach (properties style)
// Get custom decorators from Py.Decorate attributes
let customDecorators =
let decoratorInfos = Util.getDecoratorInfo info.Attributes
let decoratorInfos = Util.getDecoratorInfo com info.Attributes
Util.generateDecorators com ctx decoratorInfos

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

// Get custom decorators from Py.Decorate attributes
let customDecorators =
let decoratorInfos = Util.getDecoratorInfo info.Attributes
let decoratorInfos = Util.getDecoratorInfo com info.Attributes
Util.generateDecorators com ctx decoratorInfos

let decorators =
Expand Down Expand Up @@ -3723,11 +3723,14 @@ let rec transformDeclaration (com: IPythonCompiler) ctx (decl: Fable.Declaration
elif hasEraseAttribute && ent.IsInterface then
// Erased interfaces should not generate any code
[]
elif hasEraseAttribute then
// Erased classes (e.g., custom attribute types) should not generate any code
[]
else
// Check for PythonClass attribute and extract parameters
let classAttributes =
if hasPythonClassAttribute ent.Attributes then
getPythonClassParameters ent.Attributes
if hasPythonClassAttribute com ent.Attributes then
getPythonClassParameters com ent.Attributes
else
ClassAttributes.Default

Expand Down
Loading
Loading