Skip to content

Commit

Permalink
Merge pull request #24 from sergei-maertens/feature/deconstructible_v…
Browse files Browse the repository at this point in the history
…alidator

Made the validator deconstructible in Django 1.7+, fixing #15
  • Loading branch information
sergei-maertens committed Aug 29, 2015
2 parents f0953b7 + 7102aed commit 5db835c
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 33 deletions.
14 changes: 14 additions & 0 deletions Changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
Changelog
=========

1.4 to 1.4.1
------------
This is a small release that fixes some ugliness. In Django 1.7 and up, the
validator can now be used as documented in the readme. Before this version, the
error::

ValueError: Cannot serialize: <bound method DjangoChoicesMeta.validator of <class 'choices.models.MyModel.Choices'>>

would be raised, and a workaround was to define a validator function calling the
choices' validator, requiring everything to be defined in the module scope.

This is now fixed by moving to a class based validator which is deconstructible.


1.3 to 1.4
----------
* Added support for upcoming Django 1.9, by preferring stlib SortedDict over
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2011-2013 Jason Webb
Copyright (c) 2011-2015 Jason Webb

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ With this::
Customer = ChoiceItem("C")
Employee = ChoiceItem("E")
Groundhog = ChoiceItem("G")

# Fields
name = models.CharField(max_length=32)
type = models.CharField(max_length=1,
Expand Down
1 change: 1 addition & 0 deletions djchoices/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from djchoices.choices import ChoiceItem, DjangoChoices, C

__version__ = '1.4'

__all__ = ["ChoiceItem", "DjangoChoices", "C"]
77 changes: 49 additions & 28 deletions djchoices/choices.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import re

from django.core.exceptions import ValidationError

try:
from collections import OrderedDict
except ImportError: # Py2.6, fall back to Django's implementation
from django.utils.datastructures import SortedDict as OrderedDict
from .compat import deconstructible, OrderedDict, six

try:
from django.utils import six
except ImportError:
import six

__all__ = ["ChoiceItem", "DjangoChoices", "C"]

### Support Functionality (Not part of public API ###

# Support Functionality (Not part of public API)

class Labels(dict):
def __getattribute__(self, name):
Expand All @@ -22,42 +17,53 @@ def __getattribute__(self, name):
return result
else:
raise AttributeError("Label for field %s was not found." % name)

def __setattr__(self, name, value):
self[name] = value

### End Support Functionality ###

class StaticProp(object):

def __init__(self, value):
self.value = value

def __get__(self, obj, objtype):
return self.value

# End Support Functionality


class ChoiceItem(object):
"""
Describes a choice item. The label is usually the field name so label can
normally be left blank. Set a label if you need characters that are illegal
in a python identifier name (ie: "DVD/Movie").
Describes a choice item.
The label is usually the field name so label can normally be left blank.
Set a label if you need characters that are illegal in a python identifier
name (ie: "DVD/Movie").
"""
order = 0

def __init__(self, value=None, label=None, order=None):
self.value = value
self.label = label

if order:
self.order = order
else:
ChoiceItem.order += 1
self.order = ChoiceItem.order
self.label = label

# Shorter convenience alias.
C = ChoiceItem


class DjangoChoicesMeta(type):
"""
Metaclass that writes the choices class.
"""
name_clean = re.compile(r"_+")
def __new__(cls, name, bases, attrs):
class StaticProp(object):
def __init__(self, value):
self.value = value
def __get__(self, obj, objtype):
return self.value

def __new__(cls, name, bases, attrs):
fields = {}
labels = Labels()
values = {}
Expand All @@ -80,9 +86,10 @@ def __get__(self, obj, objtype):
for field_name in fields:
val = fields[field_name]
if isinstance(val, ChoiceItem):
if not val.label is None:
if val.label is not None:
label = val.label
else:
# TODO: mark translatable by default?
label = cls.name_clean.sub(" ", field_name)

val0 = label if val.value is None else val.value
Expand All @@ -97,18 +104,32 @@ def __get__(self, obj, objtype):
attrs["labels"] = labels
attrs["values"] = values
attrs["_fields"] = fields
attrs["validator"] = ChoicesValidator(values)

return super(DjangoChoicesMeta, cls).__new__(cls, name, bases, attrs)


@deconstructible
class ChoicesValidator(object):

def __init__(self, values):
self.values = values

def __call__(self, value):
if value not in self.values:
raise ValidationError('Select a valid choice. %(value)s is not '
'one of the available choices.')

def __eq__(self, other):
return isinstance(other, ChoicesValidator) and self.values == other.values

def __ne__(self, other):
return not (self == other)


class DjangoChoices(six.with_metaclass(DjangoChoicesMeta)):
order = 0
choices = ()
labels = Labels()
values = {}

@classmethod
def validator(cls, value):
if value not in cls.values:
raise ValidationError('Select a valid choice. %(value)s is not '
'one of the available choices.')

validator = None
21 changes: 21 additions & 0 deletions djchoices/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""
Module to handle different Django/Python version libraries.
"""
try:
from collections import OrderedDict
except ImportError: # Py2.6, fall back to Django's implementation
from django.utils.datastructures import SortedDict as OrderedDict # pragma: no cover


try:
from django.utils import six
except ImportError:
import six # pragma: no cover


try:
from django.utils.deconstruct import deconstructible
except ImportError:
# just return a noop decorator
def deconstructible(cls):
return cls
18 changes: 16 additions & 2 deletions djchoices/tests/test_choices.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
try:
import unittest2 as unittest
import unittest2 as unittest
except ImportError:
import unittest
import unittest

from djchoices import DjangoChoices, C, ChoiceItem

from .utils import has_new_migrations


class NumericTestClass(DjangoChoices):
Item_0 = C(0)
Item_1 = C(1)
Item_2 = C(2)
Item_3 = C(3)


class StringTestClass(DjangoChoices):
empty = ChoiceItem("", "")
One = ChoiceItem("O")
Two = ChoiceItem("T")
Three = ChoiceItem("H")


class SubClass1(NumericTestClass):
Item_4 = C(4)
Item_5 = C(5)


class SubClass2(SubClass1):
Item_6 = C(6)
Item_7 = C(7)


class EmptyValueClass(DjangoChoices):
Option1 = ChoiceItem()
Option2 = ChoiceItem()
Expand Down Expand Up @@ -148,3 +155,10 @@ def test_empty_value_class(self):
self.assertEqual(choices[0][0], "Option1")
self.assertEqual(choices[1][0], "Option2")
self.assertEqual(choices[2][0], "Option3")

@unittest.skipUnless(*has_new_migrations())
def test_deconstructible_validator(self):
deconstructed = NumericTestClass.validator.deconstruct()
self.assertEqual(deconstructed, (
'djchoices.choices.ChoicesValidator', (NumericTestClass.values,), {}
))
5 changes: 5 additions & 0 deletions djchoices/tests/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import django


def has_new_migrations():
return (django.VERSION[:2] >= (1, 7), "Test requires the Django migrations introduced in Django 1.7")
5 changes: 3 additions & 2 deletions runtests.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
try:
import unittest2 as unittest
import unittest2 as unittest
except ImportError:
import unittest
import unittest

from os import path
from sys import stdout


if __name__ == "__main__":
disc_folder = path.abspath(path.dirname(__file__))

Expand Down

0 comments on commit 5db835c

Please sign in to comment.