Decorators¶
Decorators modify the behavior of functions, members, methods, and classes.
Decorator Ordering:
When multiple decorators are applied, they are processed bottom-up (closest to the definition first), matching Python semantics:
For Sharpy's built-in decorators (@virtual, @override, @abstract, @final, etc.), the order typically doesn't matter since they're metadata flags rather than transforming decorators. However, it's conventional to place them in a consistent order:
# Recommended ordering (when applicable)
@virtual # Inheritance behavior
@static
@override
@final
@public # Access modifiers last
@protected
@private
@internal
Note that Sharpy does not support any version of class methods equating to Python's @classmethod decorator. However, it does support something like Python's @staticmethod, which is named @static in Sharpy. See static_methods.md and class_methods.md respectively for details.
Access Modifiers¶
| Decorator | C# Equivalent | Visibility |
|---|---|---|
@public (can be omitted, it is the default) |
public |
Everyone |
@protected or _name |
protected |
Class and derived |
@private or __name |
private |
Declaring class only |
@internal |
internal |
Same assembly |
Assembly Boundaries for @internal:
In Sharpy, an assembly corresponds to a compiled project. Assembly boundaries are defined by:
- A
.spyprojproject file defines a single assembly - All
.spyfiles in the same project compile to the same assembly - Each referenced project becomes a separate assembly
@internal members are accessible from any file within the same project but not from other projects that reference it.
# In mylib/internal_utils.spy (part of mylib.spyproj)
@internal
def helper_function() -> None:
pass
# In mylib/public_api.spy (same project) - OK
from mylib.internal_utils import helper_function # ✅ Same assembly
# In app/main.spy (different project referencing mylib) - ERROR
from mylib.internal_utils import helper_function # ❌ Different assembly
class Example:
@private
def internal_method(self) -> None:
pass
# Naming convention also works
def _protected_method(self) -> None:
pass
def __private_method(self) -> None:
pass
Note: When
@publicoverrides a naming-convention access level (e.g.,@public __should_be_public), the generated C# member retains the underscore prefix (public int __ShouldBePublic). Downstream C# analyzers (StyleCop, Roslyn analyzers) may flag this as a naming violation on the generated output. These warnings on generated code can be safely ignored.
Implementation: ✅ Native - Direct mapping to C# access modifiers.
Method Modifiers¶
| Decorator | C# Equivalent | Notes |
|---|---|---|
@static |
static |
Class-level method, no self parameter. Can be omitted if the first parameter is not self. It is a compile-time error to use it on a method with self as the first parameter. |
@override |
override |
Override virtual/abstract base method |
@virtual |
virtual |
Method can be overridden by subclasses |
@abstract |
abstract |
Must be overridden, no implementation |
@final (method) |
sealed override |
Prevents further overriding |
@final (class) |
sealed class |
Prevents inheritance |
@abstract (class) |
abstract class |
Cannot be instantiated, may contain abstract members |
@test |
[Fact] |
Marks a test method for xUnit discovery. See unittest.md |
class Calculator:
@static
def add(x: int, y: int) -> int:
return x + y
# Also valid, `@static` is implied when the method
# does not have `self` as the first parameter.
def add(x: int, y: int) -> int:
return x + y
# WRONG: Cannot use `@static` on a method that has
# `self` as the first parameter, as that makes it an
# instance method.
@static
def reverse_add(self, x: int, y: int) -> int:
return x + y
@virtual
def compute(self, x: int) -> int:
return x * 2
@override
def __str__(self) -> str:
return "Calculator"
class ScientificCalculator(Calculator):
@override
def compute(self, x: int) -> int:
return x ** 2
@final
@override
def __str__(self) -> str:
return "ScientificCalculator"
@final
class CannotBeExtended:
"""This class cannot be subclassed."""
pass
# Usage
result = Calculator.add(5, 3) # Static method call
calc = ScientificCalculator()
calc.compute(4) # Returns 16 (overridden method)
Note: Sharpy uses @final rather than C#'s sealed keyword to align with Python's typing.final decorator and Java's final keyword. The compiled output uses C#'s sealed keyword.
Abstract Classes:
Classes can be marked @abstract to indicate they cannot be instantiated directly and may contain abstract members. A class with any abstract members must be marked @abstract.
Streamlined Abstract Method Syntax:
Methods in an @abstract class with an ellipsis (...) body are automatically treated as abstract - no explicit @abstract decorator is needed on the method. You can also use the inline ellipsis syntax for maximum brevity:
@abstract
class Shape:
name: str
def __init__(self, name: str):
self.name = name
# Implicit abstract - ellipsis body in @abstract class
def area(self) -> float: ...
def perimeter(self) -> float: ...
# Non-abstract methods have real implementations
def describe(self) -> str:
return f"{self.name} with area {self.area()}"
class Circle(Shape):
radius: float
def __init__(self, radius: float):
super().__init__("Circle")
self.radius = radius
@override
def area(self) -> float:
return 3.14159 * self.radius ** 2
@override
def perimeter(self) -> float:
return 2 * 3.14159 * self.radius
# Usage
# shape = Shape("test") # ERROR: Cannot instantiate abstract class
circle = Circle(5.0) # OK
print(circle.describe()) # "Circle with area 78.53975"
The explicit @abstract decorator on methods is still supported but optional when using ellipsis body in an @abstract class.
Abstract method body conventions:
- ... (ellipsis) = abstract, no implementation — requires @abstract class or interface context
- pass = concrete empty body (default implementation)
- Body-less (no colon, no body) = deprecated (emits SPY0464 warning), use ... instead
@abstract
class Shape:
# Preferred forms:
def area(self) -> float: ... # Implicit abstract (preferred)
@abstract
def perimeter(self) -> float: ... # Explicit abstract with ellipsis body
@abstract
def volume(self) -> float # DEPRECATED: body-less form (SPY0464 warning)
Note: Ellipsis body in a non-abstract class generates a NotImplementedException stub instead of an abstract method:
class TodoService:
def not_done_yet(self) -> int: ... # Generates: throw new NotImplementedException()
Implementation: ✅ Native - Direct mapping to C# keywords.
Bracket Attribute Syntax (@[...])¶
C# attributes are applied using bracket syntax: @[AttributeName]. This is the only way to emit C# [Attribute] annotations — regular @decorator syntax is reserved for Sharpy language keywords.
Syntax¶
Key Rules¶
- Automatic name mangling: Names inside
@[...]follow the same snake_case → PascalCase mangling as the rest of Sharpy. Write@[serializable], not@[Serializable]. - Keyword arguments mangled:
entry_point="Func"becomesEntryPoint = "Func"in the emitted C#. - Backtick escape: Use backticks to bypass mangling for non-obvious names:
@[`SerializableAttribute`]emits[SerializableAttribute]verbatim. - Unknown
@decoratorsare rejected:@serializableis a compile-time error (SPY0444). The error message suggests the bracket equivalent.
Argument Restrictions¶
Bracket attribute arguments must be compile-time constants, matching C# attribute argument restrictions:
- String, int, float, bool literals
- None (maps to null)
- Enum member access (e.g., StringComparison.ordinal)
- type(X) (maps to typeof(X) in C#)
- Negative numeric literals (e.g., -42, -3.14)
Non-constant expressions (e.g., 1 + 2, variable references, function calls other than type()) are rejected at compile time with SPY0425.
Known Decorators vs. Bracket Attributes¶
| Category | Syntax | Behavior |
|---|---|---|
Built-in modifier (@virtual, @static, etc.) |
@name |
Maps to C# keyword modifier. No arguments allowed. |
Sharpy keyword (@deprecated, @dataclass, @test, etc.) |
@name(...) |
Special Sharpy semantics |
| C# attribute | @[name(...)] |
Emitted as C# [Attribute] with PascalCase mangling |
Note: @[final] emits the C# attribute [Final] — it is NOT the Sharpy @final keyword. Bracket attributes and language decorators are completely separate.
Source generators also use
@[Name]syntax. If a bracket attribute resolves to a class extendingSourceGenerator(fromsharpy.generators), the compiler invokes the generator at compile time and merges the produced Sharpy source into the compilation. See source_generators.md for details.
Examples¶
# Simple attribute
@[serializable]
class Config:
pass
# Attribute with argument
@[obsolete("Use bar() instead")]
def foo() -> None:
pass
# Dotted (qualified) attribute name
@[system.serializable]
class Data:
pass
# Multiple arguments with keyword
@[dll_import("user32.dll", entry_point="MessageBox")]
def message_box() -> None: ...
# Combining Sharpy modifier with bracket attribute
@virtual
@[obsolete("Will be removed in v2")]
def legacy_method(self) -> None:
pass
# type() maps to typeof() in attribute arguments
@[system.diagnostics.debugger_type_proxy(type(str))]
class MyList:
pass
# Multiple bracket attributes on same declaration
@[serializable]
@[obsolete("Use NewConfig instead")]
class OldConfig:
pass
# Bracket attribute on a field
class Widget:
@[system.component_model.default_value(42)]
value: int
# Backtick escape for verbatim names
@[`SerializableAttribute`]
class RawName:
pass
Implementation: ✅ Emitted as C# attributes via Roslyn SyntaxFactory.
Custom Attribute Classes¶
You can define your own .NET attributes by subclassing System.Attribute. Custom attributes are regular Sharpy classes — they follow the same syntax for fields, constructors, and inheritance.
Defining a Custom Attribute¶
from System import Attribute
class AuthorAttribute(Attribute):
name: str
year: int
def __init__(self, name: str, year: int):
super().__init__()
self.name = name
self.year = year
This compiles to a standard C# attribute class:
public class AuthorAttribute : System.Attribute
{
public string Name;
public int Year;
public AuthorAttribute(string name, int year) : base()
{
this.Name = name;
this.Year = year;
}
}
Applying Custom Attributes¶
Apply your custom attribute using the same @[...] bracket syntax. Name mangling applies — write the snake_case version:
@[author_attribute("Alice", 2026)]
class Library:
pass
# Keyword arguments work the same way
@[author_attribute("Bob", year=2025)]
class Archive:
pass
Custom attributes can be applied to any target that supports bracket attributes: classes, structs, methods, fields, interfaces, unions, events, and properties.
Controlling Attribute Targets and Multiplicity¶
Use @[attribute_usage(...)] on your custom attribute class to restrict where it can be applied and whether it can be applied multiple times:
from System import Attribute
@[attribute_usage(AttributeTargets.method, allow_multiple=True)]
class LogAttribute(Attribute):
level: str
def __init__(self, level: str):
super().__init__()
self.level = level
class Service:
@[log_attribute("info")]
@[log_attribute("debug")]
def process(self) -> str:
return "ok"
Reading Attributes at Runtime¶
Custom attributes are baked into the .NET assembly as metadata and can be retrieved via reflection:
from System import Attribute
@[author_attribute("Alice", 2026)]
class Library:
pass
def main():
lib = Library()
obj: object = lib
t = obj.get_type()
attrs = Attribute.get_custom_attributes(t)
for a in attrs:
author = a to AuthorAttribute?
if author is not None:
print(author.name) # Alice
print(author.year) # 2026
Note: Attribute validation (e.g., verifying the attribute class exists, constructor signatures match) is deferred to the C# compiler, consistent with Axiom 1 (.NET compatibility first).
@deprecated Decorator¶
The @deprecated decorator is a Sharpy language keyword that maps to C#'s [Obsolete] attribute:
This is equivalent to @[obsolete("Use new_method instead")] but uses the Pythonic @deprecated name (PEP 702). Requires exactly one string argument.
Flexible Argument Decorators¶
Dropped —
@kwargsand@dynamic_kwargswere removed from the roadmap. Compiler-understood transforming decorators violate the "no magic" principle, and@dynamic_kwargsconflicts with Axiom 3 (type safety). Named arguments with default values and user-defined option structs provide equivalent functionality without invisible code generation. See SRP-0001 and SRP-0002 for full rationale.