diff --git a/.travis.yml b/.travis.yml index dc0556a..96802cf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,47 @@ +sudo: false + language: python python: - 2.7 - 3.3 - 3.4 + - 3.5 + +env: + - DJANGO=1.6 + - DJANGO=1.7 + - DJANGO=1.8 + +matrix: + exclude: + - python: 3.5 + env: DJANGO=1.6 + - python: 3.5 + env: DJANGO=1.7 + include: + - python: 3.4 + env: DJANGO=1.8 COVERAGE=true COVERALLS_REPO_TOKEN=LdECqqwg7eelQx9w8gvooUZCFIaCqGZCv + allow_failures: + - env: COVERAGE=true install: - - pip install tox flake8 + - pip install flake8 + - pip install -q "Django>=$DJANGO,<$DJANGO.99" - make install script: - make flake8 - - tox + - make test + +after_script: + - if [ "$COVERAGE" == "true" ]; then + pip install --quiet python-coveralls; + make coverage; + coverage report; + coveralls --ignore-errors; + fi notifications: email: - hannes@5monkeys.se - diff --git a/Makefile b/Makefile index bee71eb..49d9694 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ test: python setup.py test flake8: - flake8 --ignore=E501,F403 --max-complexity 12 django_enumfield + flake8 django_enumfield install: python setup.py install @@ -11,4 +11,4 @@ develop: python setup.py develop coverage: - coverage run --include=django_enumfield/* setup.py test + coverage run --source django_enumfield setup.py test diff --git a/README.rst b/README.rst index 51484ec..d2cf40d 100644 --- a/README.rst +++ b/README.rst @@ -3,26 +3,11 @@ django-enumfield Provides an enumeration Django model field (using IntegerField) with reusable enums and transition validation. -.. image:: https://travis-ci.org/5monkeys/django-enumfield.png?branch=master +.. image:: https://travis-ci.org/5monkeys/django-enumfield.svg?branch=master :target: http://travis-ci.org/5monkeys/django-enumfield -.. image:: https://pypip.in/d/django-enumfield/badge.png - :target: https://pypi.python.org/pypi/django-enumfield/ - -.. image:: https://pypip.in/v/django-enumfield/badge.png - :target: https://pypi.python.org/pypi/django-enumfield/ - -.. image:: https://pypip.in/egg/django-enumfield/badge.png - :target: https://pypi.python.org/pypi/django-enumfield/ - -.. image:: https://pypip.in/wheel/django-enumfield/badge.png - :target: https://pypi.python.org/pypi/django-enumfield/ - -.. image:: https://pypip.in/format/django-enumfield/badge.png - :target: https://pypi.python.org/pypi/django-enumfield/ - -.. image:: https://pypip.in/license/django-enumfield/badge.png - :target: https://pypi.python.org/pypi/django-enumfield/ +.. image:: https://coveralls.io/repos/5monkeys/django-enumfield/badge.svg?branch=master&service=github + :target: https://coveralls.io/github/5monkeys/django-enumfield Installation @@ -65,7 +50,7 @@ You can use your own labels for Enum items CAT = 1 DOG = 2 - labels = { + __labels__ = { CAT: 'Cat', DOG: 'Dog' } @@ -82,7 +67,7 @@ The Enum-class provides the possibility to use transition validation. DEAD = 2 REANIMATED = 3 - _transitions = { + __transitions__ = { DEAD: (ALIVE,), REANIMATED: (DEAD,) } @@ -112,7 +97,7 @@ The Enum-class can also be used without the EnumField. This is very useful in Dj MALE = 1 FEMALE = 2 - labels = { + __labels__ = { MALE: 'Male', FEMALE: 'Female', } @@ -121,3 +106,13 @@ The Enum-class can also be used without the EnumField. This is very useful in Dj gender = forms.TypedChoiceField(choices=GenderEnum.choices(), coerce=int) Rendering PersonForm in a template will generate a select-box with "Male" and "Female" as option labels for the gender field. + +Changelog +--------- + +v2.0.0 +~~~~~~ + +* The ``enumfield.enum.Enum`` class is now a subclass of the native ``IntEnum`` shipped with Python 3.4 (uses the ``enum34`` package on previous versions of Python) +* Renamed ``_labels`` to ``__labels__`` +* Renamed ``_transitions`` to ``__transitions__`` diff --git a/django_enumfield/db/fields.py b/django_enumfield/db/fields.py index cca18c8..a2ab19e 100644 --- a/django_enumfield/db/fields.py +++ b/django_enumfield/db/fields.py @@ -1,27 +1,73 @@ +from enum import Enum + from django.db import models -from django import forms +from django.utils.functional import curry +from django.utils.encoding import force_text from django.utils import six +from django import forms +import django + +from .. import validators -from django_enumfield import validators +if django.VERSION < (1, 8): + base = six.with_metaclass(models.SubfieldBase, models.Field) +else: + base = models.Field -class EnumField(six.with_metaclass(models.SubfieldBase, models.IntegerField)): + +class EnumField(base): """ EnumField is a convenience field to automatically handle validation of transitions between Enum values and set field choices from the enum. EnumField(MyEnum, default=MyEnum.INITIAL) """ + default_error_messages = models.IntegerField.default_error_messages def __init__(self, enum, *args, **kwargs): kwargs['choices'] = enum.choices() if 'default' not in kwargs: kwargs['default'] = enum.default() self.enum = enum - models.IntegerField.__init__(self, *args, **kwargs) + super(EnumField, self).__init__(self, *args, **kwargs) + + def get_default(self): + if callable(self.default): + return self.default() + return self.default + + def get_internal_type(self): + return "IntegerField" def contribute_to_class(self, cls, name, virtual_only=False): super(EnumField, self).contribute_to_class(cls, name) + if self.choices: + setattr(cls, 'get_%s_display' % self.name, + curry(self._get_FIELD_display)) models.signals.class_prepared.connect(self._setup_validation, sender=cls) + def _get_FIELD_display(self, cls): + value = getattr(cls, self.attname) + return force_text(value.label, strings_only=True) + + def get_prep_value(self, value): + value = super(EnumField, self).get_prep_value(value) + if value is None: + return value + + if isinstance(value, Enum): + return value.value + return int(value) + + def from_db_value(self, value, expression, connection, context): + if value is not None: + return self.enum.get(value) + + return value + + def to_python(self, value): + if value is not None: + return self.enum.get(value) + def _setup_validation(self, sender, **kwargs): """ User a customer setter for the field to validate new value against the old one. @@ -39,6 +85,8 @@ def set_enum(self, new_value): # First setattr no previous value on instance. old_value = new_value # Update private enum attribute with new value + if new_value is not None and not isinstance(new_value, Enum): + new_value = enum.get(new_value) setattr(self, private_att_name, new_value) # Run validation for new value. validators.validate_valid_transition(enum, old_value, new_value) @@ -76,7 +124,6 @@ def south_field_triple(self): def deconstruct(self): name, path, args, kwargs = super(EnumField, self).deconstruct() - path = "django.db.models.fields.IntegerField" - if 'choices' in kwargs: - del kwargs['choices'] + kwargs['enum'] = self.enum + del kwargs['verbose_name'] return name, path, args, kwargs diff --git a/django_enumfield/enum.py b/django_enumfield/enum.py index 48b0218..a918db1 100644 --- a/django_enumfield/enum.py +++ b/django_enumfield/enum.py @@ -1,79 +1,81 @@ +from __future__ import absolute_import + +from enum import Enum as NativeEnum +from enum import IntEnum as NativeIntEnum + import logging +from django.utils.translation import ugettext from django.utils import six -from django.utils.encoding import python_2_unicode_compatible from django_enumfield.db.fields import EnumField - +from django_enumfield.exceptions import InvalidStatusOperationError logger = logging.getLogger(__name__) -class EnumType(type): - """ Custom metaclass for Enum type """ +class BlankEnum(NativeEnum): + BLANK = '' - def __new__(mcs, *args): - """ Create enum values from all uppercase class attributes and store them in a dict on the Enum class.""" - enum = super(EnumType, mcs).__new__(mcs, *args) - attributes = [k_v for k_v in list(enum.__dict__.items()) if k_v[0].isupper()] - labels = enum.__dict__.get('labels', {}) + @property + def label(self): + return '' - enum.values = {} - for attribute in attributes: - enum.values[attribute[1]] = enum.Value(attribute[0], attribute[1], labels.get(attribute[1]), enum) - return enum - -class Enum(six.with_metaclass(EnumType)): +class Enum(NativeIntEnum): """ A container for holding and restoring enum values """ - @python_2_unicode_compatible - class Value(object): - """ - A value represents a key-value pair with a uppercase name and a integer value: - GENDER = 1 - "name" is a upper case string representing the class attribute - "label" is a translatable human readable version of "name" - "enum_type" is the value defined for the class attribute - """ + __labels__ = {} + __transitions__ = {} - def __init__(self, name, value, label, enum_type): - self.name = name - self.value = value - self._label = label - self.enum_type = enum_type + def __ge__(self, other): + if self.__class__ is other.__class__: + return self.value >= other.value + return NotImplemented - def __str__(self): - return six.text_type(self.label) + def __gt__(self, other): + if self.__class__ is other.__class__: + return self.value > other.value + return NotImplemented - def __repr__(self): - return self.name + def __le__(self, other): + if self.__class__ is other.__class__: + return self.value <= other.value + return NotImplemented - def __eq__(self, other): - if other and isinstance(other, Enum.Value): - return self.value == other.value - elif isinstance(other, six.string_types): - return type(other)(self.value) == other - else: - raise TypeError('Can not compare Enum with %s' % other.__class__.__name__) + def __lt__(self, other): + if self.__class__ is other.__class__: + return self.value < other.value + return NotImplemented - @property - def label(self): - return self._label or self.name + def __eq__(self, other): + if isinstance(other, int): + return self.value == other + return super(Enum, self).__eq__(other) - def deconstruct(self): - path = self.__module__ + '.' + self.__class__.__name__ - return path, (self.name, self.value, self.label, self.enum_type), {} + def __hash__(self): + path, (val,), _ = self.deconstruct() + return hash('{}.{}'.format(path, val)) + + def deconstruct(self): + """ + See "Adding a deconstruct() method" in + https://docs.djangoproject.com/en/1.8/topics/migrations/ + """ + c = self.__class__ + path = '{}.{}'.format(c.__module__, c.__name__) + return path, [self.value], {} @classmethod def choices(cls, blank=False): """ Choices for Enum - :return: List of tuples (, ) + :return: List of tuples (, ) :rtype: list """ - choices = sorted([(key, value) for key, value in cls.values.items()], key=lambda x: x[0]) + choices = sorted([(member.value, member) for member in cls], + key=lambda x: x[0]) if blank: - choices.insert(0, ('', Enum.Value('', None, '', cls))) + choices.insert(0, (BlankEnum.BLANK.value, BlankEnum.BLANK)) return choices @classmethod @@ -82,9 +84,9 @@ def default(cls): Usage: IntegerField(choices=my_enum.choices(), default=my_enum.default(), ... :return Default value, which is the first one by default. - :rtype: int + :rtype: enum member """ - return cls.choices()[0][0] + return tuple(cls)[0] @classmethod def field(cls, **kwargs): @@ -109,38 +111,34 @@ def get(cls, name_or_numeric): :rtype: Enum.Value """ if isinstance(name_or_numeric, six.string_types): - name_or_numeric = getattr(cls, name_or_numeric.upper()) + if name_or_numeric.isdigit(): + name_or_numeric = int(name_or_numeric) - return cls.values.get(name_or_numeric) + if isinstance(name_or_numeric, six.text_type): + for member in cls: + if six.text_type(member.name) == name_or_numeric: + return member - @classmethod - def name(cls, numeric): - """ Get attribute name for the matching Enum.Value - :param numeric: Enum value - :type numeric: int - :return: Attribute name for value - :rtype: str - """ - return cls.get(numeric).name + elif isinstance(name_or_numeric, int): + for member in cls: + if int(member.value) == name_or_numeric: + return cls(name_or_numeric) - @classmethod - def label(cls, numeric): + elif isinstance(name_or_numeric, cls): + return name_or_numeric + + raise InvalidStatusOperationError(ugettext(six.text_type( + '{value!r} is not one of the available choices for enum {enum}.' + )).format(value=name_or_numeric, enum=cls)) + + @property + def label(self): """ Get human readable label for the matching Enum.Value. - :param numeric: Enum value - :type numeric: int :return: label for value - :rtype: str or - """ - return six.text_type(cls.get(numeric).label) - - @classmethod - def items(cls): - """ - :return: List of tuples consisting of every enum value in the form [('NAME', value), ...] - :rtype: list + :rtype: str """ - items = [(value.name, key) for key, value in cls.values.items()] - return sorted(items, key=lambda x: x[1]) + label = self.__class__.__labels__.get(self.value, self.name) + return six.text_type(label) @classmethod def is_valid_transition(cls, from_value, to_value): @@ -152,8 +150,14 @@ def is_valid_transition(cls, from_value, to_value): :return: Success flag :rtype: bool """ + if isinstance(from_value, cls): + from_value = from_value.value + + if isinstance(to_value, cls): + to_value = to_value.value try: - return from_value == to_value or from_value in cls.transition_origins(to_value) + return from_value == to_value or not cls.__transitions__ or \ + (from_value in cls.transition_origins(to_value)) except KeyError: return False @@ -164,7 +168,6 @@ def transition_origins(cls, to_value): :type to_value: int :rtype: list """ - return cls._transitions[to_value] - - -Value = Enum.Value + if isinstance(to_value, cls): + to_value = to_value.value + return cls.__transitions__.get(to_value, []) diff --git a/django_enumfield/tests/__init__.py b/django_enumfield/tests/__init__.py index 1203701..e69de29 100644 --- a/django_enumfield/tests/__init__.py +++ b/django_enumfield/tests/__init__.py @@ -1 +0,0 @@ -from django_enumfield.tests.test_enum import * diff --git a/django_enumfield/tests/models.py b/django_enumfield/tests/models.py index 9cbded3..cc21747 100644 --- a/django_enumfield/tests/models.py +++ b/django_enumfield/tests/models.py @@ -21,7 +21,7 @@ class PersonStatus(Enum): REANIMATED = 3 VOID = 4 - _transitions = { + __transitions__ = { UNBORN: (VOID,), ALIVE: (UNBORN,), DEAD: (UNBORN, ALIVE), @@ -59,7 +59,7 @@ class LabelBeer(Enum): JUPILER = 1 TYSKIE = 2 - labels = { + __labels__ = { STELLA: _('Stella Artois'), TYSKIE: _('Browar Tyskie'), } diff --git a/django_enumfield/tests/test_enum.py b/django_enumfield/tests/test_enum.py index 1ffb56f..656fe67 100644 --- a/django_enumfield/tests/test_enum.py +++ b/django_enumfield/tests/test_enum.py @@ -5,9 +5,10 @@ from django.utils import six from django_enumfield.db.fields import EnumField -from django_enumfield.enum import Enum +from django_enumfield.enum import Enum, BlankEnum from django_enumfield.exceptions import InvalidStatusOperationError -from django_enumfield.tests.models import Person, PersonStatus, Lamp, LampState, Beer, BeerStyle, BeerState, LabelBeer +from django_enumfield.tests.models import Person, PersonStatus, Lamp, \ + LampState, Beer, BeerStyle, BeerState, LabelBeer class EnumFieldTest(TestCase): @@ -21,7 +22,7 @@ def test_enum_field_init(self): self.assertEqual(field.default, None) def test_enum_field_save(self): - # Test model with EnumField WITHOUT _transitions + # Test model with EnumField WITHOUT __transitions__ lamp = Lamp.objects.create() self.assertEqual(lamp.state, LampState.OFF) @@ -32,18 +33,19 @@ def test_enum_field_save(self): self.assertRaises(InvalidStatusOperationError, setattr, lamp, 'state', 99) - # Test model with EnumField WITH _transitions + # Test model with EnumField WITH __transitions__ person = Person.objects.create() pk = person.pk self.assertEqual(person.status, PersonStatus.ALIVE) person.status = PersonStatus.DEAD person.save() - self.assertTrue(isinstance(person.status, int)) + self.assertTrue(isinstance(person.status, PersonStatus)) self.assertEqual(person.status, PersonStatus.DEAD) person = Person.objects.get(pk=pk) self.assertEqual(person.status, PersonStatus.DEAD) self.assertTrue(isinstance(person.status, int)) + self.assertTrue(isinstance(person.status, PersonStatus)) self.assertRaises(InvalidStatusOperationError, setattr, person, 'status', 99) @@ -123,19 +125,27 @@ class Meta: class EnumTest(TestCase): def test_label(self): - self.assertEqual(PersonStatus.label(PersonStatus.ALIVE), six.text_type('ALIVE')) + self.assertEqual(PersonStatus.ALIVE.label, six.text_type('ALIVE')) + self.assertEqual(LabelBeer.STELLA.label, + six.text_type('Stella Artois')) def test_name(self): - self.assertEqual(PersonStatus.name(PersonStatus.ALIVE), six.text_type('ALIVE')) + self.assertEqual(PersonStatus.ALIVE.name, six.text_type('ALIVE')) + self.assertEqual(LabelBeer.STELLA.name, six.text_type('STELLA')) def test_get(self): - self.assertTrue(isinstance(PersonStatus.get(PersonStatus.ALIVE), Enum.Value)) - self.assertTrue(isinstance(PersonStatus.get(six.text_type('ALIVE')), Enum.Value)) + self.assertTrue(isinstance(PersonStatus.get(PersonStatus.ALIVE), Enum)) + self.assertTrue(isinstance(PersonStatus.get(six.text_type('ALIVE')), Enum)) self.assertEqual(PersonStatus.get(PersonStatus.ALIVE), PersonStatus.get(six.text_type('ALIVE'))) def test_choices(self): - self.assertEqual(len(PersonStatus.choices()), len(list(PersonStatus.items()))) - self.assertTrue(all(key in PersonStatus.__dict__ for key in dict(list(PersonStatus.items())))) + self.assertEqual(len(PersonStatus.choices()), len(PersonStatus)) + for value, member in PersonStatus.choices(): + self.assertTrue(isinstance(value, int)) + self.assertTrue(isinstance(member, PersonStatus)) + self.assertTrue(PersonStatus.get(value) == member) + blank = PersonStatus.choices(blank=True)[0] + self.assertEqual(blank, (BlankEnum.BLANK.value, BlankEnum.BLANK)) def test_default(self): self.assertEqual(PersonStatus.default(), PersonStatus.UNBORN) @@ -149,7 +159,11 @@ def test_equal(self): self.assertEqual(PersonStatus.get(PersonStatus.ALIVE), PersonStatus.get(PersonStatus.ALIVE)) def test_labels(self): - self.assertEqual(LabelBeer.name(LabelBeer.JUPILER), LabelBeer.label(LabelBeer.JUPILER)) - self.assertNotEqual(LabelBeer.name(LabelBeer.STELLA), LabelBeer.label(LabelBeer.STELLA)) - self.assertTrue(isinstance(LabelBeer.label(LabelBeer.STELLA), six.string_types)) - self.assertEqual(LabelBeer.label(LabelBeer.STELLA), six.text_type('Stella Artois')) + self.assertEqual(LabelBeer.JUPILER.name, LabelBeer.JUPILER.label) + self.assertNotEqual(LabelBeer.STELLA.name, LabelBeer.STELLA.label) + self.assertTrue(isinstance(LabelBeer.STELLA.label, six.string_types)) + self.assertEqual(LabelBeer.STELLA.label, + six.text_type('Stella Artois')) + + def test_hash(self): + self.assertTrue({LabelBeer.JUPILER: True}[LabelBeer.JUPILER]) diff --git a/django_enumfield/tests/test_validators.py b/django_enumfield/tests/test_validators.py index a1bdfec..fec3b45 100644 --- a/django_enumfield/tests/test_validators.py +++ b/django_enumfield/tests/test_validators.py @@ -19,7 +19,7 @@ def test_validate_available_choice_2(self): """Test passing an int as a string validation """ self.assertIsNone( - validate_available_choice(BeerStyle, '%s' % BeerStyle.LAGER) + validate_available_choice(BeerStyle, '%s' % BeerStyle.LAGER.value) ) def test_validate_available_choice_3(self): diff --git a/django_enumfield/validators.py b/django_enumfield/validators.py index 8d8fbe0..b1786fe 100644 --- a/django_enumfield/validators.py +++ b/django_enumfield/validators.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from enum import Enum + from django.utils.translation import gettext as _ from django.utils import six @@ -9,12 +12,22 @@ def validate_valid_transition(enum, from_value, to_value): Validate that to_value is a valid choice and that to_value is a valid transition from from_value. """ validate_available_choice(enum, to_value) - if hasattr(enum, '_transitions') and not enum.is_valid_transition(from_value, to_value): + if isinstance(to_value, Enum): + t_value = to_value.value + else: + t_value = to_value + + if isinstance(from_value, Enum): + f_value = from_value.value + else: + f_value = from_value + + if not enum.is_valid_transition(f_value, t_value): message = _(six.text_type('{enum} can not go from "{from_value}" to "{to_value}"')) raise InvalidStatusOperationError(message.format( enum=enum.__name__, - from_value=enum.name(from_value), - to_value=enum.name(to_value) or to_value + from_value=getattr(from_value, 'name', None) or from_value, + to_value=getattr(to_value, 'name', None) or to_value )) @@ -25,14 +38,4 @@ def validate_available_choice(enum, to_value): if to_value is None: return - if type(to_value) is not int: - try: - to_value = int(to_value) - except ValueError: - message_str = "'{value}' cannot be converted to int" - message = _(six.text_type(message_str)) - raise InvalidStatusOperationError(message.format(value=to_value)) - - if to_value not in list(dict(enum.choices()).keys()): - message = _(six.text_type('Select a valid choice. {value} is not one of the available choices.')) - raise InvalidStatusOperationError(message.format(value=to_value)) + enum.get(to_value) diff --git a/run_tests.py b/run_tests.py index 27f54fa..65d1ab2 100644 --- a/run_tests.py +++ b/run_tests.py @@ -30,6 +30,7 @@ def main(): ROOT_URLCONF='django_enumfield.tests.urls', DEBUG=True, TEMPLATE_DEBUG=True, + MIDDLEWARE_CLASSES=[], ) # Compatibility with Django 1.7's stricter initialization diff --git a/setup.cfg b/setup.cfg index 5e40900..1543474 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,10 @@ [wheel] universal = 1 + +[extras] +enum34:python_version>"2.7";:python_version<"3.4" + +[flake8] +exclude = .tox,.git,docs,migrations +max-complexity = 12 +max-line-length = 110 diff --git a/setup.py b/setup.py index b7596f6..1d3d4a0 100644 --- a/setup.py +++ b/setup.py @@ -84,4 +84,12 @@ def fullsplit(path, result=None): packages=packages, tests_require=['Django'], test_suite='run_tests.main', + extras_require={ + ':python_version=="2.6"': ['enum34'], + ':python_version=="2.7"': ['enum34'], + ':python_version=="3.0"': ['enum34'], + ':python_version=="3.1"': ['enum34'], + ':python_version=="3.2"': ['enum34'], + ':python_version=="3.3"': ['enum34'], + } )