Skip to content

Commit 7bad4a7

Browse files
committed
add static type checking hints
1 parent e399042 commit 7bad4a7

File tree

6 files changed

+107
-57
lines changed

6 files changed

+107
-57
lines changed

CONTRIBUTING.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ Installation
2626
------------
2727

2828
`django-enum` uses Poetry_ for environment, package and dependency
29-
management. Poetry_ greatly simplifies environment bootstrapping. Once it's
30-
installed:
29+
management:
3130

3231
.. code-block::
3332
@@ -60,6 +59,7 @@ justified is acceptable:
6059
6160
poetry run isort django_enum
6261
poetry run pylint django_enum
62+
poetry run mypy django_enum
6363
poetry run doc8 -q doc
6464
poetry check
6565
poetry run pip check

django_enum/choices.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ def __init__(self, *args, **kwargs):
7373
f'installed.'
7474
)
7575

76-
DjangoSymmetricMixin = MissingEnumProperties
76+
DjangoSymmetricMixin = MissingEnumProperties # type: ignore
7777

7878

79-
class DjangoEnumPropertiesMeta(ChoicesMeta):
79+
class DjangoEnumPropertiesMeta(ChoicesMeta): # type: ignore
8080
"""
8181
Throw error if metaclass is used without enum-properties
8282
@@ -89,21 +89,21 @@ def __init__(cls, *args, **kwargs): # pylint: disable=W0231
8989
f'installed.'
9090
)
9191

92-
class TextChoices(
92+
class TextChoices( # type: ignore
9393
DjangoSymmetricMixin,
9494
str,
9595
Choices
9696
):
9797
"""Raises ImportError on class definition"""
9898

99-
class IntegerChoices(
99+
class IntegerChoices( # type: ignore
100100
DjangoSymmetricMixin,
101101
int,
102102
Choices
103103
):
104104
"""Raises ImportError on class definition"""
105105

106-
class FloatChoices(
106+
class FloatChoices( # type: ignore
107107
DjangoSymmetricMixin,
108108
float,
109109
Choices

django_enum/fields.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,50 @@
11
"""
22
Support for Django model fields built from enumeration types.
33
"""
4+
from typing import (
5+
TYPE_CHECKING,
6+
Any,
7+
List,
8+
Optional,
9+
Tuple,
10+
Type,
11+
TypeVar,
12+
Union,
13+
)
14+
415
from django.core.exceptions import ValidationError
516
from django.db.models import (
617
BigIntegerField,
718
CharField,
819
Choices,
20+
Field,
921
FloatField,
1022
IntegerField,
23+
Model,
1124
PositiveBigIntegerField,
1225
PositiveIntegerField,
1326
PositiveSmallIntegerField,
1427
SmallIntegerField,
1528
)
1629

30+
T = TypeVar('T')
31+
1732

18-
class EnumMixin:
33+
def with_typehint(baseclass: Type[T]) -> Type[T]:
34+
"""
35+
Change inheritance to add Field type hints when . This is just more simple
36+
than defining a Protocols - revisit if Django provides Field protocol -
37+
should also just be a way to create a Protocol from a class?
38+
"""
39+
if TYPE_CHECKING:
40+
return baseclass # pragma: no cover
41+
return object # type: ignore
42+
43+
44+
class EnumMixin(
45+
# why can't mypy handle the line below?
46+
with_typehint(Field) # type: ignore
47+
):
1948
"""
2049
This mixin class turns any Django database field into an enumeration field.
2150
It works by overriding validation and pre/post database hooks to validate
@@ -30,23 +59,32 @@ class EnumMixin:
3059
field type.
3160
"""
3261

33-
enum = None
34-
strict = True
62+
enum: Optional[Type[Choices]] = None
63+
strict: bool = True
3564

36-
def _coerce_to_value_type(self, value):
65+
def _coerce_to_value_type(self, value: Any) -> Choices:
3766
"""Coerce the value to the enumerations value type"""
3867
# note if enum type is int and a floating point is passed we could get
3968
# situations like X.xxx == X - this is acceptable
40-
return type(self.enum.values[0])(value)
69+
if self.enum:
70+
return type(self.enum.values[0])(value)
71+
# can't ever reach this - just here to make type checker happy
72+
return value # pragma: no cover
4173

42-
def __init__(self, *args, enum=None, strict=strict, **kwargs):
74+
def __init__(
75+
self,
76+
*args,
77+
enum: Optional[Type[Choices]] = None,
78+
strict: bool = strict,
79+
**kwargs
80+
):
4381
self.enum = enum
4482
self.strict = strict if enum else False
4583
if self.enum is not None:
4684
kwargs.setdefault('choices', enum.choices if enum else [])
4785
super().__init__(*args, **kwargs)
4886

49-
def _try_coerce(self, value):
87+
def _try_coerce(self, value: Any) -> Union[Choices, Any]:
5088
if self.enum is not None and not isinstance(value, self.enum):
5189
try:
5290
value = self.enum(value)
@@ -61,7 +99,7 @@ def _try_coerce(self, value):
6199
) from err
62100
return value
63101

64-
def deconstruct(self):
102+
def deconstruct(self) -> Tuple[str, str, List, dict]:
65103
"""
66104
Preserve enum class for migrations. Strict is omitted because
67105
reconstructed fields are *always* non-strict sense enum is null.
@@ -74,7 +112,7 @@ def deconstruct(self):
74112

75113
return name, path, args, kwargs
76114

77-
def get_prep_value(self, value):
115+
def get_prep_value(self, value: Any) -> Any:
78116
"""
79117
Convert the database field value into the Enum type.
80118
@@ -105,10 +143,10 @@ def get_db_prep_value(self, value, connection, prepared=False):
105143

106144
def from_db_value(
107145
self,
108-
value,
146+
value: Any,
109147
expression, # pylint: disable=W0613
110148
connection # pylint: disable=W0613
111-
):
149+
) -> Any:
112150
"""
113151
Convert the database field value into the Enum type.
114152
@@ -118,7 +156,7 @@ def from_db_value(
118156
return value
119157
return self._try_coerce(value)
120158

121-
def to_python(self, value):
159+
def to_python(self, value: Any) -> Union[Choices, Any]:
122160
"""
123161
Converts the value in the enumeration type.
124162
@@ -139,7 +177,7 @@ def to_python(self, value):
139177
f"'{value}' is not a valid {self.enum.__name__}."
140178
) from err
141179

142-
def validate(self, value, model_instance):
180+
def validate(self, value: Any, model_instance: Model):
143181
"""
144182
Validates the field as part of model clean routines. Runs super class
145183
validation routines then tries to convert the value to a valid
@@ -231,7 +269,7 @@ class _EnumFieldMetaClass(type):
231269

232270
SUPPORTED_PRIMITIVES = {int, str, float}
233271

234-
def __new__(mcs, enum): # pylint: disable=R0911
272+
def __new__(mcs, enum: Choices) -> Field: # pylint: disable=R0911
235273
"""
236274
Construct a new Django Field class given the Enumeration class. The
237275
correct Django field class to inherit from is determined based on the
@@ -241,12 +279,13 @@ def __new__(mcs, enum): # pylint: disable=R0911
241279
"""
242280
assert issubclass(enum, Choices), \
243281
f'{enum} must inherit from {Choices}!'
244-
primitive = mcs.SUPPORTED_PRIMITIVES.intersection(set(enum.__mro__))
245-
assert len(primitive) == 1, f'{enum} must inherit from exactly one ' \
246-
f'supported primitive type ' \
247-
f'{mcs.SUPPORTED_PRIMITIVES}'
282+
primitives = mcs.SUPPORTED_PRIMITIVES.intersection(set(enum.__mro__))
283+
assert len(primitives) == 1, f'{enum} must inherit from exactly one ' \
284+
f'supported primitive type ' \
285+
f'{mcs.SUPPORTED_PRIMITIVES}, ' \
286+
f'encountered: {primitives}.'
248287

249-
primitive = list(primitive)[0]
288+
primitive = list(primitives)[0]
250289

251290
if primitive is float:
252291
return EnumFloatField
@@ -271,7 +310,11 @@ def __new__(mcs, enum): # pylint: disable=R0911
271310
return EnumCharField
272311

273312

274-
def EnumField(enum, *field_args, **field_kwargs): # pylint: disable=C0103
313+
def EnumField( # pylint: disable=C0103
314+
enum: Choices,
315+
*field_args,
316+
**field_kwargs
317+
) -> Field:
275318
"""
276319
Some syntactic sugar that wraps the enum field metaclass so that we can
277320
cleanly create enums like so:

django_enum/filters.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Support for django-filter"""
2+
from typing import Tuple, Type
3+
4+
from django.db.models import Field as ModelField
5+
from django.forms.fields import Field as FormField
26
from django_enum.fields import EnumMixin
37
from django_enum.forms import EnumChoiceField
48

59
try:
6-
from django_filters import ChoiceFilter, filterset
7-
10+
from django_filters import ChoiceFilter, Filter, filterset
811

912
class EnumFilter(ChoiceFilter):
1013
"""
@@ -28,7 +31,7 @@ class Color(TextChoices, s('rgb'), s('hex', case_fold=True)):
2831
parameter values from any of the symmetric properties: ?color=Red,
2932
?color=ff0000, etc...
3033
"""
31-
field_class = EnumChoiceField
34+
field_class: FormField = EnumChoiceField
3235

3336
def __init__(self, *, enum, **kwargs):
3437
self.enum = enum
@@ -42,7 +45,11 @@ class FilterSet(filterset.FilterSet):
4245
default instead of ``ChoiceFilter``.
4346
"""
4447
@classmethod
45-
def filter_for_lookup(cls, field, lookup_type):
48+
def filter_for_lookup(
49+
cls,
50+
field: ModelField,
51+
lookup_type: str
52+
) -> Tuple[Type[Filter], dict]:
4653
"""For EnumFields use the EnumFilter class by default"""
4754
if isinstance(field, EnumMixin) and getattr(field, 'enum', None):
4855
return EnumFilter, {'enum': field.enum}
@@ -51,13 +58,14 @@ def filter_for_lookup(cls, field, lookup_type):
5158

5259
except (ImportError, ModuleNotFoundError):
5360

54-
class MissingDjangoFilters:
61+
class _MissingDjangoFilters:
5562
"""Throw error if filter support is used without django-filter"""
63+
5664
def __init__(self, *args, **kwargs):
5765
raise ImportError(
5866
f'{self.__class__.__name__} requires django-filter to be '
5967
f'installed.'
6068
)
6169

62-
EnumFilter = MissingDjangoFilters
63-
FilterSet = MissingDjangoFilters
70+
EnumFilter = _MissingDjangoFilters # type: ignore
71+
FilterSet = _MissingDjangoFilters # type: ignore

django_enum/forms.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Enumeration support for django model forms"""
2+
from typing import Any, Dict, Iterable, List, Tuple, Type, Union
3+
24
from django.core.exceptions import ValidationError
5+
from django.db.models import Choices
36
from django.forms.fields import ChoiceField
4-
from django.forms.widgets import Select
7+
from django.forms.widgets import Select, Widget
58

69
# pylint: disable=R0801
710

@@ -20,12 +23,13 @@ class NonStrictSelect(Select):
2023
A Select widget for non-strict EnumChoiceFields that includes any existing
2124
non-conforming value as a choice option.
2225
"""
26+
choices: Iterable[Tuple[Any, str]]
2327

2428
def render(self, *args, **kwargs):
2529
"""Before rendering if we're a non strict field and our value is """
26-
value = kwargs.get('value')
30+
value: Any = kwargs.get('value')
2731
if value not in self.attrs.get('empty_values', []):
28-
self.choices = list(self.choices) + [(value, value)]
32+
self.choices = list(self.choices) + [(value, str(value))]
2933
return super().render(*args, **kwargs)
3034

3135

@@ -48,16 +52,18 @@ class EnumChoiceField(ChoiceField):
4852
:param kwargs: Any additional parameters to pass to ChoiceField base class.
4953
"""
5054

51-
strict = True
52-
empty_value = ''
55+
enum: Type[Choices]
56+
strict: bool = True
57+
empty_value: Any = ''
58+
empty_values: List[Any]
5359

5460
def __init__(
5561
self,
56-
enum,
62+
enum: Type[Choices],
5763
*,
58-
empty_value=_Unspecified,
59-
strict=strict,
60-
choices=(),
64+
empty_value: Any = _Unspecified,
65+
strict: bool = strict,
66+
choices: Iterable[Tuple[Any, str]] = (),
6167
**kwargs
6268
):
6369
self.enum = enum
@@ -91,11 +97,11 @@ def __init__(
9197
f'non-conflicting empty_value.'
9298
)
9399

94-
def _coerce_to_value_type(self, value):
100+
def _coerce_to_value_type(self, value: Any) -> Any:
95101
"""Coerce the value to the enumerations value type"""
96102
return type(self.enum.values[0])(value)
97103

98-
def _coerce(self, value):
104+
def _coerce(self, value: Any) -> Union[Choices, Any]:
99105
"""
100106
Attempt conversion of value to an enumeration value and return it
101107
if successful.
@@ -127,20 +133,13 @@ def _coerce(self, value):
127133
) from err
128134
return value
129135

130-
def widget_attrs(self, widget):
136+
def widget_attrs(self, widget: Widget) -> Dict[Any, Any]:
131137
attrs = super().widget_attrs(widget)
132138
attrs.setdefault('empty_value', self.empty_value)
133139
attrs.setdefault('empty_values', self.empty_values)
134140
return attrs
135141

136-
def clean(self, value):
137-
"""
138-
Validate the given value and return its "cleaned" value as an
139-
appropriate Python object. Raise ValidationError for any errors.
140-
"""
141-
return super().clean(self._coerce(value))
142-
143-
def prepare_value(self, value):
142+
def prepare_value(self, value: Any) -> Any:
144143
"""Must return the raw enumeration value type"""
145144
value = self._coerce(value)
146145
return super().prepare_value(
@@ -149,11 +148,11 @@ def prepare_value(self, value):
149148
else value
150149
)
151150

152-
def to_python(self, value):
151+
def to_python(self, value: Any) -> Union[Choices, Any]:
153152
"""Return the value as its full enumeration object"""
154153
return self._coerce(value)
155154

156-
def valid_value(self, value):
155+
def valid_value(self, value: Any) -> bool:
157156
"""Return false if this value is not valid"""
158157
try:
159158
self._coerce(value)

0 commit comments

Comments
 (0)