Skip to content

Commit

Permalink
📝 Closes #65 -- document migration path to native choices
Browse files Browse the repository at this point in the history
* Extensively documented (and tested some) equivalent usage for the public API
* Removed bulk of quickstart information from README to coerce users to upgrade
* Added notices to _please_ migrate to vanilla enums
  • Loading branch information
sergei-maertens committed Jul 24, 2023
1 parent 8c8ef8c commit d2f2107
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 76 deletions.
75 changes: 8 additions & 67 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions djchoices/tests/test_native_choices.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 10 additions & 7 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions docs/migrating.rst
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit d2f2107

Please sign in to comment.