A nice HTML rendering of this proposal is available at:
http://lukasz.langa.pl/2/upgrading-choices-machinery-django/



==========================================
Upgrading the choices machinery for Django
==========================================

Specifying choices for form fields and models currently does not do much justice
to the `DRY
<https://docs.djangoproject.com/en/1.4/misc/design-philosophies/#don-t-repeat-yourself-dry>`_
philosophy Django is famous for. Everybody seems to either have their own way of
working around it or live with the suboptimal tuple-based pairs. This Django
enhancement proposal presents a comprehensive solution based on an existing
implementation, explaining reasons behind API decisions and their implications
on the framework in general.

Current status
--------------

The current way of specifying choices for a field is as follows::

  GENDER_CHOICES = (
      (0, 'male'),
      (1, 'female'),
      (2, 'not specified'),
  )

  class User(models.Model):
      gender = models.IntegerField(choices=GENDER_CHOICES)
  
When I then want to implement behaviour which depends on the ``User.gender``
value, I have a couple of possibilities. I've seen all in production code which
makes it all the more scary.

Way 1
~~~~~

Use ``get_FIELD_display``::

  GENDER_CHOICES = (
      (0, 'male'),
      (1, 'female'),
      (2, 'not specified'),
  )

  class User(models.Model):
      gender = models.IntegerField(choices=GENDER_CHOICES)

      def greet(self):
          gender = self.get_gender_display()
          if gender == 'male':
              return 'Hi, boy.'
          elif gender == 'female':
              return 'Hello, girl.'
          else:
              return 'Hey there, user!'

This will fail once you start translating the choice descriptions or if you
rename them and is generally brittle. So we have to improve on it by using the
numeric identifiers.

Way 2
~~~~~

Use numeric IDs::

  GENDER_CHOICES = (
      (0, _('male')),
      (1, _('female')),
      (2, _('not specified')),
  )

  class User(models.Model):
      gender = models.IntegerField(choices=GENDER_CHOICES,
              default=2)

      def greet(self):
          if self.gender == 0:
              return 'Hi, boy.'
          elif self.gender == 1:
              return 'Hello, girl.'
          else:
              return 'Hey there, user!'

This is just as bad because once the identifiers change, it's not trivial to
grep for existing usage, let alone 3rd party usage. This looks less wrong when
using ``CharFields`` instead of ``IntegerFields`` but the problem stays the
same.  So we have to improve on it by explicitly naming the options.

Way 3
~~~~~

Explicit choice values::

  GENDER_MALE = 0
  GENDER_FEMALE = 1
  GENDER_NOT_SPECIFIED = 2

  GENDER_CHOICES = (
      (GENDER_MALE, _('male')),
      (GENDER_FEMALE, _('female')),
      (GENDER_NOT_SPECIFIED, _('not specified')),
  )

  class User(models.Model):
      gender = models.IntegerField(choices=GENDER_CHOICES,
              default=GENDER_NOT_SPECIFIED)

      def greet(self):
          if self.gender == GENDER_MALE:
              return 'Hi, boy.'
          elif self.gender == GENDER_NOT_SPECIFIED:
              return 'Hello, girl.'
          else: return 'Hey there, user!'

This is a saner way but starts getting overly verbose and redundant. You can
improve encapsulation by moving the choices into the ``User`` class but that on
the other hand beats reusability.

The real problem however is that there is no `One Obvious Way To Do It
<http://www.python.org/dev/peps/pep-0020/>`_ and newcomers are likely to choose
poorly.

tl;dr or The Gist of It
-----------------------

My proposal suggests embracing a solution that is already implemented and tested
with 100% statement coverage. It's easily installable::

  pip install dj.choices

and lets you write the above example as::

  from dj.choices import Choices, Choice

  class Gender(Choices):
      male = Choice("male")
      female = Choice("female")
      not_specified = Choice("not specified")

  class User(models.Model):
      gender = models.IntegerField(choices=Gender(),
              default=Gender.not_specified.id)

      def greet(self):
          gender = Gender.from_id(self.gender)
          if gender == Gender.male:
              return 'Hi, boy.'
          elif gender == Gender.female:
              return 'Hello, girl.'
          else:
              return 'Hey there, user!'

or using the provided ``ChoiceField`` (fully compatible with
``IntegerFields``)::

  from dj.choices import Choices, Choice
  from dj.choices.fields import ChoiceField

  class Gender(Choices):
      male = Choice("male")
      female = Choice("female")
      not_specified = Choice("not specified")

  class User(models.Model):
      gender = ChoiceField(choices=Gender,
              default=Gender.not_specified)

      def greet(self):
          if self.gender == Gender.male:
              return 'Hi, boy.'
          elif self.gender == Gender.female:
              return 'Hello, girl.'
          else:
              return 'Hey there, user!'

BTW, the reason choices need names as arguments is so they can be picked up by
``makemessages`` and translated.

But it's much more than that so read on.

The Choices class
-----------------

By default the ``Gender`` class has its choices enumerated similarly to how
Django models order their fields. This can be seen by instantiating it::

  >>> Gender()
  [(1, u'male'), (2, u'female'), (3, u'not specified')]

By default an item contains the numeric ID and the localized description.  The
format can be customized, for instance for ``CharField`` usage::

  >>> Gender(item=lambda c: (c.name, c.desc))
  [(u'male', u'male'), (u'female', u'female'), (u'not_specified', u'not 
specified')] 

But that will probably make more sense with a foreign translation::

  >>> from django.utils.translation import activate
  >>> activate('pl')
  >>> Gender(item=lambda c: (c.name, c.desc))
  [(u'male', u'm\u0119\u017cczyzna'), (u'female', u'kobieta'), 
(u'not_specified', u'nie podano')]

It sometimes makes sense to provide only a subset of the defined choices::

  >>> Gender(filter=('female', 'not_specified'), item=lambda c: (c.name, 
c.desc))
  [(u'female', u'kobieta'), (u'not_specified', u'nie podano')]

A single Choice
---------------

Every ``Choice`` is an object::

  >>> Gender.female
  <Choice: female (id: 2, name: female)>

It contains a bunch of attributes, e.g. its name::

  >>> Gender.female.name
  u'female'

which probably makes more sense if accessed from a database model (in the
following example, using a ``ChoiceField``)::

  >>> user.gender.name
  u'female'

Other attributes include the numeric ID, the localized and the raw description
(the latter is the string as present before the translation)::

  >>> Gender.female.id
  2
  >>> Gender.female.desc
  u'kobieta'
  >>> Gender.female.raw
  'female'

Within a Python process, choices can be compared using identity comparison::

  >>> u.gender
  <Choice: male (id: 1, name: male)>
  >>> u.gender is Gender.male
  True

Across processes the serializable value (either ``id`` or ``name``) should be
used. Then a choice can be retrieved using a class-level getter::

  >>> Gender.from_id(3)
  <Choice: not specified (id: 3, name: not_specified)>
  >>> Gender.from_name('male')
  <Choice: male (id: 1, name: male)>

Grouping choices
----------------

One of the problems with specifying choice lists is their weak extensibility.
For instance, an application defines a group of possible choices like this::

  >>> class License(Choices):
  ...   gpl = Choice("GPL")
  ...   bsd = Choice("BSD")
  ...   proprietary = Choice("Proprietary")
  ...
  >>> License()
  [(1, u'GPL'), (2, u'BSD'), (3, u'Proprietary')]

All is well until the application goes live and after a while the developer
wants to include LGPL. The natural choice would be to add it after gpl but when
we do that, the indexing would break. On the other hand, adding the new entry at
the end of the definition looks ugly and makes the resulting combo boxes in the
UI sorted in a counter-intuitive way. Grouping lets us solve this problem by
explicitly defining the structure within a class of choices::

  >>> class License(Choices):
  ...   COPYLEFT = Choices.Group(0)
  ...   gpl = Choice("GPL")
  ...
  ...   PUBLIC_DOMAIN = Choices.Group(100)
  ...   bsd = Choice("BSD")
  ...
  ...   OSS = Choices.Group(200)
  ...   apache2 = Choice("Apache 2")
  ...
  ...   COMMERCIAL = Choices.Group(300)
  ...   proprietary = Choice("Proprietary")
  ...
  >>> License()
  [(1, u'GPL'), (101, u'BSD'), (201, u'Apache 2'), (301, u'Proprietary')]

This enables the developer to include more licenses of each group later on::

  >>> class License(Choices):
  ...   COPYLEFT = Choices.Group(0)
  ...   gpl_any = Choice("GPL, any")
  ...   gpl2 = Choice("GPL 2")
  ...   gpl3 = Choice("GPL 3")
  ...   lgpl = Choice("LGPL")
  ...   agpl = Choice("Affero GPL")
  ...
  ...   PUBLIC_DOMAIN = Choices.Group(100)
  ...   bsd = Choice("BSD")
  ...   public_domain = Choice("Public domain")
  ...
  ...   OSS = Choices.Group(200)
  ...   apache2 = Choice("Apache 2")
  ...   mozilla = Choice("MPL")
  ...
  ...   COMMERCIAL = Choices.Group(300)
  ...   proprietary = Choice("Proprietary")
  ...
  >>> License()
  [(1, u'GPL, any'), (2, u'GPL 2'), (3, u'GPL 3'), (4, u'LGPL'),
  (5, u'Affero GPL'), (101, u'BSD'), (102, u'Public domain'),
  (201, u'Apache 2'), (202, u'MPL'), (301, u'Proprietary')]

The behaviour in the example above was as follows:

- the developer renamed the GPL choice but its meaning and ID remained stable
 
- BSD, Apache and proprietary choices have their IDs unchanged
  
- the resulting class is self-descriptive, readable and extensible

The explicitly specified groups can be used as other means of filtering, etc.::

  >>> License.COPYLEFT
  <ChoiceGroup: COPYLEFT (id: 0)>
  >>> License.gpl2 in License.COPYLEFT.choices
  True
  >>> [(c.id, c.desc) for c in License.COPYLEFT.choices]
  [(1, u'GPL, any'), (2, u'GPL 2'), (3, u'GPL 3'), (4, u'LGPL'),
  (5, u'Affero GPL')]

Pushing polymorphism to the limit - extra attributes
----------------------------------------------------

Let's see our original example once again::

  from dj.choices import Choices, Choice
  from dj.choices.fields import ChoiceField

  class Gender(Choices):
      male = Choice("male")
      female = Choice("female")
      not_specified = Choice("not specified")

  class User(models.Model):
      gender = ChoiceField(choices=Gender,
              default=Gender.not_specified)

      def greet(self):
          if self.gender == Gender.male:
              return 'Hi, boy.'
          elif self.gender == Gender.female:
              return 'Hello, girl.'
          else:
              return 'Hey there, user!'


If you treat DRY really seriously, you'll notice that actually separation
between the choices and the greetings supported by each of them may be
a violation of the "Every distinct concept and piece of data should live in one,
and only one, place" rule. You might want to move this information up::

  from dj.choices import Choices, Choice
  from dj.choices.fields import ChoiceField

  class Gender(Choices):
      male = Choice("male").extra(
              hello='Hi, boy.')
      female = Choice("female").extra(
              hello='Hello, girl.')
      not_specified = Choice("not specified").extra(
              hello='Hey there, user!')

  class User(models.Model):
      gender = ChoiceField(choices=Gender,
              default=Gender.not_specified)

      def greet(self):
          return self.gender.hello

As you see, the ``User.greet()`` method is now virtually gone. Moreover, it
already supports whatever new choice you will want to introduce in the future.
This way the choices class starts to be the canonical place for the concept it
describes. Getting rid of chains of ``if`` - ``elif`` statements is now just
a nice side effect.

.. note::

  I'm aware this is advanced functionality. This is not a solution for every
  case but when it is needed, it's priceless.


Advantages of merging this solution 
-----------------------------------

1. Having a single source of information, whether it is a list of languages,
   genders or states in the USA, is DRY incarnate.  
   
2. If necessary, this source can later be filter and reformatted upon
   instatiation.

3. Using ``ChoiceFields`` or explicit ``from_id()`` class methods on choices
   enables cleaner user code with less redundancy and dependency on hard-coded
   values.

4. Upgrading the framework's choices to use class-based choices will increase
   its DRYness and enable better future extensibility.

5. Bikeshedding on the subject will hopefully stop.

Disadvantages
-------------

1. A new concept in the framework increases the vocabulary necessary to
   understand what's going on.

2. Because of backwards compatibility in order to provide a *One Obvious Way To
   Do It* we actually add another way to do it. We can however endorse it as the
   recommended way.

Performance considerations
--------------------------

Creation of the proper ``Choices`` subclass uses a metaclass to figure out
choice order, group membership, etc. This is done once when the module
containing the class is loaded.

After instantiation the resulting object is little more than a raw list of
pairs.

Using ``ChoiceFields`` instead of raw ``IntegerFields`` introduces automatic
choice unboxing and moves the field ID checks up the stack introducing
negligible performance costs.  I don't personally believe in microbenchmarks so
didn't try any but can do so if requested.

FAQ
---

Is it used anywhere already?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Yes, it is. It grew out of a couple of projects I did over the years, for
instance `allplay.pl <http://allplay.pl/>`_ or `spiralear.com
<http://spiralear.com/en/>`_. Various versions of the library are also used by
my former and current employers. It has 100% statement coverage in unit tests.

Has anyone evaluated it yet?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

I have shown this implementation to various people during PyCon US 2012 and it
gathered some enthusiasm. Jannis Leidel, Carl Meyer and Julien Phalip seemed
interested in the idea at the very least.

Why not use an existing Django enum implementation?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The most popular are:

- `django-choices <http://pypi.python.org/pypi/django-choices>`_ by Jason Webb.
  The home page link is dead, the implementation lacks grouping support,
  internationalization support and automatic integer ID incrementation. Also,
  I find the reversed syntax a bit unnatural.  There are some tests.

- `django-enum <pypi.python.org/pypi/django-enum>`_ by Jacob Smullyan. There is
  no documentation nor tests, the API is based on a syntax similar to
  namedtuples with a single string of space-separated choices. Doesn't support
  groups nor internationalization.

Why not use a generic enum implementation?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The most popular are:

- `enum <http://pypi.python.org/pypi/enum>`_ by Ben Finney (also a rejected `PEP
  <http://www.python.org/dev/peps/pep-0354/>`_) - uses the "object
  instantiation" approach which I find less readable and extensible.

- `flufl.enum <https://launchpad.net/flufl.enum>`_ by Barry Warsaw - uses the
  familiar "class definition" approach but doesn't support internationalization
  and grouping. 

Naturally, none of the generic implementations provide shortcuts for Django
forms and models.

Why not use namedtuples?
~~~~~~~~~~~~~~~~~~~~~~~~

This doesn't solve anything because a ``namedtuple`` defines a type that is
later populated with data on a per-instance basis. Defining a type first and
then instantiating it is already clumsy and redundant.

Do you have a field like ``ChoiceField`` but holding characters?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Not yet but this is planned.

I don't like having to write ``Choice()`` all the time.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

You can use a trick::

  class Gender(Choices):
      _ = Choices.Choice

      male = _("male")
      female = _("female")
      not_specified = _("not specified")

Current documentation for the project uses that because outside of Django core
this is necessary for ``makemessages`` to pick up the string for translation.
Once merged this will not be necessary so **this is not a part of the
proposal**.  You're free to use whatever you wish, like importing ``Choice`` as
``C``, etc.



-- 
Best regards,
Łukasz Langa
Senior Systems Architecture Engineer

IT Infrastructure Department
Grupa Allegro Sp. z o.o.

http://lukasz.langa.pl/
+48 791 080 144

-- 
You received this message because you are subscribed to the Google Groups 
"Django developers" group.
To post to this group, send email to django-developers@googlegroups.com.
To unsubscribe from this group, send email to 
django-developers+unsubscr...@googlegroups.com.
For more options, visit this group at 
http://groups.google.com/group/django-developers?hl=en.

Reply via email to