There are a lot of features that don’t suit the “django batteries included”, or are just too niche to get merged into Django. Actually a lot of 3rd party packages were never merged to Django core even when they get a lot of traction of the community (like django-rest-framework).
From now on, this is mine, and only mine opinion on this matter:
I personally hate the python’s Enum
class, it’s clunky, error prone, hard to use, easy to shoot yourself on the foot. Generate errors for invalid value (this makes impossible to safely remove a legacy enum).
I don’t like any of the current solutions for this problem. On a project that I work, I use a different solution, using the typing.Literal
to deliberate the DB values with conjunction with the typing.Annotated
to provide the label values. With this approach, I get a great auto-complete and “type-safety” (with linters such as mypy
with django-stubs-ext
).
The approach I take is like:
from typing import Annotated, Literal
# note: I don't use gettext, but it probably might be easy to addapt or it works out of a box for it)
BiotaEnum = (
Annotated[Literal["flora"], "Flora"]
| Annotated[Literal["fauna"], "Fauna"]
| Annotated[Literal["fungi"], "Fungi"]
| Annotated[Literal[""], "N/A"]
)
class Species(models.Model):
biota: models.CharField[BiotaEnum, BiotaEnum] = literal_as_char_field(
BiotaEnum,
blank=True,
default="",
)
# somewhere else
# ............................
from typing import get_args, Protocol, Unpack
def literal_as_choices(literal) -> list[tuple[str | None, str]]:
"""Returns an Annotated[Literal["SOMETHING"], "Something label"] as a django choices like object.
If the type is not annotated with a proper label, an error will be raised"""
choices = []
for annotation in get_args(literal):
if isinstance(annotation, str):
# A single annotated type yields first the Literal, then the string inside the literal
# in that we don't want this value, it was already added
continue
metadata = getattr(annotation, "__metadata__", ())
# Supports Union of Annotated types, using `|`
if not metadata:
# Supports a single Annotated type
metadata = getattr(literal, "__metadata__", ())
try:
label = metadata[0] # type: ignore
except IndexError as e:
raise TypeError(f"Missing Annotated label for {annotation=}") from e
assert isinstance(label, str), f"Expected a `str` instance for label of {annotation=} don't use gettext"
db_value = annotation
while not isinstance(db_value, str | None):
if db_value is None.__class__:
# accepts `Annotated[None]` as well as `Annotated[Literal[None]]` the latter is discouraged by ruff
db_value = None
break
db_value = get_args(db_value)[0]
choices.append((db_value, label))
return choices
class DjangoValidatorProtocol(Protocol):
def __call__(self, value: Any) -> None: ...
class CharFieldKwargs(TypedDict):
verbose_name: NotRequired[str]
help_text: NotRequired[str]
name: NotRequired[str]
primary_key: NotRequired[bool]
max_length: NotRequired[int]
unique: NotRequired[bool]
blank: NotRequired[bool]
null: NotRequired[bool]
db_index: NotRequired[bool]
default: NotRequired[str | None]
editable: NotRequired[bool]
serialize: NotRequired[bool]
choices: NotRequired[Sequence[tuple[Any, str]]]
db_column: NotRequired[str]
db_tablespace: NotRequired[str]
auto_created: NotRequired[bool]
validators: NotRequired[Sequence[DjangoValidatorProtocol]]
error_messages: NotRequired[dict[str, str]]
db_comment: NotRequired[str]
def literal_as_char_field(literal, **kwargs: Unpack[CharFieldKwargs]) -> models.CharField:
"""Returns a models.CharField from the given Literal type. The literal type must be annotated with the proper
label for the end-user otherwise an error will be raised."""
choices = literal_as_choices(literal)
kwargs = {
"choices": choices,
"max_length": max(len(db_value or "") for db_value, _ in choices),
**kwargs,
}
return models.CharField(**kwargs)
Feel free to use this code wherever you like. Maybe one day I will publish this into a django package.
I’ve been using this from about an year and the DX have been really great.
There are some limitations on the python language in the moment that would improve this examples, right now you can’t turn the literal_as_choices
and literal_as_char_field
generic, and you need to be really verbose at the model definition, repeating the BiomaEnum a few times, but it is what it is. I just feel happy for not using the python’s Enum
module.
Hope that helps.