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
5 changes: 5 additions & 0 deletions doc/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
Change Log
==========

v2.7.0 (2026-03-04)
===================

* Implemented `Make @symmetric a property if it wraps a plain function. <https://github.com/bckohan/enum-properties/issues/153>`_

v2.6.0 (2026-03-04)
===================

Expand Down
5 changes: 3 additions & 2 deletions doc/source/howto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ example to make name case insensitive we might:
Mark @properties as Symmetric
-----------------------------

The :py:func:`~enum_properties.symmetric` decorator may be used to mark @properties as symmetric or other members not specified
in the Enum value tuple as symmetric. For example:
The :py:func:`~enum_properties.symmetric` decorator may be used to mark methods as
symmetric. Plain functions are automatically wrapped as properties, so ``@property``
is not required. For example:

.. literalinclude:: ../../tests/examples/howto_symmetric_decorator.py

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "enum-properties"
version = "2.6.0"
version = "2.7.0"
description = "Add properties and method specializations to Python enumeration values with a simple declarative syntax."
requires-python = ">=3.10,<4.0"
authors = [
Expand Down
18 changes: 13 additions & 5 deletions src/enum_properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from dataclasses import dataclass
from functools import cached_property

VERSION = (2, 6, 0)
VERSION = (2, 7, 0)

__title__ = "Enum Properties"
__version__ = ".".join(str(i) for i in VERSION)
Expand Down Expand Up @@ -55,6 +55,12 @@

S = t.TypeVar("S")

_enum_property = property
if sys.version_info[0:2] >= (3, 11):
from enum import (
property as _enum_property, # pyright: ignore[reportAttributeAccessIssue]
)


_lazy_annotations_: bool = sys.version_info[:2] >= (3, 14)
"""
Expand Down Expand Up @@ -110,9 +116,10 @@ def __init__(self, member: t.Any, symmetric: Symmetric):
self.symmetric = symmetric


def symmetric(case_fold: bool = False, match_none: bool = False) -> t.Callable[[S], S]:
def symmetric(case_fold: bool = False, match_none: bool = False):
"""
A decorator that marks non-enum value members as symmetric. For example, properties:
A decorator that marks non-enum value members as symmetric. Plain functions are
automatically wrapped with :func:`property`. For example:

.. code-block:: python

Expand All @@ -121,7 +128,6 @@ class MyEnum(EnumProperties):
...

@symmetric(case_fold=True)
@property
def name(self):
return "value"

Expand All @@ -132,7 +138,9 @@ def name(self):
"""

def symmetric_decorator(member: S) -> S:
return _MarkedSymmetric( # type: ignore
if callable(member) and not isinstance(member, (property, _enum_property)):
member = _enum_property(member) # type: ignore[assignment]
return _MarkedSymmetric( # type: ignore[return-value]
member, Symmetric(case_fold, match_none)
)

Expand Down
24 changes: 21 additions & 3 deletions src/enum_properties/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sys
from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Callable, Literal, TypeAlias, TypeVar, overload
from typing import Any, Callable, Generic, Literal, TypeAlias, TypeVar, overload

VERSION: tuple[int, int, int]
__title__: str
Expand All @@ -14,7 +14,8 @@ __author__: str
__license__: str
__copyright__: str

S = TypeVar("S")
_T = TypeVar("_T")
_PropertyT = TypeVar("_PropertyT", bound=property)
_EnumMemberT = TypeVar("_EnumMemberT")
_EnumNames: TypeAlias = (
str | Iterable[str] | Iterable[Iterable[str | Any]] | Mapping[str, Any]
Expand Down Expand Up @@ -42,9 +43,26 @@ def s(
prop_name: str, case_fold: bool = False, match_none: bool = False
) -> type[_SProp]: ...
def p(prop_name: str) -> type[_Prop]: ...

class _SymmetricProperty(Generic[_T]):
"""Read-only descriptor returned by @symmetric(); __get__ on an instance returns _T."""
@overload
def __get__(
self, obj: None, objtype: type[Any] = ...
) -> _SymmetricProperty[_T]: ...
@overload
def __get__(self, obj: Any, objtype: type[Any] | None = ...) -> _T: ...

class _SymmetricDecorator:
"""Return type of symmetric() — wraps a callable as a symmetric property."""
@overload
def __call__(self, f: _PropertyT) -> _PropertyT: ...
@overload
def __call__(self, f: Callable[[Any], _T]) -> _SymmetricProperty[_T]: ...

def symmetric(
case_fold: bool = False, match_none: bool = False
) -> Callable[[S], S]: ...
) -> _SymmetricDecorator: ...
def specialize(
*values: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
Expand Down
13 changes: 7 additions & 6 deletions tests/annotations/test_symmetric.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class SymEnum(EnumProperties):

@symmetric(case_fold=True)
@property
def label(self):
def label(self) -> str:
return self.name

self.assertEqual(SymEnum.ONE.label, "ONE")
Expand Down Expand Up @@ -74,14 +74,15 @@ class SymEnum(EnumProperties):
TWO = 2
THREE = 3

# lol - should work with anything
# plain functions are auto-wrapped as properties
@symmetric()
def label(self):
return self.name

self.assertEqual(SymEnum.ONE.label(), "ONE")
self.assertEqual(SymEnum.TWO.label(), "TWO")
self.assertEqual(SymEnum.THREE.label(), "THREE")
# label is now a property — access without parens
self.assertEqual(SymEnum.ONE.label, "ONE")
self.assertEqual(SymEnum.TWO.label, "TWO")
self.assertEqual(SymEnum.THREE.label, "THREE")

self.assertTrue(SymEnum(SymEnum.ONE.label) is SymEnum.ONE)
self.assertTrue(SymEnum(SymEnum.TWO.label) is SymEnum.TWO)
Expand All @@ -95,7 +96,7 @@ class SymEnum(EnumProperties):

@symmetric(case_fold=True)
@enum_property
def label(self):
def label(self) -> str:
return self.name

self.assertEqual(SymEnum.ONE.label, "ONE")
Expand Down
2 changes: 0 additions & 2 deletions tests/examples/howto_symmetric_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ class Color(EnumProperties):
BLUE = auto(), (0, 0, 1), '0000ff'

@symmetric()
@property
def integer(self) -> int:
return int(self.hex, 16)

@symmetric()
@property
def binary(self) -> str:
return bin(self.integer)[2:]

Expand Down
18 changes: 18 additions & 0 deletions tests/type_hints/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
IntEnumProperties,
IntFlagProperties,
StrEnumProperties,
symmetric,
)


Expand Down Expand Up @@ -285,3 +286,20 @@ class ColorFlag(IntFlagProperties):
RED = 1, "Roja"
GREEN = 2, "Verde"
BLUE = 4, "Azul"

# ---------------------------------------------------- @symmetric decorator
class SymColor(EnumProperties):
@symmetric()
def integer(self) -> int:
return int(str(self.value), 16)

@symmetric()
def label(self) -> str:
return self.name

RED = "ff0000"
GREEN = "00ff00"
BLUE = "0000ff"

assert_type(SymColor.RED.integer, int)
assert_type(SymColor.RED.label, str)
Loading
Loading