Skip to content

Commit

Permalink
Merge branch 'release/v1.0.5'
Browse files Browse the repository at this point in the history
  • Loading branch information
twidi committed Oct 14, 2015
2 parents 09f2db9 + 0a20dcf commit ec7f8b2
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 16 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Changelog
=========

Release *v1.0.5* - ``2015-10-14``
---------------------------------
* add compatibility with the ``pickle`` module

Release *v1.0.4* - ``2015-05-05``
---------------------------------
* explicitly raise ``ValueError`` when using ``None`` for constant, value or display name.
Expand Down
4 changes: 3 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ You can also pass the ``argument`` to the ``Choices`` constructor to create a su
the choices entries added at the same time (it will call ``add_choices`` with the name and the
entries)
The list of existing subset names is in the ``subsets`` attributes of the parent ``Choicess`` object.
Notes
-----
Expand Down Expand Up @@ -381,7 +383,7 @@ Written by Stephane "Twidi" Angel <[email protected]> (http://twidi.com), origin
.. _Github: https://github.com/twidi/django-extended-choices
.. _Django matrix: https://docs.djangoproject.com/en/1.8/faq/install/#what-python-version-can-i-use-with-django
.. _TravisCi: https://travis-ci.org/twidi/django-extended-choices/pull_requests
.. _RedTheDoc: http://django-extended-choices.readthedocs.org
.. _ReadTheDoc: http://django-extended-choices.readthedocs.org
.. _BSD: http://opensource.org/licenses/BSD-3-Clause
.. |PyPI Version| image:: https://img.shields.io/pypi/v/django-extended-choices.png
Expand Down
2 changes: 1 addition & 1 deletion extended_choices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
__author__ = 'Stephane "Twidi" Ange;'
__contact__ = "[email protected]"
__homepage__ = "https://pypi.python.org/pypi/django-extended-choices"
__version__ = "1.0.4"
__version__ = "1.0.5"
85 changes: 82 additions & 3 deletions extended_choices/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ class Choices(list):
``values`` and ``displays``. Could be set for example to ``OrderedSet``.
retro_compatibility : boolean, optional
``True`` by default, it makes the ``Choices`` object compatible with version < 1.
If set to ``False``, all the attributes created for this purpose wont be created.
Example
Expand Down Expand Up @@ -197,6 +196,9 @@ def __init__(self, *choices, **kwargs):
# List of ``ChoiceEntry``, one for each choice in this instance.
self.entries = []

# List of the created subsets
self.subsets = []

# Dicts to access ``ChoiceEntry`` instances by constant, value or display value.
self.constants = self.dict_class()
self.values = self.dict_class()
Expand Down Expand Up @@ -305,7 +307,7 @@ def add_choices(self, *choices, **kwargs):
choices = choices[1:]

# Check for an optional subset name in the named arguments.
if 'name' in kwargs:
if kwargs.get('name', None):
if subset_name:
raise ValueError("The name of the subset cannot be defined as the first "
"argument and also as a named argument")
Expand Down Expand Up @@ -464,6 +466,7 @@ def add_subset(self, name, constants):

# Make the subset accessible via an attribute.
setattr(self, name, subset)
self.subsets.append(name)

# Will be removed one day. See the "compatibility" section in the documentation.
if self.retro_compatibility:
Expand All @@ -488,7 +491,6 @@ def add_subset(self, name, constants):
setattr(self, '%s_CONST_DICT' % name, SUBSET_CONST_DICT)
setattr(self, 'REVERTED_%s_CONST_DICT' % name, REVERTED_SUBSET_CONST_DICT)


def for_constant(self, constant):
"""Returns the ``ChoiceEntry`` for the given constant.
Expand Down Expand Up @@ -768,6 +770,83 @@ def __eq__(self, other):

# TODO: implement __iadd__ and __add__

def __reduce__(self):
"""Reducer to make the auto-created classes picklable.
Returns
-------
tuple
A tuple as expected by pickle, to recreate the object when calling ``pickle.loads``:
1. a callable to recreate the object
2. a tuple with all positioned arguments expected by this callable
"""

return (
# Function to create a ``Choices`` instance
create_choice,
(
# The ``Choices`` class, or a subclass, used to create the current instance
self.__class__,
# The list of choices
[
(
entry.constant.original_value,
entry.value.original_value,
entry.display.original_value,
)
for entry in self.entries
],
# The list of subsets
[
(
# The name
subset_name,
# The list of constants to use in this subset
[
c.original_value
for c in getattr(self, subset_name).constants.keys()
]
)
for subset_name in self.subsets
],
# Extra kwargs to pass to ``__ini__``
{
'dict_class': self.dict_class,
'retro_compatibility': self.retro_compatibility,
'mutable': self._mutable,
}
)
)

def create_choice(klass, choices, subsets, kwargs):
"""Create an instance of a ``Choices`` object.
Parameters
----------
klass : type
The class to use to recreate the object.
choices : list(tuple)
A list of choices as expected by the ``__init__`` method of ``klass``.
subsets : list(tuple)
A tuple with an entry for each subset to create. Each entry is a list with two entries:
- the name of the subsets
- a list of the constants to use for this subset
kwargs : dict
Extra parameters expected on the ``__init__`` method of ``klass``.
Returns
-------
Choices
A new instance of ``Choices`` (or other class defined in ``klass``).
"""

obj = klass(*choices, **kwargs)
for subset in subsets:
obj.add_subset(*subset)
return obj


if __name__ == '__main__':
import doctest
Expand Down
79 changes: 75 additions & 4 deletions extended_choices/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

from builtins import object

try:
import cPickle as pickle
except ImportError:
import pickle

from django.utils.functional import Promise


Expand All @@ -35,6 +40,11 @@ class ChoiceAttributeMixin(object):
Returns the choice field holding the value of the attached ``ChoiceEntry``.
display : property
Returns the choice field holding the display name of the attached ``ChoiceEntry``.
original_type : type (class attribute)
The class of the value used to create a new class.
creator_type : type
The class that created a new class. Will be ``ChoiceAttributeMixin`` except if it was
overridden by the author.
Example
-------
Expand Down Expand Up @@ -77,7 +87,6 @@ def __new__(cls, *args, **kwargs):

return super(ChoiceAttributeMixin, cls).__new__(cls, *args[:1])


def __init__(self, value, choice_entry):
"""Initiate the object to save the value and the choice entry.
Expand Down Expand Up @@ -120,6 +129,11 @@ def display(self):
"""Property that returns the ``display`` attribute of the attached ``ChoiceEntry``."""
return self.choice_entry.display

@property
def original_value(self):
"""Return the original value used to create the current instance."""
return self.original_type(self)

@classmethod
def get_class_for_value(cls, value):
"""Class method to construct a class based on this mixin and the type of the given value.
Expand All @@ -136,19 +150,77 @@ def get_class_for_value(cls, value):
"""
type_ = value.__class__

# Check if the type is already a ``ChoiceAttribute``
if issubclass(type_, ChoiceAttributeMixin):
# In this case we can return this type
return type_

# Create a new class only if it wasn't already created for this type.
if type_ not in cls._classes_by_type:
# Compute the name of the class with the name of the type.
class_name = str('%sChoiceAttribute' % type_.__name__.capitalize())
# Create a new class and save it in the cache.
cls._classes_by_type[type_] = type(class_name, (cls, type_), {})
cls._classes_by_type[type_] = type(class_name, (cls, type_), {
'original_type': type_,
'creator_type': cls,
})

# Return the class from the cache based on the type.
return cls._classes_by_type[type_]

def __reduce__(self):
"""Reducer to make the auto-created classes picklable.
Returns
-------
tuple
A tuple as expected by pickle, to recreate the object when calling ``pickle.loads``:
1. a callable to recreate the object
2. a tuple with all positioned arguments expected by this callable
"""

return (
# Function to create a choice attribute
create_choice_attribute,
(
# The class that created the class of the current value
self.creator_type,
# The original type of the current value
self.original_value,
# The tied `choice_entry`
self.choice_entry
)
)

_classes_by_type = {}


def create_choice_attribute(creator_type, value, choice_entry):
"""Create an instance of a subclass of ChoiceAttributeMixin for the given value.
Parameters
----------
creator_type : type
``ChoiceAttributeMixin`` or a subclass, from which we'll call the ``get_class_for_value``
class-method.
value : ?
The value for which we want to create an instance of a new subclass of ``creator_type``.
choice_entry: ChoiceEntry
The ``ChoiceEntry`` instance that hold the current value, used to access its constant,
value and display name.
Returns
-------
ChoiceAttributeMixin
An instance of a subclass of ``creator_type`` for the given value
"""

klass = creator_type.get_class_for_value(value)
return klass(value, choice_entry)


class ChoiceEntry(tuple):
"""Represents a choice in a ``Choices`` object, with easy access to its attribute.
Expand Down Expand Up @@ -243,5 +315,4 @@ def _get_choice_attribute(self, value):
raise ValueError('Using `None` in a `Choices` object is not supported. You may '
'use an empty string.')

klass = self.ChoiceAttributeMixin.get_class_for_value(value)
return klass(value, self)
return create_choice_attribute(self.ChoiceAttributeMixin, value, self)
79 changes: 73 additions & 6 deletions extended_choices/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,24 @@

import os, sys

try:
import cPickle as pickle
except ImportError:
import pickle

if sys.version_info >= (2, 7):
import unittest
else:
import unittest2 as unittest

import django

# Use an ordered dict, from python or django depending on the python version.
if sys.version_info >= (2, 7):
from collections import OrderedDict
else:
from django.utils.datastructures import SortedDict as OrderedDict

# Minimal django conf to test a real field.
from django.conf import settings
settings.configure(DATABASE_ENGINE='sqlite3')
Expand Down Expand Up @@ -861,12 +872,6 @@ def test_retrocompatibility(self):
def test_dict_class(self):
"""Test that the dict class to use can be set on the constructor."""

# For the test, use an ordered dict, from python or django depending on the python version.
if sys.version_info >= (2, 7):
from collections import OrderedDict
else:
from django.utils.datastructures import SortedDict as OrderedDict

OTHER_CHOICES = Choices(
('ONE', 1, 'One for the money'),
('TWO', 2, 'Two for the show'),
Expand All @@ -891,6 +896,68 @@ def test_dict_class(self):
self.assertFalse(isinstance(getattr(self.MY_CHOICES, attr), OrderedDict))
self.assertTrue(isinstance(getattr(OTHER_CHOICES, attr), OrderedDict))

def test_pickle_choice_attribute(self):
"""Test that a choice attribute could be pickled and unpickled."""

value = self.MY_CHOICES.ONE

pickled_value = pickle.dumps(value)
unpickled_value = pickle.loads(pickled_value)

self.assertEqual(unpickled_value, value)
self.assertEqual(unpickled_value.choice_entry, value.choice_entry)
self.assertEqual(unpickled_value.constant, 'ONE')
self.assertEqual(unpickled_value.display, 'One for the money')
self.assertEqual(unpickled_value.value, 1)

def test_pickle_choice_entry(self):
"""Test that a choice entry could be pickled and unpickled."""

entry = self.MY_CHOICES.ONE.choice_entry

pickled_entry = pickle.dumps(entry)
unpickled_entry = pickle.loads(pickled_entry)

self.assertEqual(unpickled_entry, entry)
self.assertEqual(unpickled_entry.constant, 'ONE')
self.assertEqual(unpickled_entry.display, 'One for the money')
self.assertEqual(unpickled_entry.value, 1)

def test_pickle_choice(self):
"""Test that a choice object could be pickled and unpickled."""

# Simple choice
pickled_choice = pickle.dumps(self.MY_CHOICES)
unpickled_choice = pickle.loads(pickled_choice)

self.assertEqual(unpickled_choice, self.MY_CHOICES)

# With a name, extra arguments and subsets
OTHER_CHOICES = Choices(
'ALL',
('ONE', 1, 'One for the money'),
('TWO', 2, 'Two for the show'),
('THREE', 3, 'Three to get ready'),
dict_class = OrderedDict,
retro_compatibility=False,
mutable=False
)
OTHER_CHOICES.add_subset("ODD", ("ONE", "THREE"))
OTHER_CHOICES.add_subset("EVEN", ("TWO", ))

pickled_choice = pickle.dumps(OTHER_CHOICES)
unpickled_choice = pickle.loads(pickled_choice)

self.assertEqual(unpickled_choice, OTHER_CHOICES)
self.assertEqual(unpickled_choice.dict_class, OrderedDict)
self.assertFalse(unpickled_choice.retro_compatibility)
self.assertFalse(unpickled_choice._mutable)
self.assertEqual(unpickled_choice.subsets, OTHER_CHOICES.subsets)
self.assertEqual(unpickled_choice.ALL, OTHER_CHOICES.ALL)
self.assertEqual(unpickled_choice.ODD, OTHER_CHOICES.ODD)
self.assertEqual(unpickled_choice.EVEN, OTHER_CHOICES.EVEN)



if __name__ == "__main__":
unittest.main()
Loading

0 comments on commit ec7f8b2

Please sign in to comment.