Skip to content

Commit 12e2ae4

Browse files
committed
fix choices resolution with and without aliases #118
1 parent 63b8d63 commit 12e2ae4

File tree

7 files changed

+459
-20
lines changed

7 files changed

+459
-20
lines changed

doc/source/changelog.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
Change Log
55
==========
66

7-
v2.2.0 (2025-03-27)
7+
v2.2.0 (2025-03-28)
88
===================
99

1010
* Implemented `Need a DRF integration for FlagFields <https://github.com/bckohan/django-enum/issues/113>`_

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ packages = ["src/django_enum"]
6161

6262

6363
[project.optional-dependencies]
64-
properties = ["enum-properties>=2.2.0"]
64+
properties = ["enum-properties>=2.3.0"]
6565
filters = ["django-filter>=21"]
6666
rest = ["djangorestframework>=3.9,<4.0"]
6767

src/django_enum/choices.py

+14
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ class DjangoEnumPropertiesMeta(EnumPropertiesMeta, ChoicesType): # type: ignore
3333
enum-properties' generic property support.
3434
"""
3535

36+
def __new__(mcs, classname, bases, classdict, **kwargs):
37+
cls = super().__new__(mcs, classname, bases, classdict, **kwargs)
38+
# choices does not allow duplicates, but base class construction breaks
39+
# this member, so we alias it here to stay compatible with enum-properties
40+
# interface
41+
# TODO - is this a fixable bug in ChoicesType?
42+
cls._member_names_ = (
43+
list(classdict._member_names.keys())
44+
if isinstance(classdict._member_names, dict) # changes based on py ver
45+
else classdict._member_names
46+
)
47+
cls.__first_class_members__ = cls._member_names_
48+
return cls
49+
3650
@property
3751
def names(self) -> t.List[str]:
3852
"""

src/django_enum/utils.py

+83-13
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,19 @@
44
from datetime import date, datetime, time, timedelta
55
from decimal import Decimal
66
from enum import Enum, Flag, IntFlag
7-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
7+
from importlib.util import find_spec
8+
from typing import (
9+
TYPE_CHECKING,
10+
Any,
11+
Dict,
12+
Generator,
13+
List,
14+
Optional,
15+
Tuple,
16+
Type,
17+
TypeVar,
18+
Union,
19+
)
820

921
from typing_extensions import get_args
1022

@@ -20,10 +32,17 @@
2032
"get_set_values",
2133
"get_set_bits",
2234
"decompose",
35+
"members",
2336
]
2437

2538

39+
PROPERTIES_ENABLED = find_spec("enum_properties")
40+
"""
41+
True if enum-properties is installed, False otherwise.
42+
"""
43+
2644
T = TypeVar("T")
45+
E = TypeVar("E", bound=Enum)
2746
F = TypeVar("F", bound=Flag)
2847

2948
SupportedPrimitive = Union[
@@ -54,7 +73,7 @@ def with_typehint(baseclass: Type[T]) -> Type[T]:
5473

5574

5675
def choices(
57-
enum_cls: Optional[Type[Enum]], override: bool = False
76+
enum_cls: Optional[Type[Enum]], override: bool = False, aliases: bool = True
5877
) -> List[Tuple[Any, str]]:
5978
"""
6079
Get the Django choices for an enumeration type. If the enum type has a
@@ -67,6 +86,7 @@ def choices(
6786
6887
:param enum_cls: The enumeration type
6988
:param override: Do not defer to choices attribute on the class if True
89+
:param aliases: Include first-class aliases in the result if True (default: True)
7090
:return: A list of (value, label) pairs
7191
"""
7292
return (
@@ -80,7 +100,7 @@ def choices(
80100
),
81101
*[
82102
(member.value, getattr(member, "label", getattr(member, "name")))
83-
for member in list(enum_cls) or enum_cls.__members__.values()
103+
for member in members(enum_cls, aliases=aliases)
84104
],
85105
]
86106
)
@@ -89,24 +109,24 @@ def choices(
89109
)
90110

91111

92-
def names(enum_cls: Optional[Type[Enum]], override: bool = False) -> List[Any]:
112+
def names(
113+
enum_cls: Optional[Type[Enum]], override: bool = False, aliases: bool = True
114+
) -> List[Any]:
93115
"""
94116
Return a list of names to use for the enumeration type. This is used
95117
for compat with enums that do not inherit from Django's Choices type.
96118
97119
:param enum_cls: The enumeration type
98120
:param override: Do not defer to names attribute on the class if True
121+
:param aliases: Include first-class aliases in the result if True (default: True)
99122
:return: A list of labels
100123
"""
101124
return (
102125
(getattr(enum_cls, "names", []) if not override else [])
103126
or (
104127
[
105128
*(["__empty__"] if hasattr(enum_cls, "__empty__") else []),
106-
*[
107-
member.name
108-
for member in list(enum_cls) or enum_cls.__members__.values()
109-
],
129+
*[member.name for member in members(enum_cls, aliases=aliases)],
110130
]
111131
)
112132
if enum_cls
@@ -189,6 +209,16 @@ def determine_primitive(enum: Type[Enum]) -> Optional[Type]:
189209
return primitive
190210

191211

212+
def is_power_of_two(n: int) -> bool:
213+
"""
214+
Check if an integer is a power of two.
215+
216+
:param n: The integer to check
217+
:return: True if the number is a power of two, False otherwise
218+
"""
219+
return n != 0 and (n & (n - 1)) == 0
220+
221+
192222
def decimal_params(
193223
enum: Optional[Type[Enum]],
194224
decimal_places: Optional[int] = None,
@@ -264,9 +294,49 @@ class Permissions(IntFlag):
264294
"""
265295
if not flags:
266296
return []
267-
if sys.version_info < (3, 11):
268-
return [
269-
flg for flg in type(flags) if flg in flags and flg is not type(flags)(0)
270-
]
297+
return [
298+
flg
299+
for flg in type(flags).__members__.values()
300+
if flg in flags and flg is not type(flags)(0)
301+
]
302+
303+
304+
def members(enum: Type[E], aliases: bool = True) -> Generator[E, None, None]:
305+
"""
306+
Get the members of an enumeration class. This can be tricky to do
307+
in a python version agnostic way, so it is recommended to
308+
use this function.
309+
310+
.. note:
311+
312+
Composite flag values, such as `A | B` when named on a
313+
:class:`~enum.IntFlag` class are considered aliases by this function.
314+
315+
:param enum_cls: The enumeration class
316+
:param aliases: Include aliases in the result if True (default: True)
317+
:return: A generator that yields the enumeration members
318+
"""
319+
if aliases:
320+
if PROPERTIES_ENABLED:
321+
from enum_properties import SymmetricMixin
322+
323+
if issubclass(enum, SymmetricMixin):
324+
for member in enum.__first_class_members__:
325+
yield enum[member] # type: ignore[index]
326+
return
327+
yield from enum.__members__.values()
271328
else:
272-
return list(flags) # type: ignore[arg-type]
329+
if issubclass(enum, Flag) and (
330+
issubclass(enum, int)
331+
or isinstance(next(iter(enum.__members__.values())).value, int)
332+
):
333+
for name in enum._member_names_:
334+
en = enum[name]
335+
value = en.value
336+
if value < 0 or is_power_of_two(value):
337+
yield en # type: ignore[misc]
338+
elif sys.version_info[:2] >= (3, 11):
339+
yield from enum # type: ignore[misc]
340+
else:
341+
for name in enum._member_names_:
342+
yield enum[name]

tests/test_requests.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from importlib.util import find_spec
22
import typing as t
3+
from enum import Enum
34
from tests.utils import EnumTypeMixin, try_convert
45
from django.test import TestCase
56
from tests.djenum.models import EnumTester
@@ -562,6 +563,9 @@ def verify_form(self, obj, soup):
562563
self.assertTrue(option.has_attr("selected"))
563564
del expected[value]
564565
except KeyError: # pragma: no cover
566+
import ipdb
567+
568+
ipdb.set_trace()
565569
self.fail(
566570
f"{field.name} did not expect option "
567571
f"{option['value']}: {option.text}."

0 commit comments

Comments
 (0)