From 1b44e415d0ca42ac04507b002919e903b429a909 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 00:38:33 -0800 Subject: [PATCH 1/9] fixes for django 5 --- .github/workflows/test.yml | 10 ++++++---- django_enum/__init__.py | 2 +- django_enum/choices.py | 10 +++++++--- django_enum/fields.py | 16 +++++++++++++++- django_enum/forms.py | 5 +++++ django_enum/tests/djenum/enums.py | 4 +++- django_enum/tests/djenum/views.py | 4 ++++ doc/requirements.txt | 2 +- doc/source/changelog.rst | 5 +++++ pyproject.toml | 5 +++-- 10 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0257456..ba842a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,19 +10,21 @@ jobs: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] django-version: - 'Django~=3.2.0' # LTS April 2024 - - 'Django~=4.1.0' # December 2023 - 'Django~=4.2.0' # LTS April 2026 + - 'Django~=5.0.0' # April 2025 exclude: - python-version: '3.7' - django-version: 'Django~=4.1.0' + django-version: 'Django~=5.0.0' - python-version: '3.7' django-version: 'Django~=4.2.0' + - python-version: '3.8' + django-version: 'Django~=5.0.0' + - python-version: '3.9' + django-version: 'Django~=5.0.0' - python-version: '3.11' django-version: 'Django~=3.2.0' - python-version: '3.12' django-version: 'Django~=3.2.0' - - python-version: '3.12' - django-version: 'Django~=4.1.0' steps: - uses: actions/checkout@v2 diff --git a/django_enum/__init__.py b/django_enum/__init__.py index 87c518f..9f7698e 100644 --- a/django_enum/__init__.py +++ b/django_enum/__init__.py @@ -47,7 +47,7 @@ 'EnumFilter' ] -VERSION = (1, 2, 2) +VERSION = (1, 2, 3) __title__ = 'Django Enum' __version__ = '.'.join(str(i) for i in VERSION) diff --git a/django_enum/choices.py b/django_enum/choices.py index b3bc72e..536f239 100644 --- a/django_enum/choices.py +++ b/django_enum/choices.py @@ -9,7 +9,11 @@ from django.db.models import Choices from django.db.models import IntegerChoices as DjangoIntegerChoices from django.db.models import TextChoices as DjangoTextChoices -from django.db.models.enums import ChoicesMeta + +try: + from django.db.models.enums import ChoicesType +except ImportError: # pragma: no cover + from django.db.models.enums import ChoicesMeta as ChoicesType def choices(enum: Optional[Type[Enum]]) -> List[Tuple[Any, str]]: @@ -87,7 +91,7 @@ def values(enum: Optional[Type[Enum]]) -> List[Any]: from enum_properties import EnumPropertiesMeta, SymmetricMixin - class DjangoEnumPropertiesMeta(EnumPropertiesMeta, ChoicesMeta): + class DjangoEnumPropertiesMeta(EnumPropertiesMeta, ChoicesType): """ A composite meta class that combines Django's Choices metaclass with enum-properties metaclass. This metaclass will add Django's expected @@ -162,7 +166,7 @@ def __init__(self, *args, **kwargs): # pylint: disable=W0231 DjangoSymmetricMixin = MissingEnumProperties # type: ignore - class DjangoEnumPropertiesMeta(ChoicesMeta): # type: ignore + class DjangoEnumPropertiesMeta(ChoicesType): # type: ignore """ Throw error if metaclass is used without enum-properties diff --git a/django_enum/fields.py b/django_enum/fields.py index 6c6fab2..5c45e50 100644 --- a/django_enum/fields.py +++ b/django_enum/fields.py @@ -83,6 +83,7 @@ class EnumMixin( enum: Optional[Type[Enum]] = None strict: bool = True coerce: bool = True + primitive: Optional[Type[Any]] = None descriptor_class = ToPythonDeferredAttribute @@ -91,7 +92,7 @@ def _coerce_to_value_type(self, value: Any) -> Enum: # note if enum type is int and a floating point is passed we could get # situations like X.xxx == X - this is acceptable if self.enum: - return type(values(self.enum)[0])(value) + return self.primitive(value) # can't ever reach this - just here to make type checker happy return value # pragma: no cover @@ -109,6 +110,7 @@ def __init__( self.coerce = coerce if enum else False if self.enum is not None: kwargs.setdefault('choices', choices(enum)) + self.primitive = type(values(self.enum)[0]) super().__init__(*args, **kwargs) def _try_coerce( @@ -145,6 +147,18 @@ def _try_coerce( f"{self.enum.__name__} " f"required by field {self.name}." ) from err + elif ( + not self.coerce and self.primitive and + not isinstance(value, self.primitive) and + self.enum and not isinstance(value, self.enum) + ): + try: + value = self._coerce_to_value_type(value) + except (TypeError, ValueError) as err: + raise ValueError( + f"'{value}' is not a valid {self.primitive} " + f"required by field {self.name}." + ) from err return value def deconstruct(self) -> Tuple[str, str, List, dict]: diff --git a/django_enum/forms.py b/django_enum/forms.py index b9544fb..1a13b75 100644 --- a/django_enum/forms.py +++ b/django_enum/forms.py @@ -217,3 +217,8 @@ def validate(self, value): code='invalid_choice', params={'value': value}, ) + + def clean(self, value: Any) -> Union[Enum, Any]: + """Return the value as its full enumeration object""" + value = super().clean(value) + return value \ No newline at end of file diff --git a/django_enum/tests/djenum/enums.py b/django_enum/tests/djenum/enums.py index 80c3075..a431fb8 100644 --- a/django_enum/tests/djenum/enums.py +++ b/django_enum/tests/djenum/enums.py @@ -5,7 +5,9 @@ class FloatChoices(float, Choices): - pass + + def __str__(self): + return str(self.value) class DJIntEnum(IntegerChoices): diff --git a/django_enum/tests/djenum/views.py b/django_enum/tests/djenum/views.py index 600e597..1f0a627 100644 --- a/django_enum/tests/djenum/views.py +++ b/django_enum/tests/djenum/views.py @@ -48,6 +48,10 @@ class EnumTesterCreateView(URLMixin, CreateView): fields = '__all__' + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + class EnumTesterUpdateView(URLMixin, UpdateView): model = EnumTester template_name = 'enumtester_form.html' diff --git a/doc/requirements.txt b/doc/requirements.txt index 0793785..64f1c37 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -6,4 +6,4 @@ sphinxcontrib-htmlhelp==2.0.1; python_version >= "3.5" sphinxcontrib-jsmath==1.0.1; python_version >= "3.5" sphinxcontrib-qthelp==1.0.3; python_version >= "3.5" sphinxcontrib-serializinghtml==1.1.5; python_version >= "3.5" -django-enum==1.2.2 +django-enum==1.2.3 diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 2f5e349..249702f 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,6 +2,11 @@ Change Log ========== +v1.2.3 +====== + +* Added `Support Django 5.0 `_ + v1.2.2 ====== diff --git a/pyproject.toml b/pyproject.toml index ab0e30a..9678741 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-enum" -version = "1.2.2" +version = "1.2.3" description = "Full and natural support for enumerations as Django model fields." authors = ["Brian Kohan "] license = "MIT" @@ -18,6 +18,7 @@ classifiers = [ "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", @@ -41,7 +42,7 @@ exclude = ["django_enum/tests"] [tool.poetry.dependencies] python = ">=3.7,<4.0" -Django = ">=3.2,<5.0" +Django = ">=3.2,<6.0" enum-properties = {version = "^1.7.0", optional = true} django-filter = {version = ">=21,<24", optional = true} djangorestframework = {version = "^3.9", optional = true} From 0227e0d648a0e5be6cb4fc7656b2c1440b52ef9c Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 00:47:05 -0800 Subject: [PATCH 2/9] remove errant function override --- django_enum/forms.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/django_enum/forms.py b/django_enum/forms.py index 1a13b75..b9544fb 100644 --- a/django_enum/forms.py +++ b/django_enum/forms.py @@ -217,8 +217,3 @@ def validate(self, value): code='invalid_choice', params={'value': value}, ) - - def clean(self, value: Any) -> Union[Enum, Any]: - """Return the value as its full enumeration object""" - value = super().clean(value) - return value \ No newline at end of file From 9e84547fdab6f7f72dfc4519929e08b439575161 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 01:01:41 -0800 Subject: [PATCH 3/9] fix coercion --- django_enum/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_enum/fields.py b/django_enum/fields.py index 5c45e50..911b956 100644 --- a/django_enum/fields.py +++ b/django_enum/fields.py @@ -91,7 +91,7 @@ def _coerce_to_value_type(self, value: Any) -> Enum: """Coerce the value to the enumerations value type""" # note if enum type is int and a floating point is passed we could get # situations like X.xxx == X - this is acceptable - if self.enum: + if self.enum and self.primitive: return self.primitive(value) # can't ever reach this - just here to make type checker happy return value # pragma: no cover From 90d4b730c8c02991a429b0590b1a29b4ba564e9a Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 11:51:52 -0800 Subject: [PATCH 4/9] fixes #55 --- django_enum/__init__.py | 2 +- django_enum/fields.py | 51 +++++++------ django_enum/tests/tests.py | 147 ++++++++++++++++++++++++++++++++++++- doc/requirements.txt | 2 +- doc/source/changelog.rst | 6 +- pyproject.toml | 2 +- 6 files changed, 179 insertions(+), 31 deletions(-) diff --git a/django_enum/__init__.py b/django_enum/__init__.py index 9f7698e..f1cd1c4 100644 --- a/django_enum/__init__.py +++ b/django_enum/__init__.py @@ -47,7 +47,7 @@ 'EnumFilter' ] -VERSION = (1, 2, 3) +VERSION = (1, 3, 0) __title__ = 'Django Enum' __version__ = '.'.join(str(i) for i in VERSION) diff --git a/django_enum/fields.py b/django_enum/fields.py index 911b956..f5cf718 100644 --- a/django_enum/fields.py +++ b/django_enum/fields.py @@ -83,20 +83,10 @@ class EnumMixin( enum: Optional[Type[Enum]] = None strict: bool = True coerce: bool = True - primitive: Optional[Type[Any]] = None + primitive: Type[Any] descriptor_class = ToPythonDeferredAttribute - def _coerce_to_value_type(self, value: Any) -> Enum: - """Coerce the value to the enumerations value type""" - # note if enum type is int and a floating point is passed we could get - # situations like X.xxx == X - this is acceptable - if self.enum and self.primitive: - return self.primitive(value) - # can't ever reach this - just here to make type checker happy - return value # pragma: no cover - - def __init__( self, *args, @@ -110,7 +100,6 @@ def __init__( self.coerce = coerce if enum else False if self.enum is not None: kwargs.setdefault('choices', choices(enum)) - self.primitive = type(values(self.enum)[0]) super().__init__(*args, **kwargs) def _try_coerce( @@ -123,24 +112,22 @@ def _try_coerce( and non-strict, coercion to enum's primitive type will be done, otherwise a ValueError is raised. """ - if ( - (self.coerce or force) - and self.enum is not None - and not isinstance(value, self.enum) - ): + if self.enum is None: + return value + + if (self.coerce or force) and not isinstance(value, self.enum): try: value = self.enum(value) except (TypeError, ValueError): try: - value = self._coerce_to_value_type(value) - value = self.enum(value) + value = self.enum(value:=self.primitive(value)) except (TypeError, ValueError): try: value = self.enum[value] except KeyError as err: if self.strict or not isinstance( value, - type(values(self.enum)[0]) + self.primitive ): raise ValueError( f"'{value}' is not a valid " @@ -148,17 +135,18 @@ def _try_coerce( f"required by field {self.name}." ) from err elif ( - not self.coerce and self.primitive and + not self.coerce and not isinstance(value, self.primitive) and - self.enum and not isinstance(value, self.enum) + not isinstance(value, self.enum) ): try: - value = self._coerce_to_value_type(value) + return self.primitive(value) except (TypeError, ValueError) as err: raise ValueError( - f"'{value}' is not a valid {self.primitive} " + f"'{value}' is not coercible to {self.primitive.__name__} " f"required by field {self.name}." ) from err + return value def deconstruct(self) -> Tuple[str, str, List, dict]: @@ -320,6 +308,8 @@ class EnumCharField(EnumMixin, CharField): A database field supporting enumerations with character values. """ + primitive = str + def __init__(self, *args, enum=None, **kwargs): kwargs.setdefault( 'max_length', @@ -334,6 +324,8 @@ def __init__(self, *args, enum=None, **kwargs): class EnumFloatField(EnumMixin, FloatField): """A database field supporting enumerations with floating point values""" + primitive = float + class EnumSmallIntegerField(EnumMixin, SmallIntegerField): """ @@ -341,6 +333,8 @@ class EnumSmallIntegerField(EnumMixin, SmallIntegerField): 2 bytes or fewer """ + primitive = int + class EnumPositiveSmallIntegerField(EnumMixin, PositiveSmallIntegerField): """ @@ -348,6 +342,7 @@ class EnumPositiveSmallIntegerField(EnumMixin, PositiveSmallIntegerField): values that fit into 2 bytes or fewer """ + primitive = int class EnumIntegerField(EnumMixin, IntegerField): """ @@ -355,6 +350,8 @@ class EnumIntegerField(EnumMixin, IntegerField): 32 bytes or fewer """ + primitive = int + class EnumPositiveIntegerField(EnumMixin, PositiveIntegerField): """ @@ -362,6 +359,8 @@ class EnumPositiveIntegerField(EnumMixin, PositiveIntegerField): values that fit into 32 bytes or fewer """ + primitive = int + class EnumBigIntegerField(EnumMixin, BigIntegerField): """ @@ -369,6 +368,8 @@ class EnumBigIntegerField(EnumMixin, BigIntegerField): 64 bytes or fewer """ + primitive = int + class EnumPositiveBigIntegerField(EnumMixin, PositiveBigIntegerField): """ @@ -376,6 +377,8 @@ class EnumPositiveBigIntegerField(EnumMixin, PositiveBigIntegerField): values that fit into 64 bytes or fewer """ + primitive = int + class _EnumFieldMetaClass(type): diff --git a/django_enum/tests/tests.py b/django_enum/tests/tests.py index 5a794c8..24e2a22 100755 --- a/django_enum/tests/tests.py +++ b/django_enum/tests/tests.py @@ -16,6 +16,7 @@ from django_enum import TextChoices from django_enum.choices import choices, labels, names, values from django_enum.forms import EnumChoiceField # dont remove this +from django.forms import Form, ModelForm # from django_enum.tests.djenum.enums import ( # BigIntEnum, # BigPosIntEnum, @@ -414,6 +415,28 @@ def test_basic_save(self): ) self.MODEL_CLASS.objects.all().delete() + def test_coerce_to_primitive(self): + + create_params = { + **self.create_params, + 'no_coerce': '32767' + } + + tester = self.MODEL_CLASS.objects.create(**create_params) + + self.assertIsInstance(tester.no_coerce, int) + self.assertEqual(tester.no_coerce, 32767) + + def test_coerce_to_primitive_error(self): + + create_params = { + **self.create_params, + 'no_coerce': 'Value 32767' + } + + with self.assertRaises(ValueError): + self.MODEL_CLASS.objects.create(**create_params) + def test_to_python_deferred_attribute(self): obj = self.MODEL_CLASS.objects.create(**self.create_params) with self.assertNumQueries(1): @@ -1813,6 +1836,109 @@ def test_bulk_update(self): ) +class FormTests(EnumTypeMixin, TestCase): + """ + Some more explicit form tests that allow easier access to other internal workflows. + """ + + MODEL_CLASS = EnumTester + + @property + def model_form_class(self): + + class EnumTesterForm(ModelForm): + + class Meta: + model = self.MODEL_CLASS + fields = '__all__' + + return EnumTesterForm + + @property + def basic_form_class(self): + from django.core.validators import MinValueValidator, MaxValueValidator + + class BasicForm(Form): + + small_pos_int = EnumChoiceField(self.SmallPosIntEnum) + small_int = EnumChoiceField(self.SmallIntEnum) + pos_int = EnumChoiceField(self.PosIntEnum) + int = EnumChoiceField(self.IntEnum) + big_pos_int = EnumChoiceField(self.BigPosIntEnum) + big_int = EnumChoiceField(self.BigIntEnum) + constant = EnumChoiceField(self.Constants) + text = EnumChoiceField(self.TextEnum) + extern = EnumChoiceField(self.ExternEnum) + dj_int_enum = EnumChoiceField(self.DJIntEnum) + dj_text_enum = EnumChoiceField(self.DJTextEnum) + non_strict_int = EnumChoiceField(self.SmallPosIntEnum, strict=False) + non_strict_text = EnumChoiceField(self.TextEnum, strict=False) + no_coerce = EnumChoiceField( + self.SmallPosIntEnum, + validators=[MinValueValidator(0), MaxValueValidator(32767)] + ) + + return BasicForm + + @property + def test_params(self): + return { + 'small_pos_int': self.SmallPosIntEnum.VAL2, + 'small_int': self.SmallIntEnum.VALn1, + 'pos_int': self.PosIntEnum.VAL3, + 'int': self.IntEnum.VALn1, + 'big_pos_int': self.BigPosIntEnum.VAL3, + 'big_int': self.BigIntEnum.VAL2, + 'constant': self.Constants.GOLDEN_RATIO, + 'text': self.TextEnum.VALUE2, + 'extern': self.ExternEnum.TWO, + 'dj_int_enum': self.DJIntEnum.THREE, + 'dj_text_enum': self.DJTextEnum.A, + 'non_strict_int': '15', + 'non_strict_text': 'arbitrary', + 'no_coerce': self.SmallPosIntEnum.VAL3 + } + + @property + def test_data_strings(self): + return {key: str(value) for key, value in self.test_params.items()} + + @property + def expected(self): + return { + **self.test_params, + 'non_strict_int': int(self.test_params['non_strict_int']), + } + + def test_modelform_binding(self): + form = self.model_form_class(data=self.test_data_strings) + + form.full_clean() + self.assertTrue(form.is_valid()) + + for key, value in self.expected.items(): + self.assertEqual(form.cleaned_data[key], value) + + self.assertIsInstance(form.cleaned_data['no_coerce'], int) + self.assertIsInstance(form.cleaned_data['non_strict_int'], int) + + obj = form.save() + + for key, value in self.expected.items(): + self.assertEqual(getattr(obj, key), value) + + def test_basicform_binding(self): + form = self.basic_form_class(data=self.test_data_strings) + form.full_clean() + self.assertTrue(form.is_valid()) + + for key, value in self.expected.items(): + self.assertEqual(form.cleaned_data[key], value) + + self.assertIsInstance(form.cleaned_data['no_coerce'], int) + self.assertIsInstance(form.cleaned_data['non_strict_int'], int) + + if ENUM_PROPERTIES_INSTALLED: from django_enum.forms import EnumChoiceField @@ -1843,6 +1969,9 @@ def test_bulk_update(self): ) from enum_properties import EnumProperties, s + class EnumPropertiesFormTests(FormTests): + + MODEL_CLASS = EnumTester class TestEnumPropertiesIntegration(TestCase): @@ -3362,6 +3491,22 @@ def test_validate(self): self.assertTrue(tester._meta.get_field('dj_text_enum').validate('A', tester) is None) self.assertTrue(tester._meta.get_field('non_strict_int').validate(20, tester) is None) + def test_coerce_to_primitive_error(self): + """ + Override this base class test because this should work with symmetrical enum. + """ + create_params = { + **self.create_params, + 'no_coerce': 'Value 32767' + } + + tester = self.MODEL_CLASS.objects.create(**create_params) + self.assertEqual(tester.no_coerce, self.SmallPosIntEnum.VAL3) + self.assertEqual(tester.no_coerce, 'Value 32767') + + tester.refresh_from_db() + self.assertEqual(tester.no_coerce, 32767) + class PerformanceTest(TestCase): """ We intentionally test bulk operations performance because thats what @@ -3660,7 +3805,6 @@ def test_color(self): ).first() == instance ) - from django.forms import ModelForm from django_enum import EnumChoiceField class TextChoicesExampleForm(ModelForm): @@ -3728,4 +3872,3 @@ def test_no_coerce(self): else: # pragma: no cover pass - diff --git a/doc/requirements.txt b/doc/requirements.txt index 64f1c37..1121837 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -6,4 +6,4 @@ sphinxcontrib-htmlhelp==2.0.1; python_version >= "3.5" sphinxcontrib-jsmath==1.0.1; python_version >= "3.5" sphinxcontrib-qthelp==1.0.3; python_version >= "3.5" sphinxcontrib-serializinghtml==1.1.5; python_version >= "3.5" -django-enum==1.2.3 +django-enum==1.3.0 diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 249702f..9a387be 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,10 +2,12 @@ Change Log ========== -v1.2.3 +v1.3.0 ====== -* Added `Support Django 5.0 `_ +* Implemented `Support db_default `_ +* Fixed `When coerce=False, enum form fields and model fields should still coerce to the enum's primitive type. `_ +* Implemented `Support Django 5.0 `_ v1.2.2 ====== diff --git a/pyproject.toml b/pyproject.toml index 9678741..5053a4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-enum" -version = "1.2.3" +version = "1.3.0" description = "Full and natural support for enumerations as Django model fields." authors = ["Brian Kohan "] license = "MIT" From 4b122c35346c5c83dd0da34c5cbb30e197aa1220 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 13:06:01 -0800 Subject: [PATCH 5/9] fixes #56 --- django_enum/fields.py | 12 +- django_enum/tests/db_default/__init__.py | 0 django_enum/tests/db_default/apps.py | 6 + .../db_default/migrations/0001_initial.py | 278 ++++++++++++++++++ .../tests/db_default/migrations/__init__.py | 0 django_enum/tests/db_default/models.py | 88 ++++++ django_enum/tests/settings.py | 4 + django_enum/tests/tests.py | 59 ++++ 8 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 django_enum/tests/db_default/__init__.py create mode 100644 django_enum/tests/db_default/apps.py create mode 100644 django_enum/tests/db_default/migrations/0001_initial.py create mode 100644 django_enum/tests/db_default/migrations/__init__.py create mode 100644 django_enum/tests/db_default/models.py diff --git a/django_enum/fields.py b/django_enum/fields.py index f5cf718..c43444e 100644 --- a/django_enum/fields.py +++ b/django_enum/fields.py @@ -32,6 +32,12 @@ T = TypeVar('T') # pylint: disable=C0103 +try: + from django.db.models.expressions import DatabaseDefault +except ImportError: # pragma: no cover + class DatabaseDefault: + pass + def with_typehint(baseclass: Type[T]) -> Type[T]: """ @@ -56,7 +62,11 @@ class ToPythonDeferredAttribute(DeferredAttribute): def __set__(self, instance: Model, value: Any): try: - instance.__dict__[self.field.name] = self.field.to_python(value) + instance.__dict__[self.field.name] = ( + value + if isinstance(value, DatabaseDefault) else + self.field.to_python(value) + ) except (ValidationError, ValueError): # Django core fields allow assignment of any value, we do the same instance.__dict__[self.field.name] = value diff --git a/django_enum/tests/db_default/__init__.py b/django_enum/tests/db_default/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_enum/tests/db_default/apps.py b/django_enum/tests/db_default/apps.py new file mode 100644 index 0000000..0b8d72e --- /dev/null +++ b/django_enum/tests/db_default/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DBDefaultConfig(AppConfig): + name = 'django_enum.tests.db_default' + label = name.replace('.', '_') diff --git a/django_enum/tests/db_default/migrations/0001_initial.py b/django_enum/tests/db_default/migrations/0001_initial.py new file mode 100644 index 0000000..9c5c47e --- /dev/null +++ b/django_enum/tests/db_default/migrations/0001_initial.py @@ -0,0 +1,278 @@ +# Generated by Django 5.0 on 2023-12-13 20:24 + +import django_enum.fields +import django_enum.tests.djenum.enums +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="DBDefaultTester", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "small_pos_int", + django_enum.fields.EnumPositiveSmallIntegerField( + blank=True, + choices=[ + (0, "Value 1"), + (2, "Value 2"), + (32767, "Value 32767"), + ], + db_default=models.Value(None), + null=True, + ), + ), + ( + "small_int", + django_enum.fields.EnumSmallIntegerField( + blank=True, + choices=[ + (-32768, "Value -32768"), + (0, "Value 0"), + (1, "Value 1"), + (2, "Value 2"), + (32767, "Value 32767"), + ], + db_default=models.Value(32767), + ), + ), + ( + "pos_int", + django_enum.fields.EnumPositiveIntegerField( + blank=True, + choices=[ + (0, "Value 0"), + (1, "Value 1"), + (2, "Value 2"), + (2147483647, "Value 2147483647"), + ], + db_default=models.Value(2147483647), + ), + ), + ( + "int", + django_enum.fields.EnumIntegerField( + blank=True, + choices=[ + (-2147483648, "Value -2147483648"), + (0, "Value 0"), + (1, "Value 1"), + (2, "Value 2"), + (2147483647, "Value 2147483647"), + ], + db_default=models.Value(-2147483648), + null=True, + ), + ), + ( + "big_pos_int", + django_enum.fields.EnumPositiveBigIntegerField( + blank=True, + choices=[ + (0, "Value 0"), + (1, "Value 1"), + (2, "Value 2"), + (2147483648, "Value 2147483648"), + ], + db_default=models.Value(None), + null=True, + ), + ), + ( + "big_int", + django_enum.fields.EnumBigIntegerField( + blank=True, + choices=[ + (-2147483649, "Value -2147483649"), + (1, "Value 1"), + (2, "Value 2"), + (2147483648, "Value 2147483648"), + ], + db_default=models.Value(-2147483649), + ), + ), + ( + "constant", + django_enum.fields.EnumFloatField( + blank=True, + choices=[ + (3.141592653589793, "Pi"), + (2.71828, "Euler's Number"), + (1.618033988749895, "Golden Ratio"), + ], + db_default=models.Value(1.618033988749895), + null=True, + ), + ), + ( + "text", + django_enum.fields.EnumCharField( + blank=True, + choices=[ + ("V1", "Value1"), + ("V22", "Value2"), + ("V333", "Value3"), + ("D", "Default"), + ], + db_default=models.Value(""), + max_length=4, + ), + ), + ( + "doubled_text", + django_enum.fields.EnumCharField( + blank=True, + choices=[ + ("V1", "Value1"), + ("V22", "Value2"), + ("V333", "Value3"), + ("D", "Default"), + ], + db_default=models.Value("db_default"), + default="", + max_length=10, + ), + ), + ( + "doubled_text_strict", + django_enum.fields.EnumCharField( + blank=True, + choices=[ + ("V1", "Value1"), + ("V22", "Value2"), + ("V333", "Value3"), + ("D", "Default"), + ], + db_default=models.Value("V22"), + default="D", + max_length=10, + ), + ), + ( + "char_field", + models.CharField( + blank=True, db_default=models.Value("db_default"), max_length=10 + ), + ), + ( + "doubled_char_field", + models.CharField( + blank=True, + db_default=models.Value("db_default"), + default="default", + max_length=10, + ), + ), + ( + "extern", + django_enum.fields.EnumPositiveSmallIntegerField( + blank=True, + choices=[(1, "ONE"), (2, "TWO"), (3, "THREE")], + db_default=models.Value( + django_enum.tests.djenum.enums.ExternEnum["THREE"] + ), + null=True, + ), + ), + ( + "dj_int_enum", + django_enum.fields.EnumPositiveSmallIntegerField( + choices=[(1, "One"), (2, "Two"), (3, "Three")], + db_default=models.Value(1), + ), + ), + ( + "dj_text_enum", + django_enum.fields.EnumCharField( + choices=[("A", "Label A"), ("B", "Label B"), ("C", "Label C")], + db_default=models.Value("A"), + max_length=1, + ), + ), + ( + "non_strict_int", + django_enum.fields.EnumPositiveSmallIntegerField( + blank=True, + choices=[ + (0, "Value 1"), + (2, "Value 2"), + (32767, "Value 32767"), + ], + db_default=models.Value(5), + null=True, + ), + ), + ( + "non_strict_text", + django_enum.fields.EnumCharField( + blank=True, + choices=[ + ("V1", "Value1"), + ("V22", "Value2"), + ("V333", "Value3"), + ("D", "Default"), + ], + db_default=models.Value("arbitrary"), + max_length=12, + ), + ), + ( + "no_coerce", + django_enum.fields.EnumPositiveSmallIntegerField( + blank=True, + choices=[ + (0, "Value 1"), + (2, "Value 2"), + (32767, "Value 32767"), + ], + db_default=models.Value(2), + null=True, + ), + ), + ( + "no_coerce_value", + django_enum.fields.EnumPositiveSmallIntegerField( + blank=True, + choices=[ + (0, "Value 1"), + (2, "Value 2"), + (32767, "Value 32767"), + ], + db_default=models.Value(32767), + null=True, + ), + ), + ( + "no_coerce_none", + django_enum.fields.EnumPositiveSmallIntegerField( + blank=True, + choices=[ + (0, "Value 1"), + (2, "Value 2"), + (32767, "Value 32767"), + ], + db_default=models.Value(None), + null=True, + ), + ), + ], + options={ + "ordering": ("id",), + }, + ), + ] diff --git a/django_enum/tests/db_default/migrations/__init__.py b/django_enum/tests/db_default/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_enum/tests/db_default/models.py b/django_enum/tests/db_default/models.py new file mode 100644 index 0000000..b1251e2 --- /dev/null +++ b/django_enum/tests/db_default/models.py @@ -0,0 +1,88 @@ +from django.db import models +from django.urls import reverse +from django_enum import EnumField +from django_enum.tests.djenum.enums import ( + BigIntEnum, + BigPosIntEnum, + Constants, + DJIntEnum, + DJTextEnum, + ExternEnum, + IntEnum, + PosIntEnum, + SmallIntEnum, + SmallPosIntEnum, + TextEnum, +) + + +class DBDefaultTester(models.Model): + + small_pos_int = EnumField(SmallPosIntEnum, null=True, db_default=None, blank=True) + small_int = EnumField(SmallIntEnum, null=False, db_default=SmallIntEnum.VAL3, blank=True) + + pos_int = EnumField(PosIntEnum, db_default=2147483647, blank=True) + int = EnumField(IntEnum, null=True, db_default=IntEnum.VALn1, blank=True) + + big_pos_int = EnumField(BigPosIntEnum, null=True, db_default=None, blank=True) + big_int = EnumField(BigIntEnum, db_default=-2147483649, blank=True) + + constant = EnumField(Constants, null=True, db_default=Constants.GOLDEN_RATIO, blank=True) + + text = EnumField(TextEnum, db_default='', blank=True, strict=False) + doubled_text = EnumField(TextEnum, default='', db_default='db_default', blank=True, max_length=10, strict=False) + doubled_text_strict = EnumField(TextEnum, default=TextEnum.DEFAULT, db_default=TextEnum.VALUE2, blank=True, max_length=10) + + char_field = models.CharField(db_default='db_default', blank=True, max_length=10) + doubled_char_field = models.CharField(default='default', db_default='db_default', blank=True, max_length=10) + + extern = EnumField(ExternEnum, null=True, db_default=ExternEnum.THREE, blank=True) + + dj_int_enum = EnumField(DJIntEnum, db_default=DJIntEnum.ONE) + dj_text_enum = EnumField(DJTextEnum, db_default='A') + + # Non-strict + non_strict_int = EnumField( + SmallPosIntEnum, + strict=False, + null=True, + db_default=5, + blank=True + ) + + non_strict_text = EnumField( + TextEnum, + max_length=12, + strict=False, + null=False, + db_default='arbitrary', + blank=True + ) + + no_coerce = EnumField( + SmallPosIntEnum, + coerce=False, + null=True, + db_default=SmallPosIntEnum.VAL2, + blank=True + ) + + no_coerce_value = EnumField( + SmallPosIntEnum, + coerce=False, + null=True, + db_default=SmallPosIntEnum.VAL3.value, + blank=True + ) + + no_coerce_none = EnumField( + SmallPosIntEnum, + coerce=False, + null=True, + db_default=None, + blank=True + ) + + class Meta: + ordering = ('id',) + diff --git a/django_enum/tests/settings.py b/django_enum/tests/settings.py index eb66088..705bfe5 100644 --- a/django_enum/tests/settings.py +++ b/django_enum/tests/settings.py @@ -1,4 +1,5 @@ from pathlib import Path +from django import VERSION as django_version SECRET_KEY = 'psst' SITE_ID = 1 @@ -53,6 +54,9 @@ 'django.contrib.admin', ] +if django_version[0:2] >= (5, 0): + INSTALLED_APPS.insert(0, 'django_enum.tests.db_default') + try: import rest_framework INSTALLED_APPS.insert(0, 'rest_framework') diff --git a/django_enum/tests/tests.py b/django_enum/tests/tests.py index 24e2a22..df93181 100755 --- a/django_enum/tests/tests.py +++ b/django_enum/tests/tests.py @@ -17,6 +17,7 @@ from django_enum.choices import choices, labels, names, values from django_enum.forms import EnumChoiceField # dont remove this from django.forms import Form, ModelForm +from django import VERSION as django_version # from django_enum.tests.djenum.enums import ( # BigIntEnum, # BigPosIntEnum, @@ -3872,3 +3873,61 @@ def test_no_coerce(self): else: # pragma: no cover pass + + +if django_version[0:2] >= (5, 0): + from django_enum.tests.db_default.models import DBDefaultTester + + class DBDefaultTests(EnumTypeMixin, TestCase): + + MODEL_CLASS = DBDefaultTester + + @property + def defaults(self): + return { + 'small_pos_int': None, + 'small_int': self.SmallIntEnum.VAL3, + 'pos_int': self.PosIntEnum.VAL3, + 'int': self.IntEnum.VALn1, + 'big_pos_int': None, + 'big_int': self.BigIntEnum.VAL0, + 'constant': self.Constants.GOLDEN_RATIO, + 'char_field': 'db_default', + 'doubled_char_field': 'default', + 'text': '', + 'doubled_text': '', + 'doubled_text_strict': self.TextEnum.DEFAULT, + 'extern': self.ExternEnum.THREE, + 'dj_int_enum': self.DJIntEnum.ONE, + 'dj_text_enum': self.DJTextEnum.A, + 'non_strict_int': 5, + 'non_strict_text': 'arbitrary', + 'no_coerce': 2, + 'no_coerce_value': 32767, + 'no_coerce_none': None + } + + def test_db_defaults(self): + + obj = DBDefaultTester.objects.create() + + for field, value in self.defaults.items(): + obj_field = DBDefaultTester._meta.get_field(field) + obj_value = getattr(obj, field) + self.assertEqual(obj_value, value) + from django_enum.fields import EnumMixin + if ( + isinstance(obj_field, EnumMixin) and + obj_field.strict and + obj_field.coerce and + obj_value is not None + ): + self.assertIsInstance(obj_value, obj_field.enum) + + def test_db_defaults_not_coerced(self): + from django.db.models.expressions import DatabaseDefault + empty_inst = DBDefaultTester() + + # check that the database default value fields are not coerced + for field in [field for field in self.defaults.keys() if not field.startswith('doubled')]: + self.assertIsInstance(getattr(empty_inst, field), DatabaseDefault) From ae9b9754f9d8120a12ae1d8c97bbafa1996858aa Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 13:07:04 -0800 Subject: [PATCH 6/9] remove errant 3.7 incompatible operator --- django_enum/fields.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/django_enum/fields.py b/django_enum/fields.py index c43444e..c253051 100644 --- a/django_enum/fields.py +++ b/django_enum/fields.py @@ -130,7 +130,8 @@ def _try_coerce( value = self.enum(value) except (TypeError, ValueError): try: - value = self.enum(value:=self.primitive(value)) + value = self.primitive(value) + value = self.enum(value) except (TypeError, ValueError): try: value = self.enum[value] From d72d47105e44ba424fcfc8e365a9e72bcf4bfa5e Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 13:21:08 -0800 Subject: [PATCH 7/9] static analysis fixes, also fix remaining problem for #55 in Django >= 5 --- django_enum/choices.py | 7 +++++-- django_enum/fields.py | 17 +++++++++-------- django_enum/tests/settings.py | 3 ++- django_enum/tests/tests.py | 8 ++++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/django_enum/choices.py b/django_enum/choices.py index 536f239..16ee322 100644 --- a/django_enum/choices.py +++ b/django_enum/choices.py @@ -108,7 +108,7 @@ class DjangoSymmetricMixin(SymmetricMixin): _symmetric_builtins_ = ['name', 'label'] - class TextChoices( + class TextChoices( # pylint: disable=too-many-ancestors DjangoSymmetricMixin, DjangoTextChoices, metaclass=DjangoEnumPropertiesMeta @@ -122,7 +122,7 @@ def __hash__(self): return DjangoTextChoices.__hash__(self) - class IntegerChoices( + class IntegerChoices( # pylint: disable=too-many-ancestors DjangoSymmetricMixin, DjangoIntegerChoices, metaclass=DjangoEnumPropertiesMeta @@ -150,6 +150,9 @@ class FloatChoices( def __hash__(self): return float.__hash__(self) + def __str__(self): + return str(self.value) + except (ImportError, ModuleNotFoundError): diff --git a/django_enum/fields.py b/django_enum/fields.py index c253051..baf1c15 100644 --- a/django_enum/fields.py +++ b/django_enum/fields.py @@ -27,16 +27,17 @@ SmallIntegerField, ) from django.db.models.query_utils import DeferredAttribute -from django_enum.choices import choices, values -from django_enum.forms import EnumChoiceField, NonStrictSelect - -T = TypeVar('T') # pylint: disable=C0103 try: from django.db.models.expressions import DatabaseDefault except ImportError: # pragma: no cover - class DatabaseDefault: - pass + class DatabaseDefault: # type: ignore + """Spoof DatabaseDefault for Django < 5.0""" + +from django_enum.choices import choices, values +from django_enum.forms import EnumChoiceField, NonStrictSelect + +T = TypeVar('T') # pylint: disable=C0103 def with_typehint(baseclass: Type[T]) -> Type[T]: @@ -124,7 +125,7 @@ def _try_coerce( """ if self.enum is None: return value - + if (self.coerce or force) and not isinstance(value, self.enum): try: value = self.enum(value) @@ -157,7 +158,7 @@ def _try_coerce( f"'{value}' is not coercible to {self.primitive.__name__} " f"required by field {self.name}." ) from err - + return value def deconstruct(self) -> Tuple[str, str, List, dict]: diff --git a/django_enum/tests/settings.py b/django_enum/tests/settings.py index 705bfe5..e04732f 100644 --- a/django_enum/tests/settings.py +++ b/django_enum/tests/settings.py @@ -1,4 +1,5 @@ from pathlib import Path + from django import VERSION as django_version SECRET_KEY = 'psst' @@ -54,7 +55,7 @@ 'django.contrib.admin', ] -if django_version[0:2] >= (5, 0): +if django_version[0:2] >= (5, 0): # pragma: no cover INSTALLED_APPS.insert(0, 'django_enum.tests.db_default') try: diff --git a/django_enum/tests/tests.py b/django_enum/tests/tests.py index df93181..19444fe 100755 --- a/django_enum/tests/tests.py +++ b/django_enum/tests/tests.py @@ -4,11 +4,13 @@ from time import perf_counter from bs4 import BeautifulSoup as Soup +from django import VERSION as django_version from django.core import serializers from django.core.exceptions import ValidationError from django.core.management import call_command from django.db import connection, transaction from django.db.models import Q +from django.forms import Form, ModelForm from django.http import QueryDict from django.test import Client, LiveServerTestCase, TestCase from django.urls import reverse @@ -16,8 +18,6 @@ from django_enum import TextChoices from django_enum.choices import choices, labels, names, values from django_enum.forms import EnumChoiceField # dont remove this -from django.forms import Form, ModelForm -from django import VERSION as django_version # from django_enum.tests.djenum.enums import ( # BigIntEnum, # BigPosIntEnum, @@ -1857,7 +1857,7 @@ class Meta: @property def basic_form_class(self): - from django.core.validators import MinValueValidator, MaxValueValidator + from django.core.validators import MaxValueValidator, MinValueValidator class BasicForm(Form): @@ -3875,7 +3875,7 @@ def test_no_coerce(self): pass -if django_version[0:2] >= (5, 0): +if django_version[0:2] >= (5, 0): # pragma: no cover from django_enum.tests.db_default.models import DBDefaultTester class DBDefaultTests(EnumTypeMixin, TestCase): From 477763e08a6e75f8ca625fcdc78de4f0d07779c9 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 13:28:05 -0800 Subject: [PATCH 8/9] remove >= django 5 tests from code coverage count --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index ed14ea4..f4c9b45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,7 @@ addopts = # dont exempt tests from coverage - useful to make sure they're being run omit = django_enum/tests/edit_tests/migrations/*.py + django_enum/tests/db_default/** [mypy] # The mypy configurations: http://bit.ly/2zEl9WI From f2b2e2269185e78497c50bd0d0e4cf32ea351534 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 13 Dec 2023 14:00:06 -0800 Subject: [PATCH 9/9] fix python > 3.11 issue --- django_enum/tests/tests.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/django_enum/tests/tests.py b/django_enum/tests/tests.py index 19444fe..5625dea 100755 --- a/django_enum/tests/tests.py +++ b/django_enum/tests/tests.py @@ -1902,7 +1902,10 @@ def test_params(self): @property def test_data_strings(self): - return {key: str(value) for key, value in self.test_params.items()} + return { + **{key: str(value) for key, value in self.test_params.items()}, + 'extern': str(self.ExternEnum.TWO.value) + } @property def expected(self):