Specifying choices for form fields and models currently does not do much justice to the 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.
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.
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.
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.
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 and newcomers are likely to choose poorly.
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 py makemessages
and translated.
But it's much more than that so read on.
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')]
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)>
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')]
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.
- Having a single source of information, whether it is a list of languages, genders or states in the USA, is DRY incarnate.
- If necessary, this source can later be filter and reformatted upon instatiation.
- Using
ChoiceFields
or explicitfrom_id()
class methods on choices enables cleaner user code with less redundancy and dependency on hard-coded values. - Upgrading the framework's choices to use class-based choices will increase its DRYness and enable better future extensibility.
- Bikeshedding on the subject will hopefully stop.
- A new concept in the framework increases the vocabulary necessary to understand what's going on.
- 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.
Creation of the proper Choices
subclass uses a metaclass to figure out choice order, group membership, etc. This is done once when the module loading 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.
Yes, it is. It grew out of a couple of projects I did over the years, for instance allplay.pl or spiralear.com. Various versions of the library are also used by my former and current employers. It has 100% statement coverage in unit tests.
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.
The most popular are:
- 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 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.
The most popular are:
- enum by Ben Finney (also a rejected PEP) - uses the "object instantiation" approach which I find less readable and extensible.
- 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.
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.
Not yet but this is planned.
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.