├── setup.cfg ├── .gitignore ├── setup.py ├── LICENSE ├── README.rst ├── lenum.py └── tests.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv/ 3 | dist/ 4 | .coverage 5 | htmlcov/ 6 | labeled_enum.egg-info/ 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import setup 3 | 4 | with open('README.rst') as fin: 5 | description = fin.read() 6 | 7 | setup( 8 | name='labeled-enum', 9 | version='1.3.1', 10 | description='Django friendly, iterable Enum type with labels.', 11 | long_description=description, 12 | author='Curtis Maloney', 13 | author_email='curtis@tinbrain.net', 14 | py_modules=['lenum',], 15 | classifiers=[ 16 | 'Development Status :: 5 - Production/Stable', 17 | 'Intended Audience :: Developers', 18 | ], 19 | ) 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Curtis Maloney 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | labeled-enums 2 | ============= 3 | 4 | A Django-friendly iterable Enum type with labels. 5 | 6 | Example 7 | ------- 8 | 9 | .. code-block:: python 10 | 11 | >>> from django.utils.translation import gettext_lazy as _ 12 | >>> from lenum import LabeledEnum 13 | >>> class STATE_CHOICES(LabeledEnum): 14 | ... NEW = 0 15 | ... IN_PROGRESS = 1 16 | ... REVIEW = 2, _('In Review') 17 | ... 18 | >>> 19 | >>> STATE_CHOICES.NEW 20 | 0 21 | >>> STATE_CHOICES.IN_PROGRESS 22 | 1 23 | >>> STATE_CHOICES[2] 24 | 'In Review' 25 | >>> list(STATE_CHOICES) 26 | [(0, 'New'), (1, 'In Progress'), (2, 'In Review')] 27 | 28 | >>> STATE_CHOICES.for_label('In Progress') 29 | 1 30 | ``` 31 | 32 | >>> STATE_CHOICES.names 33 | ('NEW', 'IN_PROGRESS', 'REVIEW') 34 | 35 | Usage in Django: 36 | 37 | .. code-block:: python 38 | 39 | class STATUS(LabeledEnum): 40 | CLOSED = 0 41 | NEW = 1 42 | PENDING = 2, 'Process Pending' 43 | FAILED = -1, 'Processing Failed' 44 | 45 | class MyModel(models.Model): 46 | # django migrations can have trouble resolving imports if we define the 47 | # class within the class, so we bind this here for convenience. 48 | STATUS = STATUS 49 | 50 | status = models.IntegerField(choices=STATUS, default=STATUS.NEW) 51 | 52 | Want translations? 53 | 54 | .. code-block:: python 55 | from django.utils.translation import gettext_lazy as _ 56 | 57 | class STATUS(LabeledEnum, label_wrapper=_): 58 | CLOSED = 0 59 | NEW = 1 60 | PENDING = 2, 'Process Pending' 61 | FAILED = -1, 'Processing Failed' 62 | 63 | All label values (including auto-generated ones) will have `label_wrapper` 64 | applied first. 65 | 66 | Installation 67 | ------------ 68 | 69 | .. code-block:: 70 | 71 | pip install labeled-enum 72 | -------------------------------------------------------------------------------- /lenum.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | 4 | class EnumProperty: 5 | '''Descriptor class for yielding values, but not allowing setting.''' 6 | def __init__(self, value): 7 | self.value = value 8 | 9 | def __get__(self, instance, cls=None): 10 | return self.value 11 | 12 | 13 | class LabeledEnumMeta(type): 14 | @classmethod 15 | def __prepare__(mcs, name, bases, **kwargs): 16 | '''Use an ordered dict for declared values.''' 17 | return OrderedDict() 18 | 19 | def __new__(mcs, classname, bases, attrs, label_wrapper=None, **kwargs): 20 | _choices = OrderedDict() 21 | 22 | names = [] 23 | for name, value in list(attrs.items()): 24 | if not name.isupper(): 25 | continue 26 | names.append(name) 27 | if isinstance(value, tuple): 28 | value, label = value 29 | else: 30 | label = name.title().replace('_', ' ') 31 | if label_wrapper: 32 | label = label_wrapper(label) 33 | _choices[value] = label 34 | attrs[name] = EnumProperty(value) 35 | attrs['names'] = tuple(names) 36 | attrs['__members__'] = _choices 37 | attrs['_reverse'] = {v: k for k, v in _choices.items()} 38 | 39 | return type.__new__(mcs, classname, bases, dict(attrs)) 40 | 41 | def __call__(cls): 42 | return cls 43 | 44 | def __getitem__(cls, key): 45 | return cls.__members__[key] 46 | 47 | def __setattr__(cls, key, value): 48 | raise AttributeError('Cannot change values on LabeledEnum type.') 49 | 50 | def __iter__(cls): 51 | return iter(cls.__members__.items()) 52 | 53 | def for_label(cls, label): 54 | return cls._reverse[label] 55 | 56 | 57 | class LabeledEnum(metaclass=LabeledEnumMeta): 58 | '''Base class for choices constants.''' 59 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from lenum import LabeledEnum 4 | 5 | 6 | class STATUS(LabeledEnum): 7 | CLOSED = 0 8 | NEW = 1 9 | PENDING = 2, 'Process Pending' 10 | FAILED = -1, 'Processing Failed' 11 | 12 | 13 | class TestLenum(TestCase): 14 | 15 | def test_name(self): 16 | ''' 17 | Make sure we don't goof the name when mangling the class. 18 | Thanks, Hynek! 19 | ''' 20 | assert STATUS.__name__ == 'STATUS' 21 | 22 | def test_lookup(self): 23 | self.assertEqual(STATUS.CLOSED, 0) 24 | self.assertEqual(STATUS.FAILED, -1) 25 | 26 | def test_reverse(self): 27 | self.assertEqual(STATUS.for_label('Closed'), 0) 28 | self.assertEqual(STATUS.for_label('Processing Failed'), -1) 29 | 30 | def test_label(self): 31 | self.assertEqual(STATUS[0], 'Closed') 32 | self.assertEqual(STATUS[-1], 'Processing Failed'), 33 | 34 | def test_iterate(self): 35 | self.assertEqual(list(STATUS), [ 36 | (0, 'Closed'), 37 | (1, 'New'), 38 | (2, 'Process Pending'), 39 | (-1, 'Processing Failed'), 40 | ]) 41 | 42 | def test_iter_callable(self): 43 | ''' 44 | Handles how Django's Form.field choices treats callabls 45 | ''' 46 | self.assertEqual(list(STATUS()), [ 47 | (0, 'Closed'), 48 | (1, 'New'), 49 | (2, 'Process Pending'), 50 | (-1, 'Processing Failed'), 51 | ]) 52 | 53 | def test_names(self): 54 | self.assertEqual(STATUS.names, ('CLOSED', 'NEW', 'PENDING', 'FAILED',)) 55 | 56 | def test_setattr(self): 57 | with self.assertRaises(AttributeError): 58 | STATUS.OLD = 3 59 | 60 | def test_setattr_names(self): 61 | with self.assertRaises(AttributeError): 62 | STATUS.names = {} 63 | 64 | def test_names_mangle(self): 65 | with self.assertRaises(AttributeError): 66 | STATUS.names.add('foo') 67 | 68 | 69 | class TestLabelWrapper(TestCase): 70 | 71 | def test_lower(self): 72 | 73 | class STATUS(LabeledEnum, label_wrapper=lambda x: x.lower()): 74 | CLOSED = 0 75 | NEW = 1 76 | PENDING = 2, 'Process Pending' 77 | FAILED = -1, 'Processing Failed' 78 | 79 | self.assertEqual(STATUS[2], 'process pending') 80 | --------------------------------------------------------------------------------