diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index e559b1b..8b4edf6 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -35,7 +35,7 @@ jobs: with: python-version: ${{ github.event.inputs.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Install Just @@ -47,7 +47,7 @@ jobs: run: | sudo apt install emacs - name: Setup tmate session - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 19a54d2..ccdd380 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -54,7 +54,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Install Just @@ -70,7 +70,7 @@ jobs: sudo apt install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e809c9..b4e7234 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: with: python-version: "3.12" # for tomlib - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abf64e2..35d4979 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -118,7 +118,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -133,7 +133,7 @@ jobs: sudo apt install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 @@ -180,7 +180,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -196,7 +196,7 @@ jobs: sudo apt install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 @@ -284,12 +284,12 @@ jobs: sudo apt install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -389,12 +389,12 @@ jobs: sudo apt install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -490,12 +490,12 @@ jobs: sudo apt install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -556,7 +556,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -571,7 +571,7 @@ jobs: uses: rhysd/action-setup-vim@v1 - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 @@ -617,7 +617,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just @@ -633,7 +633,7 @@ jobs: brew install emacs - name: Setup tmate session if: ${{ github.event.inputs.debug == 'true' }} - uses: mxschmitt/action-tmate@v3.19 + uses: mxschmitt/action-tmate@v3.21 with: detached: true timeout-minutes: 60 @@ -659,7 +659,7 @@ jobs: with: python-version: '3.12' - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true - name: Setup Just diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index e0b452f..5fc52c2 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -4,6 +4,11 @@ Change Log ========== +v2.2.3 (2025-04-28) +=================== + +* Add docs on hash equivalency, fixing `Enum value doesn't show in Django Admin's list_display `_ + v2.2.2 (2025-04-18) =================== diff --git a/doc/source/howto/admin.rst b/doc/source/howto/admin.rst new file mode 100644 index 0000000..6fc9518 --- /dev/null +++ b/doc/source/howto/admin.rst @@ -0,0 +1,13 @@ +.. include:: ../refs.rst + +.. _admin: + +================ +Use Django Admin +================ + +:class:`~django_enum.fields.EnumField` will mostly just work in the Django +:mod:`~django.contrib.admin`. There is +`one issue `_ where :ref:`enums that are +not hash equivalent ` will not render value labels correctly in the +:class:`~django.contrib.admin.ModelAdmin` :attr:`~django.contrib.admin.ModelAdmin.list_display`. diff --git a/doc/source/howto/external.rst b/doc/source/howto/external.rst index 4175d38..c16569a 100644 --- a/doc/source/howto/external.rst +++ b/doc/source/howto/external.rst @@ -29,3 +29,34 @@ The list of choice tuples for each field are: values assigned depend on the order of declaration. This means that if the order changes existing database values will no longer align with the enumeration values. When control over the values is not certain it is a good idea to add integration tests that look for value changes. + +.. _hash_equivalency: + +Hash Equivalency +---------------- + +.. tip:: + + It is a good idea to make sure your enumeration instances are hash equivalent to their + primitive values. You can do this simply by inheriting from their primitive value + (e.g. ``class MyEnum(str, Enum):``) or by using :class:`~enum.StrEnum` and + :class:`~enum.IntEnum` types. Any enumeration defined using :doc:`enum-properties:index` + will be hash equivalent to its values by default. + +:class:`~django_enum.fields.EnumField` automatically sets the choices tuple on the field. Django_ +has logic in a number of places that handles fields with choices in a special way +(e.g. :ref:`in the admin `). For example, the choices may be converted to a dictionary +mapping values to labels. The values will be the primitive values of the enumeration not +enumeration instances and the current value of the field which may be an enumeration instance will +be searched for in the dictionary. This will fail if the enumeration instance is not hash +equivalent to its value. + +To control the hashing behavior of an object, you must override its :meth:`~object.__hash__` and +:meth:`~object.__eq__` methods. + +For example: + +.. literalinclude:: ../../../tests/examples/models/hash_equivalency.py + +.. literalinclude:: ../../../tests/examples/hash_equivalency_howto.py + :lines: 3- diff --git a/doc/source/howto/index.rst b/doc/source/howto/index.rst index 0c62ceb..546ce13 100644 --- a/doc/source/howto/index.rst +++ b/doc/source/howto/index.rst @@ -45,3 +45,4 @@ are possible with :class:`~django_enum.fields.EnumField`. See :ref:`enum_props`. integrations migrations urls + admin diff --git a/pyproject.toml b/pyproject.toml index 229eb1b..6cf0a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "django-enum" -version = "2.2.2" +version = "2.2.3" description = "Full and natural support for enumerations as Django model fields." requires-python = ">=3.9,<4.0" authors = [ diff --git a/src/django_enum/__init__.py b/src/django_enum/__init__.py index f38b0dd..70218ee 100644 --- a/src/django_enum/__init__.py +++ b/src/django_enum/__init__.py @@ -16,7 +16,7 @@ __all__ = ["EnumField"] -VERSION = (2, 2, 2) +VERSION = (2, 2, 3) __title__ = "Django Enum" __version__ = ".".join(str(i) for i in VERSION) diff --git a/tests/djenum/admin.py b/tests/djenum/admin.py index 0607a9f..d6c2839 100644 --- a/tests/djenum/admin.py +++ b/tests/djenum/admin.py @@ -27,7 +27,14 @@ class AdminDisplayBug35Admin(admin.ModelAdmin): - list_display = ("text_enum", "int_enum") + list_display = ( + "text_enum", + "int_enum", + "status_basic", + "status_basic_int", + "status_str", + "status_int", + ) readonly_fields = ("text_enum", "int_enum", "blank_int", "blank_txt") diff --git a/tests/djenum/enums.py b/tests/djenum/enums.py index eb7212a..3fa0342 100644 --- a/tests/djenum/enums.py +++ b/tests/djenum/enums.py @@ -398,3 +398,61 @@ class GNSSConstellation(IntFlag): GALILEO = 1 << 2 BEIDOU = 1 << 3 QZSS = 1 << 4 + + +class NestStatusBasic(Enum): + INIT = "INIT" + LOADED = "LOADED" + ACTIVE = "ACTIVE" + DONE = "DONE" + REJECTED = "REJECTED" + CANCELLED = "CANCELLED" + + def __hash__(self): + return hash(self.value) + + def __eq__(self, value) -> bool: + if isinstance(value, self.__class__): + return self.value == value.value + try: + return self.value == self.__class__(value).value + except (ValueError, TypeError): + return False + + +class NestStatusStr(str, Enum): + INIT = "INIT" + LOADED = "LOADED" + ACTIVE = "ACTIVE" + DONE = "DONE" + REJECTED = "REJECTED" + CANCELLED = "CANCELLED" + + +class NestStatusBasicInt(Enum): + INIT = 0 + LOADED = 1 + ACTIVE = 2 + DONE = 3 + REJECTED = 4 + CANCELLED = 5 + + def __hash__(self): + return hash(self.value) + + def __eq__(self, value) -> bool: + if isinstance(value, self.__class__): + return self.value == value.value + try: + return self.value == self.__class__(value).value + except (ValueError, TypeError): + return False + + +class NestStatusInt(int, Enum): + INIT = 0 + LOADED = 1 + ACTIVE = 2 + DONE = 3 + REJECTED = 4 + CANCELLED = 5 diff --git a/tests/djenum/models.py b/tests/djenum/models.py index 9a31a15..0091c94 100644 --- a/tests/djenum/models.py +++ b/tests/djenum/models.py @@ -41,6 +41,10 @@ TimeEnum, NullableConstants, GNSSConstellation, + NestStatusBasic, + NestStatusInt, + NestStatusBasicInt, + NestStatusStr, ) @@ -159,6 +163,11 @@ class AdminDisplayBug35(models.Model): blank_txt = EnumField(TextEnum, null=True, default=None) + status_basic = EnumField(NestStatusBasic, null=True, default=None) + status_basic_int = EnumField(NestStatusBasicInt, null=True, default=None) + status_int = EnumField(NestStatusInt, null=True, default=None) + status_str = EnumField(NestStatusStr, null=True, default=None) + class EmptyEnumValueTester(models.Model): class BlankTextEnum(TextChoices): diff --git a/tests/examples/hash_equivalency_howto.py b/tests/examples/hash_equivalency_howto.py new file mode 100644 index 0000000..3d6e178 --- /dev/null +++ b/tests/examples/hash_equivalency_howto.py @@ -0,0 +1,37 @@ +from .models.hash_equivalency import HashEquivalencyExample + + +obj = HashEquivalencyExample.objects.create( + not_hash_eq=HashEquivalencyExample.NotHashEq.VALUE1, + hash_eq=HashEquivalencyExample.HashEq.VALUE1, + hash_eq_str=HashEquivalencyExample.HashEqStr.VALUE1 +) + +# direct comparisons to values do not work +assert obj.not_hash_eq != "V1" + +# unless you have provided __eq__ or inherited from the primitive +assert obj.hash_eq == obj.hash_eq_str == "V1" + +# here is the problem that can break some Django internals in rare instances: +assert dict(HashEquivalencyExample._meta.get_field("not_hash_eq").flatchoices) == { + "V1": "VALUE1", + "V2": "VALUE2", + "V3": "VALUE3" +} + +try: + dict(HashEquivalencyExample._meta.get_field("not_hash_eq").flatchoices)[ + HashEquivalencyExample.NotHashEq.VALUE1 + ] + assert False +except KeyError: + assert True + +# if we've made our enum hash equivalent though, this works: +assert dict(HashEquivalencyExample._meta.get_field("hash_eq").flatchoices)[ + HashEquivalencyExample.HashEq.VALUE1 +] == "VALUE1" +assert dict(HashEquivalencyExample._meta.get_field("hash_eq_str").flatchoices)[ + HashEquivalencyExample.HashEqStr.VALUE1 +] == "VALUE1" diff --git a/tests/examples/models/__init__.py b/tests/examples/models/__init__.py index cf1a8b6..789a594 100644 --- a/tests/examples/models/__init__.py +++ b/tests/examples/models/__init__.py @@ -11,6 +11,7 @@ from .gnss import GNSSReceiver, Constellation from .gnss_vanilla import GNSSReceiverBasic from .equivalency import EquivalencyExample +from .hash_equivalency import HashEquivalencyExample from .extern import ExternalChoices from .flag_howto import Group from .text_choices import TextChoicesExample @@ -37,6 +38,7 @@ "Constellation", "GNSSReceiverBasic", "EquivalencyExample", + "HashEquivalencyExample", "ExternalChoices", "Group", "TextChoicesExample", diff --git a/tests/examples/models/hash_equivalency.py b/tests/examples/models/hash_equivalency.py new file mode 100644 index 0000000..b04a64d --- /dev/null +++ b/tests/examples/models/hash_equivalency.py @@ -0,0 +1,55 @@ +from enum import Enum +from django.db.models import Model +from django_enum import EnumField + + +class HashEquivalencyExample(Model): + """ + This example model defines three enum fields. The first uses an enum that + is not hash equivalent to its values. The second two are. + """ + + class NotHashEq(Enum): + """ + Enums that inherit only from :class:`~enum.Enum` are not hash equivalent + to their values by default. + """ + + VALUE1 = "V1" + VALUE2 = "V2" + VALUE3 = "V3" + + class HashEq(Enum): + """ + We can force our Enum to be hash equivalent by overriding the necessary + dunder methods.. + """ + + VALUE1 = "V1" + VALUE2 = "V2" + VALUE3 = "V3" + + def __hash__(self): + return hash(self.value) + + def __eq__(self, value) -> bool: + if isinstance(value, self.__class__): + return self.value == value.value + try: + return self.value == self.__class__(value).value + except (ValueError, TypeError): + return False + + class HashEqStr(str, Enum): # or StrEnum on py 3.11+ + """ + Or we can inherit from the primitive value type. + """ + + VALUE1 = "V1" + VALUE2 = "V2" + VALUE3 = "V3" + + + not_hash_eq = EnumField(NotHashEq) + hash_eq = EnumField(HashEq) + hash_eq_str = EnumField(HashEqStr) diff --git a/tests/test_examples.py b/tests/test_examples.py index ffa1c36..f45ae08 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -304,6 +304,9 @@ def test_gnss_tutorial_vanilla(self): def test_equivalency_howto(self): from tests.examples import equivalency_howto + def test_hash_equivalency_howto(self): + from tests.examples import hash_equivalency_howto + def test_extern_howto(self): from tests.examples import extern_howto diff --git a/uv.lock b/uv.lock index 404ec96..09d3f61 100644 --- a/uv.lock +++ b/uv.lock @@ -548,7 +548,7 @@ wheels = [ [[package]] name = "django-enum" -version = "2.2.2" +version = "2.2.3" source = { editable = "." } dependencies = [ { name = "django" },