Repository: bigjason/django-choices (language: python)

Issue:
Upcoming 'native' choices model support in Django 3.0
It appears the upcoming Django 3.0 release (expected December 2019) will have something very similar built in (although the syntax is slightly different): https://docs.djangoproject.com/en/dev/releases/3.0/#enumerations-for-model-field-choices

I haven't looked into it too deeply but it appears the new built-in functionality can do everything this app does and, well, it comes built-in with Django...

Maybe it's time to retire this app for future Django releases?

At the very least, the docs should start mentioning this when 3.0 lands.

(Of course, for the foreseeable future this is still a very useful app for those that are stuck on older versions!)


Discussion / hints:
Correct, and I'm personally excited we finally have this feature in core Django!

At first look the API is slightly different, but close enough to make migrating to native Django choices straight forward. 

> I haven't looked into it too deeply but it appears the new built-in functionality can do everything this app does and, well, it comes built-in with Django...

One key difference is that django-choices supports passing arbitrary kwargs to `ChoiceItem`, which you can access later and build some business logic around that. But I don't consider this a show stopper, as you can still maintain a separate mapping. I'm not sure how often this feature is used though, input would be welcome.

> Maybe it's time to retire this app for future Django releases?

That's the plan. Not sure if we should still do one final release that detects Django 3.0+ and issues warnings to recommend you to use the core django stuff, or just not release for Django 3.0+ alltogether anymore.

Either way, a documentation update that tells you how to migrate seems the minimal effort required in both cases.

> At the very least, the docs should start mentioning this when 3.0 lands.

For sure!

Fix patch:
diff --git a/README.rst b/README.rst
index 8e3360c..07be429 100644
--- a/README.rst
+++ b/README.rst
@@ -7,80 +7,21 @@ Django-Choices
 Order and sanity for django model choices.
 ------------------------------------------
 
-Django choices provides a declarative way of using the choices_ option on django_
-fields. Read the full `documentation`_ on ReadTheDocs.
+**DISCLAIMER**
+
+New projects should not use this package. Existing users can follow the migration guide
+in the `documentation`_.
 
 **Note:** Django 3.0 added `enumeration types <https://docs.djangoproject.com/en/3.0/releases/3.0/#enumerations-for-model-field-choices>`__.
 This feature mostly replaces the need for Django-Choices.
 See also `Adam Johnson's post on using them <https://adamj.eu/tech/2020/01/27/moving-to-django-3-field-choices-enumeration-types/>`__.
 
-------------
-Installation
-------------
-
-You can install via PyPi_ or direct from the github_ repo.
-
-.. code-block:: bash
-
-    $ pip install django-choices
-
------------
-Basic Usage
------------
-
-To start you create a choices class. Then you point the choices property on your
-fields to the ``choices`` attribute of the new class. Django will be able to use
-the choices and you will be able to access the values by name.  For example you
-can replace this:
-
-.. code-block:: python
-
-    # In models.py
-    class Person(models.Model):
-    	# Choices
-    	PERSON_TYPE = (
-            ("C", "Customer"),
-            ("E", "Employee"),
-            ("G", "Groundhog"),
-        )
-        # Fields
-        name = models.CharField(max_length=32)
-        type = models.CharField(max_length=1, choices=PERSON_TYPE)
+**Introduction**
 
-With this:
-
-.. code-block:: python
-
-    # In models.py
-    from djchoices import DjangoChoices, ChoiceItem
-
-    class Person(models.Model):
-    	# Choices
-        class PersonType(DjangoChoices):
-            customer = ChoiceItem("C")
-            employee = ChoiceItem("E")
-            groundhog = ChoiceItem("G")
-
-        # Fields
-        name = models.CharField(max_length=32)
-        type = models.CharField(max_length=1, choices=PersonType.choices)
-
-You can use this elsewhere like this:
-
-.. code-block:: python
-
-    # Other code
-    Person.create(name="Phil", type=Person.PersonType.groundhog)
-
-You can use them without value, and the label will be used as value:
-
-.. code-block:: python
-
-    class Sample(DjangoChoices):
-        option_a = ChoiceItem()
-        option_b = ChoiceItem()
+Django choices provides a declarative way of using the choices_ option on django_
+fields.
 
-    print(Sample.option_a)  # "option_a"
+See the `documentation`_ on ReadTheDocs on how to use this library.
 
 -------
 License
diff --git a/djchoices/choices.py b/djchoices/choices.py
index b814c4e..5f6f911 100644
--- a/djchoices/choices.py
+++ b/djchoices/choices.py
@@ -1,5 +1,3 @@
-from __future__ import absolute_import, unicode_literals
-
 import re
 from collections import OrderedDict
 
diff --git a/djchoices/management/__init__.py b/djchoices/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/djchoices/management/commands/__init__.py b/djchoices/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/djchoices/management/commands/generate_native_django_choices.py b/djchoices/management/commands/generate_native_django_choices.py
new file mode 100644
index 0000000..ae4f976
--- /dev/null
+++ b/djchoices/management/commands/generate_native_django_choices.py
@@ -0,0 +1,90 @@
+from textwrap import indent
+
+from django.core.management.base import BaseCommand, no_translations
+
+from djchoices import DjangoChoices
+
+
+def get_subclasses(cls):
+    for subclass in cls.__subclasses__():
+        yield from get_subclasses(subclass)
+        yield subclass
+
+
+def iter_django_choices():
+    for cls in get_subclasses(DjangoChoices):
+        yield cls
+
+
+BASE_CLS = {
+    str: "models.TextChoices",
+    int: "models.IntegerChoices",
+}
+
+
+class Command(BaseCommand):
+    help = "Introspect DjangoChoices subclasses and generate equivalent native Django choices code."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--no-wrap-gettext",
+            dest="wrap_gettext",
+            action="store_false",
+            help="Do not wrap labels in gettext/gettext_lazy calls. See also --gettext-alias.",
+        )
+        parser.add_argument(
+            "--gettext-alias",
+            default="_",
+            help="Alias/function name for the gettext(_lazy) wrapper. Defaults to '_'.",
+        )
+
+    @no_translations
+    def handle(self, **options):
+        wrap_gettext = options["wrap_gettext"]
+        gettext_alias = options["gettext_alias"]
+
+        def wrap_label(label):
+            if not wrap_gettext:
+                return label
+            return f"{gettext_alias}({label})"
+
+        for cls in iter_django_choices():
+            full_path = f"{cls.__module__}.{cls.__qualname__}"
+
+            value_types = set(
+                [type(choice_item.value) for choice_item in cls._fields.values()]
+            )
+            if len(value_types) != 1:
+                self.stdout.write(
+                    f"  Choices do not have consistent value types: {value_types}"
+                )
+                continue
+
+            value_type = list(value_types)[0]
+            base = BASE_CLS.get(value_type)
+            if not base:
+                self.stdout.write(
+                    indent(
+                        f"No builtin enum for type '{value_type}'. You may need to define your own."
+                        "See: https://docs.djangoproject.com/en/3.2/ref/models/fields/#enumeration-types",
+                        prefix="  ",
+                    )
+                )
+                continue
+
+            lines = [
+                f"# {full_path}",
+                f"class {cls.__name__}({base}):",
+            ]
+            for field_name, choice_item in cls._fields.items():
+                label = wrap_label(f'"{choice_item.label}"')
+                lines.append(
+                    indent(
+                        f"{field_name} = {repr(choice_item.value)}, {label}",
+                        prefix="    ",
+                    )
+                )
+            snippet = indent("\n".join(lines), prefix="  ")
+            self.stdout.write("\n")
+            self.stdout.write(snippet)
+            self.stdout.write("\n")
diff --git a/docs/conf.py b/docs/conf.py
index 4fc88d0..7ba3fae 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -55,9 +55,9 @@
 # built documents.
 #
 # The short X.Y version.
-version = "1.6"
+version = "2.0"
 # The full version, including alpha/beta/rc tags.
-release = "1.6.0"
+release = "2.0.0"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/docs/index.rst b/docs/index.rst
index 909f301..d043e99 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -17,28 +17,31 @@ Contents:
 .. toctree::
    :maxdepth: 2
 
+   migrating
    choices
    contributing
 
 Overview
 --------
 
-Django choices provides a declarative way of using the
-choices_ option on django_ fields.
+.. warning::
+
+    We **strongly** recommend migrating to the native functionality and not use
+    django-choices for new projects. See the :ref:`migration`. This library will not be
+    actively developed any longer.
+
+Django choices provides a declarative way of using the choices_ option on django_ fields.
 
 **Note:** Django 3.0 added `enumeration types <https://docs.djangoproject.com/en/3.0/releases/3.0/#enumerations-for-model-field-choices>`__.
 This feature mostly replaces the need for Django-Choices.
 See also `Adam Johnson's post on using them <https://adamj.eu/tech/2020/01/27/moving-to-django-3-field-choices-enumeration-types/>`__.
 
+
 Requirements
 ------------
 
 Django choices is fairly simple, so most Python and Django
-versions should work. It is tested against Python 2.7, 3.4, 3.5, 3.6, and 3.7.
-Django 1.11 until and including 3.0 are supported (and tested in Travis).
-
-If you need to support older Python or Django versions, you should stick with
-version ``1.4.4``. Backwards compatibility is dropped from 1.5 onwards.
+versions should work. It is tested against Python 3.8+ and Django 3.2+.
 
 
 Quick-start
diff --git a/docs/migrating.rst b/docs/migrating.rst
new file mode 100644
index 0000000..25f1c16
--- /dev/null
+++ b/docs/migrating.rst
@@ -0,0 +1,140 @@
+.. _migration:
+
+Migrating to native Django Choices
+==================================
+
+Since version 3.0, Django offers native choices enums (mostly equivalent) to the
+functionality that this library offers. See the `django docs`_ for more details.
+
+We provide some automated tooling to facilitate migrating and instructions for possible
+hurdles.
+
+Generating equivalent native code
+---------------------------------
+
+For trivial usage where you just define the choices as a constant, there is a management
+command since version 2.0 to generate the equivalent code. It supports ``str`` and ``int``
+for values types.
+
+#. Ensure you have ``djchoices`` added to your ``INSTALLED_APPS`` setting.
+#. Run the command ``python manage.py generate_native_django_choices``
+
+The command essentially discovers all subclasses of ``DjangoChoices`` and introspects
+them to generate the equivalent native choices code. This depends on the classes being
+imported during Django's initialization phase. This should be the case for almost all
+usages, as the choices need to be imported to be picked up by models.
+
+Possible options for the command:
+
+* ``--no-wrap-gettext``: do not wrap the choice labels in a function call to mark them
+  translatable.
+
+* ``--gettext-alias``: when wrapping the labels, you can specify the name of the
+  function call/alias to wrap with, e.g. ``gettext_lazy``. It defaults to the common
+  pattern of ``_``. You need to ensure the necessary imports are present in the module.
+
+Public API
+----------
+
+* The ``choices`` class attribute behaves the same in native choices.
+
+Migrating non-trivial usage
+---------------------------
+
+Django-choices offered some class attributes that need to be updated too when migrating
+to native choices.
+
+
+``DjangoChoices.labels``
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+This is roughly equivalent to:
+
+.. code-block:: python
+
+    dict(zip(Native.names, Native.labels))
+
+Notable differences:
+
+* for a value like ``'option1'`` without explicit label, Django Choices produces
+  ``'option1'`` as label, while Django produces ``'option 1'``. A value like
+  ``'option_1'`` results in the same label.
+* It may not play nice with the empty option in native choices.
+
+
+``ChoiceItem.order``
+^^^^^^^^^^^^^^^^^^^^
+
+The management command emits the generated native choices in the configured order.
+
+If you need access to the order, you can leverage ``enumerate(Native.values)`` to loop
+over tuples of ``(order, value)``.
+
+``DjangoChoices.values``
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+This is equivalent to:
+
+.. code-block:: python
+
+    dict(zip(Native.values, Native.labels))
+
+
+``DjangoChoices.validator``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Django has been performing this out of the box on model fields since at least
+Django 1.3 - you don't need it.
+
+``DjangoChoices.attributes``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This is roughly equivalent to:
+
+.. code-block:: python
+
+    native = dict(zip(Native.values, Native.names))
+
+Remarks:
+
+* It may not play nice with the empty option in native choices.
+
+``DjangoChoices.get_choice``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+There is no direct equivalent, however you can access the enum instance and look up
+properties:
+
+.. code-block:: python
+
+    an_enum_value = Native[Native.some_value]
+    print(an_enum_value.value)
+    print(an_enum_value.label)
+
+``DjangoChoices.get_order_expression``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+There is no equivalent, but you should easily be able to add this as your own
+class method/mixin:
+
+.. code-block:: python
+
+    from django.db.models import Case, IntegerField, Value, When
+
+    @classmethod
+    def get_order_expression(cls, field_name):
+        whens = []
+        for order, value in enumerate(cls.values()):
+            whens.append(
+                When(**{field_name: value, "then": Value(order)})
+            )
+        return Case(*whens, output_field=IntegerField())
+
+Custom attributes
+^^^^^^^^^^^^^^^^^
+
+It's recommended to keep a separate dictionary with a mapping of choice values to the
+additional attributes. You could consider dataclasses to model this too.
+
+
+.. _django docs: https://docs.djangoproject.com/en/3.2/ref/models/fields/#enumeration-types


Test patch:
diff --git a/djchoices/tests/test_native_choices.py b/djchoices/tests/test_native_choices.py
new file mode 100644
index 0000000..bd966ac
--- /dev/null
+++ b/djchoices/tests/test_native_choices.py
@@ -0,0 +1,42 @@
+import unittest
+
+from django.db.models import TextChoices
+
+from djchoices import ChoiceItem, DjangoChoices
+
+
+class ToMigrate(DjangoChoices):
+    option_1 = ChoiceItem("option_1")
+    option_2 = ChoiceItem("option_2", "Option 2")
+
+
+class Native(TextChoices):
+    option_1 = "option_1", "option 1"
+    option_2 = "option_2", "Option 2"
+
+
+class NativeChoicesEquivalenceTests(unittest.TestCase):
+    def test_labels(self):
+        labels = ToMigrate.labels
+        native = dict(zip(Native.names, Native.labels))
+
+        self.assertEqual(native, labels)
+
+    def test_values(self):
+        values = ToMigrate.values
+        native = dict(zip(Native.values, Native.labels))
+
+        self.assertEqual(native, values)
+
+    def test_attributes(self):
+        attributes = ToMigrate.attributes
+        native = dict(zip(Native.values, Native.names))
+
+        self.assertEqual(native, attributes)
+
+    def test_get_choice(self):
+        a_choice = ToMigrate.get_choice(ToMigrate.option_2)
+        native = Native[Native.option_2]
+
+        self.assertEqual(native.value, a_choice.value)
+        self.assertEqual(native.label, a_choice.label)