Skip to content

Commit 031dbd5

Browse files
committed
feat: add support for integer flags
1 parent 273e582 commit 031dbd5

File tree

10 files changed

+347
-33
lines changed

10 files changed

+347
-33
lines changed

.coveragerc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,11 @@ omit = .venv/**
44

55
[report]
66
precision = 2
7+
exclude_lines =
8+
pragma: nocover
9+
pragma:nocover
10+
if TYPE_CHECKING:
11+
@overload
12+
@abstractmethod
13+
@abc.abstractmethod
14+
assert_never

README.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ pip install django-choices-field
1717
## Usage
1818

1919
```python
20+
import enum
21+
2022
from django.db import models
21-
from django_choices_field import TextChoicesField, IntegerChoicesField
23+
from django_choices_field import TextChoicesField, IntegerChoicesField, IntegerChoicesFlag
2224

2325

2426
class MyModel(models.Model):
@@ -30,23 +32,41 @@ class MyModel(models.Model):
3032
FIRST = 1, "First Description"
3133
SECOND = 2, "Second Description"
3234

33-
c_field = TextChoicesField(
35+
class IntegerFlagEnum(IntegerChoicesFlag):
36+
FIRST = enum.auto(), "First Option"
37+
SECOND = enum.auto(), "Second Option"
38+
THIRD = enum.auto(), "Third Option"
39+
40+
text_field = TextChoicesField(
3441
choices_enum=TextEnum,
3542
default=TextEnum.FOO,
3643
)
37-
i_field = IntegerChoicesField(
44+
integer_field = IntegerChoicesField(
3845
choices_enum=IntegerEnum,
3946
default=IntegerEnum.FIRST,
4047
)
48+
flag_field = IntegerChoicesFlagField(
49+
choices_enum=IntegerFlagEnum,
50+
default=IntegerFlagEnum.FIRST | IntegerFlagEnum.SECOND,
51+
)
4152

4253

4354
obj = MyModel()
44-
obj.c_field # MyModel.TextEnum.FOO
45-
isinstance(obj.c_field, MyModel.TextEnum) # True
46-
obj.i_field # MyModel.IntegerEnum.FIRST
47-
isinstance(obj.i_field, MyModel.IntegerEnum) # True
55+
reveal_type(obj.text_field) # MyModel.TextEnum.FOO
56+
assert isinstance(obj.text_field, MyModel.TextEnum)
57+
assert obj.text_field == "foo"
58+
59+
reveal_type(obj.integer_field) # MyModel.IntegerEnum.FIRST
60+
assert isinstance(obj.integer_field, MyModel.IntegerEnum)
61+
assert obj.integer_field == 1
62+
63+
reveal_type(obj.flag_field) # MyModel.IntegerFlagEnum.FIRST | MyModel.IntegerFlagEnum.SECOND
64+
assert isinstance(obj.integer_field, MyModel.IntegerFlagEnum)
65+
assert obj.flag_field == 3
4866
```
4967

68+
NOTE: The `IntegerChoicesFlag` requires python 3.11+ to work properly.
69+
5070
## License
5171

5272
This project is licensed under MIT licence (see `LICENSE` for more info)

django_choices_field/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from .fields import IntegerChoicesField, TextChoicesField
2+
from .types import IntegerChoicesFlag
23

34
__all__ = [
5+
"IntegerChoicesFlag",
46
"TextChoicesField",
57
"IntegerChoicesField",
68
]

django_choices_field/fields.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import functools
2+
import itertools
13
from typing import ClassVar, Dict, Optional, Type
24

35
from django.core.exceptions import ValidationError
46
from django.db import models
57

8+
from .types import IntegerChoicesFlag
9+
610

711
class TextChoicesField(models.CharField):
812
description: ClassVar[str] = "TextChoices"
@@ -97,3 +101,65 @@ def formfield(self, **kwargs): # pragma:nocover
97101
**kwargs,
98102
},
99103
)
104+
105+
106+
class IntegerChoicesFlagField(models.IntegerField):
107+
description: ClassVar[str] = "IntegerChoicesFlag"
108+
default_error_messages: ClassVar[Dict[str, str]] = {
109+
"invalid": "“%(value)s” must be a subclass of %(enum)s.",
110+
}
111+
112+
def __init__(
113+
self,
114+
choices_enum: Type[IntegerChoicesFlag],
115+
verbose_name: Optional[str] = None,
116+
name: Optional[str] = None,
117+
**kwargs,
118+
):
119+
self.choices_enum = choices_enum
120+
121+
default_choices = choices_enum.choices
122+
kwargs["choices"] = default_choices[:]
123+
for i in range(1, len(default_choices)):
124+
for combination in itertools.combinations(default_choices, i + 1):
125+
kwargs["choices"].append(
126+
(
127+
functools.reduce(lambda a, b: a | b[0], combination, 0),
128+
"|".join(c[1] for c in combination),
129+
),
130+
)
131+
132+
super().__init__(verbose_name=verbose_name, name=name, **kwargs)
133+
134+
def deconstruct(self):
135+
name, path, args, kwargs = super().deconstruct()
136+
kwargs["choices_enum"] = self.choices_enum
137+
return name, path, args, kwargs
138+
139+
def to_python(self, value):
140+
if value is None:
141+
return None
142+
143+
try:
144+
return self.choices_enum(int(value) if isinstance(value, str) else value)
145+
except ValueError as e:
146+
raise ValidationError(
147+
self.error_messages["invalid"],
148+
code="invalid",
149+
params={"value": value, "enum": self.choices_enum},
150+
) from e
151+
152+
def from_db_value(self, value, expression, connection):
153+
return self.to_python(value)
154+
155+
def get_prep_value(self, value):
156+
value = super().get_prep_value(value)
157+
return self.to_python(value)
158+
159+
def formfield(self, **kwargs): # pragma:nocover
160+
return super().formfield(
161+
**{
162+
"coerce": self.to_python,
163+
**kwargs,
164+
},
165+
)

django_choices_field/fields.pyi

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ from typing import (
1313
from django.db.models import Field, IntegerChoices, TextChoices
1414
from typing_extensions import TypeAlias
1515

16+
from django_choices_field.types import IntegerChoicesFlag
17+
1618
_ValidatorCallable: TypeAlias = Callable[..., None]
1719
_ErrorMessagesToOverride: TypeAlias = Dict[str, Any]
1820

@@ -145,3 +147,68 @@ class IntegerChoicesField(Generic[_I], Field[_I, _I]):
145147
allow_files: bool = ...,
146148
allow_folders: bool = ...,
147149
) -> IntegerChoicesField[_I | None]: ...
150+
151+
_IF = TypeVar("_IF", bound=Optional[IntegerChoicesFlag])
152+
153+
class IntegerChoicesFlagField(Generic[_IF], Field[_IF, _IF]):
154+
choices_enum: type[_IF]
155+
@overload
156+
def __new__(
157+
cls,
158+
choices_enum: type[_IF],
159+
verbose_name: str | None = ...,
160+
name: str | None = ...,
161+
primary_key: bool = ...,
162+
max_length: int | None = ...,
163+
unique: bool = ...,
164+
blank: bool = ...,
165+
null: Literal[False] = ...,
166+
db_index: bool = ...,
167+
default: _IF | Callable[[], _IF] = ...,
168+
editable: bool = ...,
169+
auto_created: bool = ...,
170+
serialize: bool = ...,
171+
unique_for_date: str | None = ...,
172+
unique_for_month: str | None = ...,
173+
unique_for_year: str | None = ...,
174+
help_text: str = ...,
175+
db_column: str | None = ...,
176+
db_tablespace: str | None = ...,
177+
validators: Iterable[_ValidatorCallable] = ...,
178+
error_messages: _ErrorMessagesToOverride | None = ...,
179+
path: str | Callable[..., str] = ...,
180+
match: str | None = ...,
181+
recursive: bool = ...,
182+
allow_files: bool = ...,
183+
allow_folders: bool = ...,
184+
) -> IntegerChoicesFlagField[_IF]: ...
185+
@overload
186+
def __new__(
187+
cls,
188+
choices_enum: type[_IF],
189+
verbose_name: str | None = ...,
190+
name: str | None = ...,
191+
primary_key: bool = ...,
192+
max_length: int | None = ...,
193+
unique: bool = ...,
194+
blank: bool = ...,
195+
null: Literal[True] = ...,
196+
db_index: bool = ...,
197+
default: _IF | Callable[[], _IF] | None = ...,
198+
editable: bool = ...,
199+
auto_created: bool = ...,
200+
serialize: bool = ...,
201+
unique_for_date: str | None = ...,
202+
unique_for_month: str | None = ...,
203+
unique_for_year: str | None = ...,
204+
help_text: str = ...,
205+
db_column: str | None = ...,
206+
db_tablespace: str | None = ...,
207+
validators: Iterable[_ValidatorCallable] = ...,
208+
error_messages: _ErrorMessagesToOverride | None = ...,
209+
path: str | Callable[..., str] = ...,
210+
match: str | None = ...,
211+
recursive: bool = ...,
212+
allow_files: bool = ...,
213+
allow_folders: bool = ...,
214+
) -> IntegerChoicesFlagField[_IF | None]: ...

django_choices_field/types.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import enum
2+
import sys
3+
from typing import TYPE_CHECKING
4+
5+
from django.db import models
6+
from typing_extensions import Self
7+
8+
9+
class IntegerChoicesFlag(models.IntegerChoices, enum.Flag):
10+
"""Enumerated integer choices."""
11+
12+
if TYPE_CHECKING:
13+
14+
def __or__(self, other: Self) -> Self:
15+
...
16+
17+
def __and__(self, other: Self) -> Self:
18+
...
19+
20+
def __xor__(self, other: Self) -> Self:
21+
...
22+
23+
def __invert__(self) -> Self:
24+
...
25+
26+
if sys.version_info >= (3, 11):
27+
__ror__ = __or__
28+
__rand__ = __and__
29+
__rxor__ = __xor__

poetry.lock

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-choices-field"
3-
version = "2.1.2"
3+
version = "2.2.0"
44
description = "Django field that set/get django's new TextChoices/IntegerChoices enum."
55
authors = ["Thiago Bellini Ribeiro <[email protected]>"]
66
license = "MIT"
@@ -32,6 +32,7 @@ packages = [{ include = "django_choices_field" }]
3232
[tool.poetry.dependencies]
3333
python = "^3.8"
3434
django = ">=3.2"
35+
typing_extensions = ">=4.0.0"
3536

3637
[tool.poetry.dev-dependencies]
3738
black = "^23.3.0"

tests/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import enum
2+
import sys
3+
14
from django.db import models
25

36
from django_choices_field import IntegerChoicesField, TextChoicesField
7+
from django_choices_field.fields import IntegerChoicesFlagField
8+
from django_choices_field.types import IntegerChoicesFlag
49

510

611
class MyModel(models.Model):
@@ -12,6 +17,11 @@ class IntegerEnum(models.IntegerChoices):
1217
I_FOO = 1, "I Foo Description"
1318
I_BAR = 2, "I Bar Description"
1419

20+
class IntegerFlagEnum(IntegerChoicesFlag):
21+
IF_FOO = enum.auto() if sys.version_info >= (3, 11) else 1, "IF Foo Description"
22+
IF_BAR = enum.auto() if sys.version_info >= (3, 11) else 2, "IF Bar Description"
23+
IF_BIN = enum.auto() if sys.version_info >= (3, 11) else 4, "IF Bin Description"
24+
1525
objects = models.Manager["MyModel"]()
1626

1727
c_field = TextChoicesField(
@@ -30,3 +40,11 @@ class IntegerEnum(models.IntegerChoices):
3040
choices_enum=IntegerEnum,
3141
null=True,
3242
)
43+
if_field = IntegerChoicesFlagField(
44+
choices_enum=IntegerFlagEnum,
45+
default=IntegerFlagEnum.IF_FOO,
46+
)
47+
if_field_nullable = IntegerChoicesFlagField(
48+
choices_enum=IntegerFlagEnum,
49+
null=True,
50+
)

0 commit comments

Comments
 (0)