I wanted to add a helper method to an Enum class. However, I didn’t want to make it a
classmethod
as property
method made more sense in this particular case. Problem is, you
aren’t supposed to initialize an enum class, and property
methods can only be accessed
from the instances of a class; not from the class itself.
While sifting through Django 3.2’s codebase, I found this neat trick to make a classmethod
that acts like a property
method and can be accessed directly from the class without
initializing it.
# src.py
# This requires Python 3.4+.
from enum import Enum, EnumMeta
class PlanetsMeta(EnumMeta):
@property
def choices(cls):
return [(v.name, v.value) for v in cls]
class Planets(Enum, metaclass=PlanetsMeta):
EARTH = "earth"
MARS = "mars"
# This can be accessed as follows.
print(Planets.choices)
If you run the script, you’ll see the following output:
$ python3.8 src.py
[('EARTH', 'earth'), ('MARS', 'mars')]
While the previous example is quite impressive, I still don’t like the solution as it requires creating a metaclass and doing a bunch of magic to achieve something so simple. Luckily, Python3.9+ makes it possible without any additional magic. Notice the example below:
# src.py
# Requires Python 3.9+
class ModernPlanets(Enum):
EARTH = "earth"
MARS = "mars"
@classmethod
@property
def choices(cls):
return [(v.name, v.value) for v in cls]
# This can be accessed as follows.
print(ModernPlanets.choices)
The only thing that matters here is the order of the property
and classmethod
decorator.
Python applies them from bottom to top. Changing the order will make it behave unexpectedly.
Complete example with tests
# src.py
# Requires Python 3.4+
import sys
import unittest
from enum import Enum, EnumMeta
class PlanetsMeta(EnumMeta):
@property
def choices(cls):
return [(v.name, v.value) for v in cls]
class Planets(Enum, metaclass=PlanetsMeta):
EARTH = "earth"
MARS = "mars"
# Requires Python 3.9+
class ModernPlanets(Enum):
EARTH = "earth"
MARS = "mars"
@classmethod
@property
def choices(cls):
return [(v.name, v.value) for v in cls]
class TestPlanets(unittest.TestCase):
python_version = (sys.version_info.major, sys.version_info.minor)
def setUp(self):
self.expected_result = [("EARTH", "earth"), ("MARS", "mars")]
def test_planets(self):
self.assertEqual(Planets.choices, self.expected_result)
@unittest.skipIf(
python_version < (3, 9),
"Not supported under Python 3.9",
)
def test_modern_planets(self):
"""This test method will fail if we try to run it on a
version earlier than Python 3.9. So we skip it accordingly."""
self.assertEqual(ModernPlanets.choices, self.expected_result)
if __name__ == "__main__":
unittest.main()
Running this will print out the following:
$ python src.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
Recent posts
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing
- Behind the blog