├── requirements.txt ├── requirements-dev.txt ├── docs ├── source │ ├── README.rst │ ├── modules │ │ ├── fields.rst │ │ ├── choices.rst │ │ └── helpers.rst │ ├── index.rst │ └── conf.py └── Makefile ├── requirements-makedoc.txt ├── MANIFEST.in ├── AUTHORS ├── setup.py ├── .gitignore ├── extended_choices ├── __main__.py ├── __init__.py ├── fields.py ├── helpers.py ├── choices.py └── tests.py ├── tox.ini ├── LICENSE ├── setup.cfg ├── .travis.yml ├── CHANGELOG.rst └── README.rst /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e .[dev] -------------------------------------------------------------------------------- /docs/source/README.rst: -------------------------------------------------------------------------------- 1 | ../../README.rst -------------------------------------------------------------------------------- /requirements-makedoc.txt: -------------------------------------------------------------------------------- 1 | -e .[doc] 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | AUTHOR 2 | Stephane "Twidi" Angel # during work at liberation.fr 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | setup() 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | include/ 3 | lib/ 4 | build/ 5 | dist/ 6 | *.pyc 7 | .*.sw? 8 | .sw? 9 | *egg-info 10 | # pycharm project directory 11 | .idea 12 | .coverage 13 | .tox 14 | -------------------------------------------------------------------------------- /docs/source/modules/fields.rst: -------------------------------------------------------------------------------- 1 | extended_choices.fields module 2 | ============================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | .. automodule:: extended_choices.fields 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/source/modules/choices.rst: -------------------------------------------------------------------------------- 1 | extended_choices.choices module 2 | =============================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | .. automodule:: extended_choices.choices 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/source/modules/helpers.rst: -------------------------------------------------------------------------------- 1 | extended_choices.helpers module 2 | =============================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | .. automodule:: extended_choices.helpers 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /extended_choices/__main__.py: -------------------------------------------------------------------------------- 1 | """Run doctests on choices.py and helpers.py""" 2 | 3 | import doctest 4 | import sys 5 | from . import choices, helpers 6 | 7 | failures = 0 8 | 9 | failures += doctest.testmod(m=choices, report=True)[0] 10 | failures += doctest.testmod(m=helpers, report=True)[0] 11 | 12 | if failures > 0: 13 | sys.exit(1) 14 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-extended-choices documentation master file, created by 2 | sphinx-quickstart on Sat May 2 18:38:53 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-extended-choices's documentation! 7 | =================================================== 8 | 9 | What is it? 10 | ----------- 11 | 12 | .. automodule:: extended_choices 13 | 14 | 15 | Documentation contents 16 | ---------------------- 17 | 18 | .. toctree:: 19 | :maxdepth: 4 20 | 21 | Readme 22 | Module "extended_choices.choices" 23 | Module "extended_choices.fields" 24 | Module "extended_choices.helpers" 25 | 26 | 27 | 28 | Indices and tables 29 | ------------------ 30 | 31 | * :ref:`genindex` 32 | * :ref:`modindex` 33 | * :ref:`search` 34 | 35 | -------------------------------------------------------------------------------- /extended_choices/__init__.py: -------------------------------------------------------------------------------- 1 | """Little helper application to improve django choices (for fields)""" 2 | from __future__ import unicode_literals 3 | import pkg_resources 4 | import six 5 | from os import path 6 | from setuptools.config import read_configuration 7 | 8 | from .choices import Choices, OrderedChoices, AutoDisplayChoices, AutoChoices # noqa: F401 9 | 10 | 11 | def _extract_version(package_name): 12 | try: 13 | # if package is installed 14 | version = pkg_resources.get_distribution(package_name).version 15 | except pkg_resources.DistributionNotFound: 16 | # if not installed, so we must be in source, with ``setup.cfg`` available 17 | _conf = read_configuration(path.join( 18 | path.dirname(__file__), '..', 'setup.cfg') 19 | ) 20 | version = _conf['metadata']['version'] 21 | 22 | return version 23 | 24 | 25 | EXACT_VERSION = six.text_type(_extract_version('django_extended_choices')) 26 | VERSION = tuple(int(part) for part in EXACT_VERSION.split('.') if part.isnumeric()) 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,34,35}-django{18,19,110}, 4 | py{27,34,35,36}-django{111}, 5 | py{34,35,36,37}-django{20}, 6 | py{35,36,37}-django{21,22}, 7 | py{36,37}-djangomaster, 8 | flake8 9 | doctests 10 | 11 | [testenv] 12 | basepython = 13 | py27: python2.7 14 | py34: python3.4 15 | py35: python3.5 16 | py36: python3.6 17 | py37: python3.7 18 | deps = 19 | django18: Django>=1.8,<1.9 20 | django19: Django>=1.9,<1.10 21 | django110: Django>=1.10,<1.11 22 | django111: Django>=1.11,<2.0 23 | django20: Django>=2.0,<2.1 24 | django21: Django>=2.1,<2.2 25 | django22: Django>=2.2,<2.3 26 | djangomaster: https://github.com/django/django/archive/master.tar.gz#egg=django 27 | coverage 28 | commands = 29 | pip install -e . 30 | pip freeze 31 | coverage run -m --source=extended_choices extended_choices.tests 32 | coverage report 33 | 34 | [testenv:doctests] 35 | basepython = python3.6 36 | deps = 37 | django 38 | commands = 39 | python -m extended_choices 40 | 41 | [testenv:flake8] 42 | basepython = python3.6 43 | deps = 44 | flake8 45 | commands = 46 | flake8 extended_choices --ignore E501,E402,E731 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Stephane Angel 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of [project] nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-extended-choices 3 | version = 1.3.3 4 | author = Stephane "Twidi" Angel 5 | author_email = s.angel@twidi.com 6 | url = https://github.com/twidi/django-extended-choices 7 | description = Little helper application to improve django choices (for fields) 8 | long_description = file: README.rst 9 | license = BSD 10 | license_file = LICENSE 11 | keywords = redis, orm, jobs, queue 12 | platform = any 13 | classifiers = 14 | Development Status :: 5 - Production/Stable 15 | Framework :: Django 16 | Framework :: Django :: 1.8 17 | Framework :: Django :: 1.9 18 | Framework :: Django :: 1.10 19 | Framework :: Django :: 1.11 20 | Framework :: Django :: 2.0 21 | Framework :: Django :: 2.1 22 | Framework :: Django :: 2.2 23 | Operating System :: OS Independent 24 | Intended Audience :: Developers 25 | License :: OSI Approved :: BSD License 26 | Programming Language :: Python 27 | Programming Language :: Python :: 2 28 | Programming Language :: Python :: 2.7 29 | Programming Language :: Python :: 3 30 | Programming Language :: Python :: 3.4 31 | Programming Language :: Python :: 3.5 32 | Programming Language :: Python :: 3.6 33 | Programming Language :: Python :: 3.7 34 | Topic :: Software Development :: Libraries 35 | Topic :: Software Development :: Libraries :: Python Modules 36 | 37 | [options] 38 | zip_safe = True 39 | packages = extended_choices 40 | install_requires = 41 | six 42 | python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* 43 | 44 | [options.extras_require] 45 | dev = 46 | django 47 | doc = 48 | django 49 | sphinx 50 | sphinxcontrib-napoleon 51 | sphinx_rtd_theme 52 | 53 | [bdist_wheel] 54 | universal = 1 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: false 3 | language: python 4 | matrix: 5 | fast_finish: true 6 | include: 7 | # Python version is just for the look on travis. 8 | - python: 3.6 9 | env: TOXENV=flake8 10 | 11 | - python: 3.6 12 | env: TOXENV=doctests 13 | 14 | - python: 2.7 15 | env: TOXENV=py27-django18 16 | 17 | - python: 2.7 18 | env: TOXENV=py27-django19 19 | 20 | - python: 2.7 21 | env: TOXENV=py27-django110 22 | 23 | - python: 2.7 24 | env: TOXENV=py27-django111 25 | 26 | - python: 3.4 27 | env: TOXENV=py34-django18 28 | 29 | - python: 3.4 30 | env: TOXENV=py34-django19 31 | 32 | - python: 3.4 33 | env: TOXENV=py34-django110 34 | 35 | - python: 3.4 36 | env: TOXENV=py34-django111 37 | 38 | - python: 3.4 39 | env: TOXENV=py34-django20 40 | 41 | - python: 3.5 42 | env: TOXENV=py35-django18 43 | 44 | - python: 3.5 45 | env: TOXENV=py35-django19 46 | 47 | - python: 3.5 48 | env: TOXENV=py35-django110 49 | 50 | - python: 3.5 51 | env: TOXENV=py35-django111 52 | 53 | - python: 3.5 54 | env: TOXENV=py35-django20 55 | 56 | - python: 3.5 57 | env: TOXENV=py35-django21 58 | 59 | - python: 3.5 60 | env: TOXENV=py35-django22 61 | 62 | - python: 3.6 63 | env: TOXENV=py36-django111 64 | 65 | - python: 3.6 66 | env: TOXENV=py36-django20 67 | 68 | - python: 3.6 69 | env: TOXENV=py36-django21 70 | 71 | - python: 3.6 72 | env: TOXENV=py36-django22 73 | 74 | - python: 3.6 75 | env: TOXENV=py36-djangomaster 76 | 77 | - python: 3.7 78 | env: TOXENV=py37-django20 79 | 80 | - python: 3.7 81 | env: TOXENV=py37-django21 82 | 83 | - python: 3.7 84 | env: TOXENV=py37-django22 85 | 86 | - python: 3.7 87 | env: TOXENV=py37-djangomaster 88 | 89 | allow_failures: 90 | - env: TOXENV=py36-djangomaster 91 | - env: TOXENV=py37-djangomaster 92 | 93 | install: 94 | - pip install tox 95 | 96 | script: 97 | - tox 98 | -------------------------------------------------------------------------------- /extended_choices/fields.py: -------------------------------------------------------------------------------- 1 | """Provides a form field for django to use constants instead of values as available values. 2 | 3 | Notes 4 | ----- 5 | 6 | The documentation format in this file is `numpydoc`_. 7 | 8 | .. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt 9 | 10 | """ 11 | 12 | from __future__ import unicode_literals 13 | 14 | import six 15 | 16 | from django import forms 17 | 18 | from . import Choices 19 | 20 | 21 | class NamedExtendedChoiceFormField(forms.Field): 22 | """Field to use with choices where values are constant names instead of choice values. 23 | 24 | Should not be very useful in normal HTML form, but if API validation is done via a form, it 25 | will to have more readable constants in the API that values 26 | """ 27 | def __init__(self, choices, *args, **kwargs): 28 | """Override to ensure that the ``choices`` argument is a ``Choices`` object.""" 29 | 30 | super(NamedExtendedChoiceFormField, self).__init__(*args, **kwargs) 31 | 32 | if not isinstance(choices, Choices): 33 | raise ValueError("`choices` must be an instance of `extended_choices.Choices`.") 34 | 35 | self.choices = choices 36 | 37 | def to_python(self, value): 38 | """Convert the constant to the real choice value.""" 39 | 40 | # ``is_required`` is already checked in ``validate``. 41 | if value is None: 42 | return None 43 | 44 | # Validate the type. 45 | if not isinstance(value, six.string_types): 46 | raise forms.ValidationError( 47 | "Invalid value type (should be a string).", 48 | code='invalid-choice-type', 49 | ) 50 | 51 | # Get the constant from the choices object, raising if it doesn't exist. 52 | try: 53 | final = getattr(self.choices, value) 54 | except AttributeError: 55 | available = '[%s]' % ', '.join(self.choices.constants) 56 | raise forms.ValidationError( 57 | "Invalid value (not in available choices. Available ones are: %s" % available, 58 | code='non-existing-choice', 59 | ) 60 | 61 | return final 62 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Release *v1.3.3* - ``2019-04-16`` 5 | --------------------------------- 6 | * official support for Django 2.2 7 | 8 | Release *v1.3.2* - ``2019-02-25`` 9 | --------------------------------- 10 | * replace ``future`` dependency by ``six`` 11 | 12 | Release *v1.3.1* - ``2019-01-17`` 13 | --------------------------------- 14 | * official support for Python 3.7 and Django 2.1 15 | 16 | Release *v1.3* - ``2018-02-17`` 17 | ------------------------------- 18 | * correct inability fo ``Auto*Choices`` be able to have subsets 19 | * `Auto*Choices` can accept entries with forced value/display 20 | * additional attributes are now correctly (un)pickled 21 | * additional attributes are now also accessible from constant/value/display 22 | 23 | Release *v1.2* - ``2018-02-01`` 24 | ------------------------------- 25 | * add ``AutoChoices`` and ``AutoDisplayChoices`` 26 | * document the fourth argument to tuples to pass additional attributes 27 | * remove support for Python 3.3 28 | * add support for Django 2.0 29 | 30 | Release *v1.1.2* - ``2017-09-20`` 31 | --------------------------------- 32 | * add ``__all__`` at package root 33 | * supports Django 1.8, 1.9, 1.10 and 1.11 34 | * follow the latests django/python support matrix 35 | * tested on travis via tox (tests, pep8 validation and code coverage) 36 | 37 | Release *v1.1.1* - ``2016-11-03`` 38 | --------------------------------- 39 | * make ``OrderedChoices`` available at the package root 40 | 41 | Release *v1.1* - ``2016-11-03`` 42 | ------------------------------- 43 | * add the ``extract_subset`` method 44 | * add the ``OrderedChoices`` subclass 45 | * add support for Django 1.10 46 | * drop retro-compatibility support 47 | 48 | Release *v1.0.7* - ``2016-03-13`` 49 | --------------------------------- 50 | * ensure falsy values are considered as so 51 | 52 | Release *v1.0.6* - ``2016-02-12`` 53 | --------------------------------- 54 | * add compatibility with "display" values set using ``ugettext_lazy`` 55 | 56 | Release *v1.0.5* - ``2015-10-14`` 57 | --------------------------------- 58 | * add compatibility with the ``pickle`` module 59 | 60 | Release *v1.0.4* - ``2015-05-05`` 61 | --------------------------------- 62 | * explicitly raise ``ValueError`` when using ``None`` for constant, value or display name. 63 | 64 | Release *v1.0.3* - ``2015-05-05`` 65 | --------------------------------- 66 | * make it work again with Django ``ugettext_lazy`` 67 | * remove support for Django 1.4 68 | 69 | Release *v1.0.2* - ``2015-05-02`` 70 | --------------------------------- 71 | * change License from GPL to BSD 72 | 73 | Release *v1.0* - ``2015-05-01`` 74 | ------------------------------- 75 | * full rewrite 76 | * new API 77 | * still compatible 0.4.1 78 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-extended-choices.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-extended-choices.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-extended-choices" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-extended-choices" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-extended-choices documentation build configuration file, created by 4 | # sphinx-quickstart on Sat May 2 18:38:53 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | DIRNAME = os.path.dirname(__file__) 22 | sys.path.insert(0, os.path.abspath(os.path.join(DIRNAME, '..', '..'))) 23 | import sphinx_rtd_theme 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.viewcode', 37 | 'sphinxcontrib.napoleon', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = u'django-extended-choices' 54 | copyright = u'2015, Stephane "Twidi" Angel' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = '1.0' 62 | # The full version, including alpha/beta/rc tags. 63 | release = '1.0' 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | #language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | #today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | #today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = [] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all 80 | # documents. 81 | #default_role = None 82 | 83 | # If true, '()' will be appended to :func: etc. cross-reference text. 84 | #add_function_parentheses = True 85 | 86 | # If true, the current module name will be prepended to all description 87 | # unit titles (such as .. function::). 88 | #add_module_names = True 89 | 90 | # If true, sectionauthor and moduleauthor directives will be shown in the 91 | # output. They are ignored by default. 92 | #show_authors = False 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = 'sphinx' 96 | 97 | # A list of ignored prefixes for module index sorting. 98 | #modindex_common_prefix = [] 99 | 100 | # If true, keep warnings as "system message" paragraphs in the built documents. 101 | #keep_warnings = False 102 | 103 | 104 | # -- Options for HTML output ---------------------------------------------- 105 | 106 | # The theme to use for HTML and HTML Help pages. See the documentation for 107 | # a list of builtin themes. 108 | #html_theme = 'default' 109 | html_theme = "sphinx_rtd_theme" 110 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | #html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | #html_theme_path = [] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | #html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | #html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | #html_logo = None 130 | 131 | # The name of an image file (within the static path) to use as favicon of the 132 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | #html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | html_static_path = ['_static'] 140 | 141 | # Add any extra paths that contain custom files (such as robots.txt or 142 | # .htaccess) here, relative to this directory. These files are copied 143 | # directly to the root of the documentation. 144 | #html_extra_path = [] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | #html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | #html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | #html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | #html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | #html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | #html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | #html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | #html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | #html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | #html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | #html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | #html_file_suffix = None 186 | 187 | # Output file base name for HTML help builder. 188 | htmlhelp_basename = 'django-extended-choicesdoc' 189 | 190 | 191 | # -- Options for LaTeX output --------------------------------------------- 192 | 193 | latex_elements = { 194 | # The paper size ('letterpaper' or 'a4paper'). 195 | #'papersize': 'letterpaper', 196 | 197 | # The font size ('10pt', '11pt' or '12pt'). 198 | #'pointsize': '10pt', 199 | 200 | # Additional stuff for the LaTeX preamble. 201 | #'preamble': '', 202 | } 203 | 204 | # Grouping the document tree into LaTeX files. List of tuples 205 | # (source start file, target name, title, 206 | # author, documentclass [howto, manual, or own class]). 207 | latex_documents = [ 208 | ('index', 'django-extended-choices.tex', u'django-extended-choices Documentation', 209 | u'Stephane "Twidi" Angel', 'manual'), 210 | ] 211 | 212 | # The name of an image file (relative to this directory) to place at the top of 213 | # the title page. 214 | #latex_logo = None 215 | 216 | # For "manual" documents, if this is true, then toplevel headings are parts, 217 | # not chapters. 218 | #latex_use_parts = False 219 | 220 | # If true, show page references after internal links. 221 | #latex_show_pagerefs = False 222 | 223 | # If true, show URL addresses after external links. 224 | #latex_show_urls = False 225 | 226 | # Documents to append as an appendix to all manuals. 227 | #latex_appendices = [] 228 | 229 | # If false, no module index is generated. 230 | #latex_domain_indices = True 231 | 232 | 233 | # -- Options for manual page output --------------------------------------- 234 | 235 | # One entry per manual page. List of tuples 236 | # (source start file, name, description, authors, manual section). 237 | man_pages = [ 238 | ('index', 'django-extended-choices', u'django-extended-choices Documentation', 239 | [u'Stephane "Twidi" Angel'], 1) 240 | ] 241 | 242 | # If true, show URL addresses after external links. 243 | #man_show_urls = False 244 | 245 | 246 | # -- Options for Texinfo output ------------------------------------------- 247 | 248 | # Grouping the document tree into Texinfo files. List of tuples 249 | # (source start file, target name, title, author, 250 | # dir menu entry, description, category) 251 | texinfo_documents = [ 252 | ('index', 'django-extended-choices', u'django-extended-choices Documentation', 253 | u'Stephane "Twidi" Angel', 'django-extended-choices', 'One line description of project.', 254 | 'Miscellaneous'), 255 | ] 256 | 257 | # Documents to append as an appendix to all manuals. 258 | #texinfo_appendices = [] 259 | 260 | # If false, no module index is generated. 261 | #texinfo_domain_indices = True 262 | 263 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 264 | #texinfo_show_urls = 'footnote' 265 | 266 | # If true, do not generate a @detailmenu in the "Top" node's menu. 267 | #texinfo_no_detailmenu = False 268 | -------------------------------------------------------------------------------- /extended_choices/helpers.py: -------------------------------------------------------------------------------- 1 | """Provides classes used to construct a full ``Choices`` instance. 2 | 3 | Notes 4 | ----- 5 | 6 | The documentation format in this file is numpydoc_. 7 | 8 | .. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt 9 | 10 | """ 11 | 12 | from __future__ import unicode_literals 13 | 14 | try: 15 | from collections.abc import Mapping 16 | except ImportError: 17 | from collections import Mapping 18 | 19 | from django.utils.functional import Promise 20 | 21 | 22 | class ChoiceAttributeMixin(object): 23 | """Base class to represent an attribute of a ``ChoiceEntry``. 24 | 25 | Used for ``constant``, ``name``, and ``display``. 26 | 27 | It must be used as a mixin with another type, and the final class will be a type with 28 | added attributes to access the ``ChoiceEntry`` instance and its attributes. 29 | 30 | Attributes 31 | ---------- 32 | choice_entry : instance of ``ChoiceEntry`` 33 | The ``ChoiceEntry`` instance that hold the current value, used to access its constant, 34 | value and display name. 35 | constant : property 36 | Returns the choice field holding the constant of the attached ``ChoiceEntry``. 37 | value : property 38 | Returns the choice field holding the value of the attached ``ChoiceEntry``. 39 | display : property 40 | Returns the choice field holding the display name of the attached ``ChoiceEntry``. 41 | original_value : ? 42 | The value used to create the current instance. 43 | creator_type : type 44 | The class that created a new class. Will be ``ChoiceAttributeMixin`` except if it was 45 | overridden by the author. 46 | 47 | Example 48 | ------- 49 | 50 | Classes can be created manually: 51 | 52 | >>> class IntChoiceAttribute(ChoiceAttributeMixin, int): pass 53 | >>> field = IntChoiceAttribute(1, ChoiceEntry(('FOO', 1, 'foo'))) 54 | >>> field 55 | 1 56 | >>> field.constant, field.value, field.display 57 | ('FOO', 1, 'foo') 58 | >>> field.choice_entry 59 | ('FOO', 1, 'foo') 60 | 61 | Or via the ``get_class_for_value`` class method: 62 | 63 | >>> klass = ChoiceAttributeMixin.get_class_for_value(1.5) 64 | >>> klass.__name__ 65 | 'FloatChoiceAttribute' 66 | >>> float in klass.mro() 67 | True 68 | 69 | """ 70 | 71 | def __new__(cls, *args, **kwargs): # pylint: disable=unused-argument 72 | """Construct the object (the other class used with this mixin). 73 | 74 | Notes 75 | ----- 76 | 77 | Only passes the very first argument to the ``super`` constructor. 78 | All others are not needed for the other class, only for this mixin. 79 | 80 | """ 81 | 82 | if issubclass(cls, Promise): 83 | # Special case to manage lazy django stuff like ugettext_lazy 84 | return super(ChoiceAttributeMixin, cls).__new__(cls) 85 | 86 | return super(ChoiceAttributeMixin, cls).__new__(cls, *args[:1]) 87 | 88 | def __init__(self, value, choice_entry): 89 | """Initiate the object to save the value and the choice entry. 90 | 91 | Parameters 92 | ---------- 93 | value : ? 94 | Value to pass to the ``super`` constructor (for the other class using this mixin) 95 | choice_entry: ChoiceEntry 96 | The ``ChoiceEntry`` instance that hold the current value, used to access its constant, 97 | value and display name. 98 | 99 | Notes 100 | ----- 101 | 102 | Call the ``super`` constructor with only the first value, as the other class doesn't 103 | expect the ``choice_entry`` parameter. 104 | 105 | """ 106 | 107 | if isinstance(self, Promise): 108 | # Special case to manage lazy django stuff like ugettext_lazy 109 | # pylint: disable=protected-access 110 | super(ChoiceAttributeMixin, self).__init__(value._proxy____args, value._proxy____kw) 111 | else: 112 | super(ChoiceAttributeMixin, self).__init__() 113 | 114 | self.original_value = value 115 | self.choice_entry = choice_entry 116 | if self.choice_entry.attributes: 117 | for key, value in self.choice_entry.attributes.items(): 118 | setattr(self, key, value) 119 | 120 | @property 121 | def constant(self): 122 | """Property that returns the ``constant`` attribute of the attached ``ChoiceEntry``.""" 123 | return self.choice_entry.constant 124 | 125 | @property 126 | def value(self): 127 | """Property that returns the ``value`` attribute of the attached ``ChoiceEntry``.""" 128 | return self.choice_entry.value 129 | 130 | @property 131 | def display(self): 132 | """Property that returns the ``display`` attribute of the attached ``ChoiceEntry``.""" 133 | return self.choice_entry.display 134 | 135 | @classmethod 136 | def get_class_for_value(cls, value): 137 | """Class method to construct a class based on this mixin and the type of the given value. 138 | 139 | Parameters 140 | ---------- 141 | value: ? 142 | The value from which to extract the type to create the new class. 143 | 144 | Notes 145 | ----- 146 | The create classes are cached (in ``cls.__classes_by_type``) to avoid recreating already 147 | created classes. 148 | """ 149 | type_ = value.__class__ 150 | 151 | # Check if the type is already a ``ChoiceAttribute`` 152 | if issubclass(type_, ChoiceAttributeMixin): 153 | # In this case we can return this type 154 | return type_ 155 | 156 | # Create a new class only if it wasn't already created for this type. 157 | if type_ not in cls._classes_by_type: 158 | # Compute the name of the class with the name of the type. 159 | class_name = str('%sChoiceAttribute' % type_.__name__.capitalize()) 160 | # Create a new class and save it in the cache. 161 | cls._classes_by_type[type_] = type(class_name, (cls, type_), { 162 | 'creator_type': cls, 163 | }) 164 | 165 | # Return the class from the cache based on the type. 166 | return cls._classes_by_type[type_] 167 | 168 | def __reduce__(self): 169 | """Reducer to make the auto-created classes picklable. 170 | 171 | Returns 172 | ------- 173 | tuple 174 | A tuple as expected by pickle, to recreate the object when calling ``pickle.loads``: 175 | 1. a callable to recreate the object 176 | 2. a tuple with all positioned arguments expected by this callable 177 | 178 | """ 179 | 180 | return ( 181 | # Function to create a choice attribute 182 | create_choice_attribute, 183 | ( 184 | # The class that created the class of the current value 185 | self.creator_type, 186 | # The original type of the current value 187 | self.original_value, 188 | # The tied `choice_entry` 189 | self.choice_entry 190 | ) 191 | ) 192 | 193 | def __bool__(self): 194 | """Use the original value to know if the value is truthy of falsy""" 195 | return bool(self.original_value) 196 | 197 | _classes_by_type = {} 198 | 199 | 200 | def create_choice_attribute(creator_type, value, choice_entry): 201 | """Create an instance of a subclass of ChoiceAttributeMixin for the given value. 202 | 203 | Parameters 204 | ---------- 205 | creator_type : type 206 | ``ChoiceAttributeMixin`` or a subclass, from which we'll call the ``get_class_for_value`` 207 | class-method. 208 | value : ? 209 | The value for which we want to create an instance of a new subclass of ``creator_type``. 210 | choice_entry: ChoiceEntry 211 | The ``ChoiceEntry`` instance that hold the current value, used to access its constant, 212 | value and display name. 213 | 214 | Returns 215 | ------- 216 | ChoiceAttributeMixin 217 | An instance of a subclass of ``creator_type`` for the given value 218 | 219 | """ 220 | 221 | klass = creator_type.get_class_for_value(value) 222 | return klass(value, choice_entry) 223 | 224 | 225 | class ChoiceEntry(tuple): 226 | """Represents a choice in a ``Choices`` object, with easy access to its attribute. 227 | 228 | Expecting a tuple with three entries. (constant, value, display name), it will add three 229 | attributes to access then: ``constant``, ``value`` and ``display``. 230 | 231 | By passing a dict after these three first entries, in the tuple, it's also possible to 232 | add some other attributes to the ``ChoiceEntry` instance``. 233 | 234 | Parameters 235 | ---------- 236 | tuple_ : tuple 237 | A tuple with three entries, the name of the constant, the value, and the display name. 238 | A dict could be added as a fourth entry to add additional attributes. 239 | 240 | 241 | Example 242 | ------- 243 | 244 | >>> entry = ChoiceEntry(('FOO', 1, 'foo')) 245 | >>> entry 246 | ('FOO', 1, 'foo') 247 | >>> (entry.constant, entry.value, entry.display) 248 | ('FOO', 1, 'foo') 249 | >>> entry.choice 250 | (1, 'foo') 251 | 252 | You can also pass attributes to add to the instance to create: 253 | 254 | >>> entry = ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2})) 255 | >>> entry 256 | ('FOO', 1, 'foo') 257 | >>> entry.bar 258 | 1 259 | >>> entry.baz 260 | 2 261 | 262 | Raises 263 | ------ 264 | AssertionError 265 | If the number of entries in the tuple is not expected. Must be 3 or 4. 266 | 267 | """ 268 | 269 | # Allow to easily change the mixin to use in subclasses. 270 | ChoiceAttributeMixin = ChoiceAttributeMixin 271 | 272 | def __new__(cls, tuple_): 273 | """Construct the tuple with 3 entries, and save optional attributes from the 4th one.""" 274 | 275 | # Ensure we have exactly 3 entries in the tuple and an optional dict. 276 | assert 3 <= len(tuple_) <= 4, 'Invalid number of entries in %s' % (tuple_,) 277 | 278 | attributes = None 279 | if len(tuple_) == 4: 280 | attributes = tuple_[3] 281 | assert attributes is None or isinstance(attributes, Mapping), 'Last argument must be a dict-like object in %s' % (tuple_,) 282 | if attributes: 283 | for invalid_key in {'constant', 'value', 'display'}: 284 | assert invalid_key not in attributes, 'Additional attributes cannot contain one named "%s" in %s' % (invalid_key, tuple_,) 285 | 286 | # Call the ``tuple`` constructor with only the real tuple entries. 287 | obj = super(ChoiceEntry, cls).__new__(cls, tuple_[:3]) 288 | 289 | # Save all special attributes. 290 | # pylint: disable=protected-access 291 | obj.attributes = attributes 292 | obj.constant = obj._get_choice_attribute(tuple_[0]) 293 | obj.value = obj._get_choice_attribute(tuple_[1]) 294 | obj.display = obj._get_choice_attribute(tuple_[2]) 295 | 296 | # Add an attribute holding values as expected by django. 297 | obj.choice = (obj.value, obj.display) 298 | 299 | # Add additional attributes. 300 | if attributes: 301 | for key, value in attributes.items(): 302 | setattr(obj, key, value) 303 | 304 | return obj 305 | 306 | def _get_choice_attribute(self, value): 307 | """Get a choice attribute for the given value. 308 | 309 | Parameters 310 | ---------- 311 | value: ? 312 | The value for which we want a choice attribute. 313 | 314 | Returns 315 | ------- 316 | An instance of a class based on ``ChoiceAttributeMixin`` for the given value. 317 | 318 | Raises 319 | ------ 320 | ValueError 321 | If the value is None, as we cannot really subclass NoneType. 322 | 323 | """ 324 | 325 | if value is None: 326 | raise ValueError('Using `None` in a `Choices` object is not supported. You may ' 327 | 'use an empty string.') 328 | 329 | return create_choice_attribute(self.ChoiceAttributeMixin, value, self) 330 | 331 | def __reduce__(self): 332 | """Reducer to pass attributes when pickling. 333 | 334 | Returns 335 | ------- 336 | tuple 337 | A tuple as expected by pickle, to recreate the object when calling ``pickle.loads``: 338 | 1. a callable to recreate the object 339 | 2. a tuple with all positioned arguments expected by this callable 340 | 341 | """ 342 | 343 | return ( 344 | # The ``ChoiceEntry`` class, or a subclass, used to create the current instance 345 | self.__class__, 346 | # The original values of the tuple, and attributes (we pass a tuple as single argument) 347 | ( 348 | ( 349 | self.constant.original_value, 350 | self.value.original_value, 351 | self.display.original_value, 352 | self.attributes 353 | ), 354 | ) 355 | ) 356 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |PyPI Version| |Build Status| |Doc Status| 2 | 3 | django-extended-choices 4 | ======================= 5 | 6 | A little application to improve Django choices 7 | ---------------------------------------------- 8 | 9 | ``django-extended-choices`` aims to provide a better and more readable 10 | way of using choices_ in Django_. 11 | 12 | Installation 13 | ------------ 14 | 15 | You can install directly via pip (since version ``0.3``):: 16 | 17 | $ pip install django-extended-choices 18 | 19 | Or from the Github_ repository (``master`` branch by default):: 20 | 21 | $ git clone git://github.com/twidi/django-extended-choices.git 22 | $ cd django-extended-choices 23 | $ sudo python setup.py install 24 | 25 | Usage 26 | ----- 27 | 28 | The aim is to replace this: 29 | 30 | .. code-block:: python 31 | 32 | STATE_ONLINE = 1 33 | STATE_DRAFT = 2 34 | STATE_OFFLINE = 3 35 | 36 | STATE_CHOICES = ( 37 | (STATE_ONLINE, 'Online'), 38 | (STATE_DRAFT, 'Draft'), 39 | (STATE_OFFLINE, 'Offline'), 40 | ) 41 | 42 | STATE_DICT = dict(STATE_CHOICES) 43 | 44 | class Content(models.Model): 45 | title = models.CharField(max_length=255) 46 | content = models.TextField() 47 | state = models.PositiveSmallIntegerField(choices=STATE_CHOICES, default=STATE_DRAFT) 48 | 49 | def __unicode__(self): 50 | return u'Content "%s" (state=%s)' % (self.title, STATE_DICT[self.state]) 51 | 52 | print(Content.objects.filter(state=STATE_ONLINE)) 53 | 54 | by this: 55 | 56 | .. code-block:: python 57 | 58 | from extended_choices import Choices 59 | 60 | STATES = Choices( 61 | ('ONLINE', 1, 'Online'), 62 | ('DRAFT', 2, 'Draft'), 63 | ('OFFLINE', 3, 'Offline'), 64 | ) 65 | 66 | class Content(models.Model): 67 | title = models.CharField(max_length=255) 68 | content = models.TextField() 69 | state = models.PositiveSmallIntegerField(choices=STATES, default=STATES.DRAFT) 70 | 71 | def __unicode__(self): 72 | return u'Content "%s" (state=%s)' % (self.title, STATES.for_value(self.state).display) 73 | 74 | print(Content.objects.filter(state=STATES.ONLINE)) 75 | 76 | 77 | As you can see there is only one declaration for all states with, for each state, in order: 78 | 79 | * the pseudo-constant name which can be used (``STATES.ONLINE`` replaces the previous ``STATE_ONLINE``) 80 | * the value to use in the database - which could equally be a string 81 | * the name to be displayed - and you can wrap the text in ``ugettext_lazy()`` if you need i18n 82 | 83 | And then, you can use: 84 | 85 | * ``STATES``, or ``STATES.choices``, to use with ``choices=`` in fields declarations 86 | * ``STATES.for_constant(constant)``, to get the choice entry from the constant name 87 | * ``STATES.for_value(constant)``, to get the choice entry from the key used in database 88 | * ``STATES.for_display(constant)``, to get the choice entry from the displayable value (can be useful in some case) 89 | 90 | Each choice entry obtained by ``for_constant``, ``for_value`` and ``for_display`` return a tuple as 91 | given to the ``Choices`` constructor, but with additional attributes: 92 | 93 | .. code-block:: python 94 | 95 | >>> entry = STATES.for_constant('ONLINE') 96 | >>> entry == ('ONLINE', 1, 'Online') 97 | True 98 | >>> entry.constant 99 | 'ONLINE' 100 | >>> entry.value 101 | 1 102 | >>> entry.display 103 | 'Online' 104 | 105 | These attributes are chainable (with a weird example to see chainability): 106 | 107 | .. code-block:: python 108 | 109 | >>> entry.constant.value 110 | 1 111 | >>> entry.constant.value.value.display.constant.display 112 | 'Online' 113 | 114 | To allow this, we had to remove support for ``None`` values. Use empty strings instead. 115 | 116 | Note that constants can be accessed via a dict key (``STATES['ONLINE']`` for example) if 117 | you want to fight your IDE that may warn you about undefined attributes. 118 | 119 | 120 | You can check whether a value is in a ``Choices`` object directly: 121 | 122 | .. code-block:: python 123 | 124 | >>> 1 in STATES 125 | True 126 | >>> 42 in STATES 127 | False 128 | 129 | 130 | You can even iterate on a ``Choices`` objects to get choices as seen by Django: 131 | 132 | .. code-block:: python 133 | 134 | >>> for choice in STATES: 135 | ... print(choice) 136 | (1, 'Online') 137 | (2, 'Draft') 138 | (3, 'Offline') 139 | 140 | To get all choice entries as given to the ``Choices`` object, you can use the ``entries`` 141 | attribute: 142 | 143 | .. code-block:: python 144 | 145 | >>> for choice_entry in STATES.entries: 146 | ... print(choice_entry) 147 | ('ONLINE', 1, 'Online'), 148 | ('DRAFT', 2, 'Draft'), 149 | ('OFFLINE', 3, 'Offline'), 150 | 151 | Or the following dicts, using constants, values or display names, as keys, and the matching 152 | choice entry as values: 153 | 154 | * ``STATES.constants`` 155 | * ``STATES.values`` 156 | * ``STATES.displays`` 157 | 158 | 159 | .. code-block:: python 160 | 161 | >>> STATES.constants['ONLINE'] is STATES.for_constant('ONLINE') 162 | True 163 | >>> STATES.values[2] is STATES.for_value(2) 164 | True 165 | >>> STATES.displays['Offline'] is STATES.for_display('Offline') 166 | True 167 | 168 | 169 | If you want these dicts to be ordered, you can pass the dict class to use to the 170 | ``Choices`` constructor: 171 | 172 | .. code-block:: python 173 | 174 | from collections import OrderedDict 175 | STATES = Choices( 176 | ('ONLINE', 1, 'Online'), 177 | ('DRAFT', 2, 'Draft'), 178 | ('OFFLINE', 3, 'Offline'), 179 | dict_class = OrderedDict 180 | ) 181 | 182 | Since version ``1.1``, the new ``OrderedChoices`` class is provided, that is exactly that: 183 | a ``Choices`` using ``OrderedDict`` by default for ``dict_class``. You can directly import 184 | it from ``extended_choices``. 185 | 186 | You can check if a constant, value, or display name exists: 187 | 188 | .. code-block:: python 189 | 190 | >>> STATES.has_constant('ONLINE') 191 | True 192 | >>> STATES.has_value(1) 193 | True 194 | >>> STATES.has_display('Online') 195 | True 196 | 197 | You can create subsets of choices within the same ``Choices`` instance: 198 | 199 | .. code-block:: python 200 | 201 | >>> STATES.add_subset('NOT_ONLINE', ('DRAFT', 'OFFLINE',)) 202 | >>> STATES.NOT_ONLINE 203 | (2, 'Draft') 204 | (3, 'Offline') 205 | 206 | Now, ``STATES.NOT_ONLINE`` is a real ``Choices`` instance, with a subset of the main ``STATES`` 207 | constants. 208 | 209 | You can use it to generate choices for when you only want a subset of choices available: 210 | 211 | .. code-block:: python 212 | 213 | offline_state = models.PositiveSmallIntegerField( 214 | choices=STATES.NOT_ONLINE, 215 | default=STATES.DRAFT 216 | ) 217 | 218 | As the subset is a real ``Choices`` instance, you have the same attributes and methods: 219 | 220 | .. code-block:: python 221 | 222 | >>> STATES.NOT_ONLINE.for_constant('OFFLINE').value 223 | 3 224 | >>> STATES.NOT_ONLINE.for_value(1).constant 225 | Traceback (most recent call last): 226 | ... 227 | KeyError: 3 228 | >>> list(STATES.NOT_ONLINE.constants.keys()) 229 | ['DRAFT', 'OFFLINE'] 230 | >>> STATES.NOT_ONLINE.has_display('Online') 231 | False 232 | 233 | You can create as many subsets as you want, reusing the same constants if needed: 234 | 235 | .. code-block:: python 236 | 237 | STATES.add_subset('NOT_OFFLINE', ('ONLINE', 'DRAFT')) 238 | 239 | If you want to check membership in a subset you could do: 240 | 241 | .. code-block:: python 242 | 243 | def is_online(self): 244 | # it's an example, we could have just tested with STATES.ONLINE 245 | return self.state not in STATES.NOT_ONLINE 246 | 247 | 248 | If you want to filter a queryset on values from a subset, you can use ``values``, but as ``values`` is a dict, ``keys()`` must be user: 249 | 250 | .. code-block:: python 251 | 252 | Content.objects.filter(state__in=STATES.NOT_ONLINE.values.keys()) 253 | 254 | You can add choice entries in many steps using ``add_choices``, possibly creating subsets at 255 | the same time. 256 | 257 | To construct the same ``Choices`` as before, we could have done: 258 | 259 | .. code-block:: python 260 | 261 | STATES = Choices() 262 | STATES.add_choices( 263 | ('ONLINE', 1, 'Online') 264 | ) 265 | STATES.add_choices( 266 | ('DRAFT', 2, 'Draft'), 267 | ('OFFLINE', 3, 'Offline'), 268 | name='NOT_ONLINE' 269 | ) 270 | 271 | You can also pass the ``argument`` to the ``Choices`` constructor to create a subset with all 272 | the choices entries added at the same time (it will call ``add_choices`` with the name and the 273 | entries) 274 | 275 | The list of existing subset names is in the ``subsets`` attributes of the parent ``Choices`` 276 | object. 277 | 278 | If you want a subset of the choices but not save it in the original ``Choices`` object, you can 279 | use ``extract_subset`` instead of ``add_subset`` 280 | 281 | .. code-block:: python 282 | 283 | >>> subset = STATES.extract_subset('DRAFT', 'OFFLINE') 284 | >>> subset 285 | (2, 'Draft') 286 | (3, 'Offline') 287 | 288 | 289 | As for a subset created by ``add_subset``, you have a real ``Choices`` object, but not accessible 290 | from the original ``Choices`` object. 291 | 292 | Note that in ``extract_subset``, you pass the strings directly, not in a list/tuple as for the 293 | second argument of ``add_subset``. 294 | 295 | Additional attributes 296 | --------------------- 297 | 298 | Each tuple must contain three elements. But you can pass a dict as a fourth one and each entry of this dict will be saved as an attribute 299 | of the choice entry 300 | 301 | .. code-block:: python 302 | 303 | >>> PLANETS = Choices( 304 | ... ('EARTH', 'earth', 'Earth', {'color': 'blue'}), 305 | ... ('MARS', 'mars', 'Mars', {'color': 'red'}), 306 | ... ) 307 | >>> PLANETS.EARTH.color 308 | 'blue' 309 | 310 | 311 | Auto display/value 312 | ------------------ 313 | 314 | We provide two classes to eases the writing of your choices, attended you don't need translation on the display value. 315 | 316 | AutoChoices 317 | ''''''''''' 318 | 319 | It's the simpler and faster version: you just past constants and: 320 | 321 | - the value saved in database will be constant lower cased 322 | - the display value will be the constant with ``_`` replaced by spaces, and the first letter capitalized 323 | 324 | .. code-block:: python 325 | 326 | >>> from extended_choices import AutoChoices 327 | >>> PLANETS = AutoChoices('EARTH', 'MARS') 328 | >>> PLANETS.EARTH.value 329 | 'earth' 330 | >>> PLANETS.MARS.display 331 | 'Mars' 332 | 333 | If you want to pass additional attributes, pass a tuple with the dict as a last element: 334 | 335 | 336 | .. code-block:: python 337 | 338 | >>> PLANETS = AutoChoices( 339 | ... ('EARTH', {'color': 'blue'}), 340 | ... ('MARS', {'color': 'red'}), 341 | ... ) 342 | >>> PLANETS.EARTH.value 343 | 'earth' 344 | >>> PLANETS.EARTH.color 345 | 'blue' 346 | 347 | 348 | You can change the transform function used to convert the constant to the value to be saved and the display value, by passing 349 | ``value_transform`` and ``display_transform`` functions to the constructor. 350 | 351 | .. code-block:: python 352 | 353 | >>> PLANETS = AutoChoices( 354 | ... 'EARTH', 'MARS', 355 | ... value_transform=lambda const: 'planet_' + const.lower(). 356 | ... display_transform=lambda const: 'Planet: ' + const.lower(). 357 | ... ) 358 | >>> PLANETS.EARTH.value 359 | 'planet_earth' 360 | >>> PLANETS.MARS.display 361 | 'Planet: mars' 362 | 363 | 364 | If you find yourself repeting these transform functions you can have a base class that defines these function, as class attributes: 365 | 366 | .. code-block:: python 367 | 368 | >>> class MyAutoChoices(AutoChoices): 369 | ... value_transform=staticmethod(lambda const: const.upper()) 370 | ... display_transform=staticmethod(lambda const: const.lower()) 371 | 372 | >>> PLANETS = MyAutoChoices('EARTH', 'MARS') 373 | >>> PLANETS.EARTH.value 374 | 'EARTH' 375 | >>> PLANETS.MARS.dispay 376 | 'mars' 377 | 378 | Of course you can still override the functions by passing them to the constructor. 379 | 380 | If you want, for an entry, force a specific value, you can do it by simply passing it as a second argument: 381 | 382 | .. code-block:: python 383 | 384 | >>> PLANETS = AutoChoices( 385 | ... 'EARTH', 386 | ... ('MARS', 'red-planet'), 387 | ... ) 388 | >>> PLANETS.MARS.value 389 | 'red-planet' 390 | 391 | And then if you want to set the display, pass a third one: 392 | 393 | .. code-block:: python 394 | 395 | >>> PLANETS = AutoChoices( 396 | ... 'EARTH', 397 | ... ('MARS', 'red-planet', 'Red planet'), 398 | ... ) 399 | >>> PLANETS.MARS.value 400 | 'red-planet' 401 | >>> PLANETS.MARS.display 402 | 'Red planet' 403 | 404 | 405 | To force a display value but let the db value to be automatically computed, use ``None`` for the second argument: 406 | 407 | .. code-block:: python 408 | 409 | >>> PLANETS = AutoChoices( 410 | ... 'EARTH', 411 | ... ('MARS', None, 'Red planet'), 412 | ... ) 413 | >>> PLANETS.MARS.value 414 | 'mars' 415 | >>> PLANETS.MARS.display 416 | 'Red planet' 417 | 418 | 419 | AutoDisplayChoices 420 | '''''''''''''''''' 421 | 422 | In this version, you have to define the value to save in database. The display value will be composed like in ``AutoChoices`` 423 | 424 | .. code-block:: python 425 | 426 | >>> from extended_choices import AutoDisplayChoices 427 | >>> PLANETS = AutoDisplayChoices( 428 | ... ('EARTH', 1), 429 | ... ('MARS', 2), 430 | ... ) 431 | >>> PLANETS.EARTH.value 432 | 1 433 | >>> PLANETS.MARS.display 434 | 'Mars' 435 | 436 | If you want to pass additional attributes, pass a tuple with the dict as a last element: 437 | 438 | 439 | .. code-block:: python 440 | 441 | >>> PLANETS = AutoDisplayChoices( 442 | ... ('EARTH', 'earth', {'color': 'blue'}), 443 | ... ('MARS', 'mars', {'color': 'red'}), 444 | ... ) 445 | >>> PLANETS.EARTH.value 446 | 1 447 | >>> PLANETS.EARTH.display 448 | 'Earth' 449 | >>> PLANETS.EARTH.color 450 | 'blue' 451 | 452 | 453 | As in ``AutoChoices``, you can change the transform function for the value to display by passing ``display_transform`` to the 454 | constructor. 455 | 456 | If you want, for an entry, force a specific display, you can do it by simply passing it as a third argument: 457 | 458 | .. code-block:: python 459 | 460 | >>> PLANETS = AutoChoices( 461 | ... ('EARTH', 1), 462 | ... ('MARS', 2, 'Red planet'), 463 | ... ) 464 | >>> PLANETS.MARS.display 465 | 'Red planet' 466 | 467 | Notes 468 | ----- 469 | 470 | * You also have a very basic field (``NamedExtendedChoiceFormField```) in ``extended_choices.fields`` which accept constant names instead of values 471 | * Feel free to read the source to learn more about this little Django app. 472 | * You can declare your choices where you want. My usage is in the ``models.py`` file, just before the class declaration. 473 | 474 | Compatibility 475 | ------------- 476 | 477 | The version ``1.0`` provided a totally new API, and compatibility with the previous one 478 | (``0.4.1``) was removed in ``1.1``. The last version with the compatibility was ``1.0.7``. 479 | 480 | If you need this compatibility, you can use a specific version by pinning it in your requirements. 481 | 482 | License 483 | ------- 484 | 485 | Available under the BSD_ License. See the ``LICENSE`` file included 486 | 487 | Python/Django versions support 488 | ------------------------------ 489 | 490 | 491 | +----------------+-------------------------------------------------+ 492 | | Django version | Python versions | 493 | +----------------+-------------------------------------------------+ 494 | | 1.8, 1.9, 1.10 | 2.7, 3.4, 3.5 | 495 | +----------------+-------------------------------------------------+ 496 | | 1.11 | 2.7, 3.4, 3.5, 3.6 | 497 | +----------------+-------------------------------------------------+ 498 | | 2.0 | 3.4, 3.5, 3.6, 3.7 | 499 | +----------------+-------------------------------------------------+ 500 | | 2.1, 2.2 | 3.5, 3.6, 3.7 | 501 | +----------------+-------------------------------------------------+ 502 | 503 | 504 | Tests 505 | ----- 506 | 507 | To run tests from the code source, create a virtualenv or activate one, install Django, then:: 508 | 509 | python -m extended_choices.tests 510 | 511 | 512 | We also provides some quick doctests in the code documentation. To execute them:: 513 | 514 | python -m extended_choices 515 | 516 | 517 | Note: the doctests will work only in python version not display `u` prefix for strings. 518 | 519 | 520 | Source code 521 | ----------- 522 | 523 | The source code is available on Github_. 524 | 525 | 526 | Developing 527 | ---------- 528 | 529 | If you want to participate in the development of this library, you'll need ``Django`` 530 | installed in your virtualenv. If you don't have it, simply run:: 531 | 532 | pip install -r requirements-dev.txt 533 | 534 | Don't forget to run the tests ;) 535 | 536 | Feel free to propose a pull request on Github_! 537 | 538 | A few minutes after your pull request, tests will be executed on TravisCi_ for all the versions 539 | of python and Django we support. 540 | 541 | 542 | Documentation 543 | ------------- 544 | 545 | You can find the documentation on ReadTheDoc_ 546 | 547 | To update the documentation, you'll need some tools:: 548 | 549 | pip install -r requirements-makedoc.txt 550 | 551 | Then go to the ``docs`` directory, and run:: 552 | 553 | make html 554 | 555 | Author 556 | ------ 557 | Written by Stephane "Twidi" Angel (http://twidi.com), originally for http://www.liberation.fr 558 | 559 | .. _choices: http://docs.djangoproject.com/en/1.5/ref/models/fields/#choices 560 | .. _Django: http://www.djangoproject.com/ 561 | .. _Github: https://github.com/twidi/django-extended-choices 562 | .. _TravisCi: https://travis-ci.org/twidi/django-extended-choices/pull_requests 563 | .. _ReadTheDoc: http://django-extended-choices.readthedocs.org 564 | .. _BSD: http://opensource.org/licenses/BSD-3-Clause 565 | 566 | .. |PyPI Version| image:: https://img.shields.io/pypi/v/django-extended-choices.png 567 | :target: https://pypi.python.org/pypi/django-extended-choices 568 | :alt: PyPI Version 569 | .. |Build Status| image:: https://travis-ci.org/twidi/django-extended-choices.png 570 | :target: https://travis-ci.org/twidi/django-extended-choices 571 | :alt: Build Status on Travis CI 572 | .. |Doc Status| image:: https://readthedocs.org/projects/django-extended-choices/badge/?version=latest 573 | :target: http://django-extended-choices.readthedocs.org 574 | :alt: Documentation Status on ReadTheDoc 575 | 576 | .. image:: https://d2weczhvl823v0.cloudfront.net/twidi/django-extended-choices/trend.png 577 | :alt: Bitdeli badge 578 | :target: https://bitdeli.com/free 579 | -------------------------------------------------------------------------------- /extended_choices/choices.py: -------------------------------------------------------------------------------- 1 | """Provides a ``Choices`` class to help using "choices" in Django fields. 2 | 3 | The aim is to replace: 4 | 5 | .. code-block:: python 6 | 7 | STATE_ONLINE = 1 8 | STATE_DRAFT = 2 9 | STATE_OFFLINE = 3 10 | 11 | STATE_CHOICES = ( 12 | (STATE_ONLINE, 'Online'), 13 | (STATE_DRAFT, 'Draft'), 14 | (STATE_OFFLINE, 'Offline'), 15 | ) 16 | 17 | STATE_DICT = dict(STATE_CHOICES) 18 | 19 | class Content(models.Model): 20 | title = models.CharField(max_length=255) 21 | content = models.TextField() 22 | state = models.PositiveSmallIntegerField(choices=STATE_CHOICES, default=STATE_DRAFT) 23 | 24 | def __unicode__(self): 25 | return 'Content "%s" (state=%s)' % (self.title, STATE_DICT[self.state]) 26 | 27 | print(Content.objects.filter(state=STATE_ONLINE)) 28 | 29 | By this: 30 | 31 | .. code-block:: python 32 | 33 | from extended_choices import Choices 34 | 35 | STATES = Choices( 36 | ('ONLINE', 1, 'Online'), 37 | ('DRAFT', 2, 'Draft'), 38 | ('OFFLINE', 3, 'Offline'), 39 | ) 40 | 41 | class Content(models.Model): 42 | title = models.CharField(max_length=255) 43 | content = models.TextField() 44 | state = models.PositiveSmallIntegerField(choices=STATES, default=STATES.DRAFT) 45 | 46 | def __unicode__(self): 47 | return 'Content "%s" (state=%s)' % (self.title, STATES.for_value(self.state).display) 48 | 49 | print(Content.objects.filter(state=STATES.ONLINE)) 50 | 51 | 52 | Notes 53 | ----- 54 | 55 | The documentation format in this file is numpydoc_. 56 | 57 | .. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt 58 | 59 | """ 60 | 61 | from __future__ import unicode_literals 62 | import six 63 | 64 | from collections import OrderedDict 65 | try: 66 | from collections.abc import Mapping 67 | except ImportError: 68 | from collections import Mapping 69 | 70 | from .helpers import ChoiceEntry 71 | 72 | __all__ = [ 73 | 'Choices', 74 | 'OrderedChoices', 75 | 'AutoDisplayChoices', 76 | 'AutoChoices', 77 | ] 78 | 79 | _NO_SUBSET_NAME_ = '__NO_SUBSET_NAME__' 80 | 81 | 82 | class Choices(list): 83 | """Helper class for choices fields in Django 84 | 85 | A choice entry has three representation: constant name, value and 86 | display name). So ``Choices`` takes list of such tuples. 87 | 88 | It's easy to get the constant, value or display name given one of these value. See in 89 | example. 90 | 91 | Parameters 92 | ---------- 93 | *choices : list of tuples 94 | It's the list of tuples to add to the ``Choices`` instance, each tuple having three 95 | entries: the constant name, the value, the display name. 96 | 97 | A dict could be added as a 4th entry in the tuple to allow setting arbitrary 98 | arguments to the final ``ChoiceEntry`` created for this choice tuple. 99 | 100 | name : string, optional 101 | If set, a subset will be created containing all the constants. It could be used if you 102 | construct your ``Choices`` instance with many calls to ``add_choices``. 103 | dict_class : type, optional 104 | ``dict`` by default, it's the dict class to use to create dictionaries (``constants``, 105 | ``values`` and ``displays``. Could be set for example to ``OrderedDict`` (you can use 106 | ``OrderedChoices`` that is a simple subclass using ``OrderedDict``. 107 | 108 | Example 109 | ------- 110 | 111 | Start by declaring your ``Choices``: 112 | 113 | >>> ALIGNMENTS = Choices( 114 | ... ('BAD', 10, 'bad'), 115 | ... ('NEUTRAL', 20, 'neutral'), 116 | ... ('CHAOTIC_GOOD', 30, 'chaotic good'), 117 | ... ('GOOD', 40, 'good'), 118 | ... dict_class=OrderedDict 119 | ... ) 120 | 121 | Then you can use it in a django field, Notice its usage in ``choices`` and ``default``: 122 | 123 | >>> from django.conf import settings 124 | >>> try: 125 | ... settings.configure(DATABASE_ENGINE='sqlite3') 126 | ... except: pass 127 | >>> from django.db.models import IntegerField 128 | >>> field = IntegerField(choices=ALIGNMENTS, # use ``ALIGNMENTS`` or ``ALIGNMENTS.choices``. 129 | ... default=ALIGNMENTS.NEUTRAL) 130 | 131 | The ``Choices`` returns a list as expected by django: 132 | 133 | >>> ALIGNMENTS == ((10, 'bad'), (20, 'neutral'), (30, 'chaotic good'), (40, 'good')) 134 | True 135 | 136 | But represents it with the constants: 137 | 138 | >>> repr(ALIGNMENTS) 139 | "[('BAD', 10, 'bad'), ('NEUTRAL', 20, 'neutral'), ('CHAOTIC_GOOD', 30, 'chaotic good'), ('GOOD', 40, 'good')]" 140 | 141 | Use ``choices`` which is a simple list to represent it as such: 142 | 143 | >>> ALIGNMENTS.choices 144 | ((10, 'bad'), (20, 'neutral'), (30, 'chaotic good'), (40, 'good')) 145 | 146 | 147 | And you can access value by their constant, or as you want: 148 | 149 | >>> ALIGNMENTS.BAD 150 | 10 151 | >>> ALIGNMENTS.BAD.display 152 | 'bad' 153 | >>> 40 in ALIGNMENTS 154 | True 155 | >>> ALIGNMENTS.has_constant('BAD') 156 | True 157 | >>> ALIGNMENTS.has_value(20) 158 | True 159 | >>> ALIGNMENTS.has_display('good') 160 | True 161 | >>> ALIGNMENTS.for_value(10) 162 | ('BAD', 10, 'bad') 163 | >>> ALIGNMENTS.for_value(10).constant 164 | 'BAD' 165 | >>> ALIGNMENTS.for_display('good').value 166 | 40 167 | >>> ALIGNMENTS.for_constant('NEUTRAL').display 168 | 'neutral' 169 | >>> ALIGNMENTS.constants 170 | OrderedDict([('BAD', ('BAD', 10, 'bad')), ('NEUTRAL', ('NEUTRAL', 20, 'neutral')), ('CHAOTIC_GOOD', ('CHAOTIC_GOOD', 30, 'chaotic good')), ('GOOD', ('GOOD', 40, 'good'))]) 171 | >>> ALIGNMENTS.values 172 | OrderedDict([(10, ('BAD', 10, 'bad')), (20, ('NEUTRAL', 20, 'neutral')), (30, ('CHAOTIC_GOOD', 30, 'chaotic good')), (40, ('GOOD', 40, 'good'))]) 173 | >>> ALIGNMENTS.displays 174 | OrderedDict([('bad', ('BAD', 10, 'bad')), ('neutral', ('NEUTRAL', 20, 'neutral')), ('chaotic good', ('CHAOTIC_GOOD', 30, 'chaotic good')), ('good', ('GOOD', 40, 'good'))]) 175 | 176 | You can create subsets of choices: 177 | 178 | >>> ALIGNMENTS.add_subset('WESTERN',('BAD', 'GOOD')) 179 | >>> ALIGNMENTS.WESTERN.choices 180 | ((10, 'bad'), (40, 'good')) 181 | >>> ALIGNMENTS.BAD in ALIGNMENTS.WESTERN 182 | True 183 | >>> ALIGNMENTS.NEUTRAL in ALIGNMENTS.WESTERN 184 | False 185 | 186 | To use it in another field (only the values in the subset will be available), or for checks: 187 | 188 | >>> def is_western(value): 189 | ... return value in ALIGNMENTS.WESTERN 190 | >>> is_western(40) 191 | True 192 | 193 | """ 194 | 195 | # Allow to easily change the ``ChoiceEntry`` class to use in subclasses. 196 | ChoiceEntryClass = ChoiceEntry 197 | 198 | def __init__(self, *choices, **kwargs): 199 | 200 | # Init the list as empty. Entries will be formatted for django and added in 201 | # ``add_choices``. 202 | super(Choices, self).__init__() 203 | 204 | # Class to use for dicts. 205 | self.dict_class = kwargs.get('dict_class', dict) 206 | 207 | # List of ``ChoiceEntry``, one for each choice in this instance. 208 | self.entries = [] 209 | 210 | # List of the created subsets 211 | self.subsets = [] 212 | 213 | # Dicts to access ``ChoiceEntry`` instances by constant, value or display value. 214 | self.constants = self.dict_class() 215 | self.values = self.dict_class() 216 | self.displays = self.dict_class() 217 | 218 | # For now this instance is mutable: we need to add the given choices. 219 | self._mutable = True 220 | self.add_choices(*choices, name=kwargs.get('name', None)) 221 | 222 | # Now we can set ``_mutable`` to its correct value. 223 | self._mutable = kwargs.get('mutable', True) 224 | 225 | @property 226 | def choices(self): 227 | """Property that returns a tuple formatted as expected by Django. 228 | 229 | Example 230 | ------- 231 | 232 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 233 | >>> MY_CHOICES.choices 234 | ((1, 'foo'), (2, 'bar')) 235 | 236 | """ 237 | return tuple(self) 238 | 239 | def _convert_choices(self, choices): 240 | """Validate each choices 241 | 242 | Parameters 243 | ---------- 244 | choices : list of tuples 245 | The list of choices to be added 246 | 247 | Returns 248 | ------- 249 | list 250 | The list of the added constants 251 | 252 | """ 253 | 254 | # Check that each new constant is unique. 255 | constants = [c[0] for c in choices] 256 | constants_doubles = [c for c in constants if constants.count(c) > 1] 257 | if constants_doubles: 258 | raise ValueError("You cannot declare two constants with the same constant name. " 259 | "Problematic constants: %s " % list(set(constants_doubles))) 260 | 261 | # Check that none of the new constants already exists. 262 | bad_constants = set(constants).intersection(self.constants) 263 | if bad_constants: 264 | raise ValueError("You cannot add existing constants. " 265 | "Existing constants: %s." % list(bad_constants)) 266 | 267 | # Check that none of the constant is an existing attributes 268 | bad_constants = [c for c in constants if hasattr(self, c)] 269 | if bad_constants: 270 | raise ValueError("You cannot add constants that already exists as attributes. " 271 | "Existing attributes: %s." % list(bad_constants)) 272 | 273 | # Check that each new value is unique. 274 | values = [c[1] for c in choices] 275 | values_doubles = [c for c in values if values.count(c) > 1] 276 | if values_doubles: 277 | raise ValueError("You cannot declare two choices with the same name." 278 | "Problematic values: %s " % list(set(values_doubles))) 279 | 280 | # Check that none of the new values already exists. 281 | try: 282 | bad_values = set(values).intersection(self.values) 283 | except TypeError: 284 | raise ValueError("One value cannot be used in: %s" % list(values)) 285 | else: 286 | if bad_values: 287 | raise ValueError("You cannot add existing values. " 288 | "Existing values: %s." % list(bad_values)) 289 | 290 | # We can now add each choice. 291 | for choice_tuple in choices: 292 | 293 | # Convert the choice tuple in a ``ChoiceEntry`` instance if it's not already done. 294 | # It allows to share choice entries between a ``Choices`` instance and its subsets. 295 | choice_entry = choice_tuple 296 | if not isinstance(choice_entry, self.ChoiceEntryClass): 297 | choice_entry = self.ChoiceEntryClass(choice_entry) 298 | 299 | # Append to the main list the choice as expected by django: (value, display name). 300 | self.append(choice_entry.choice) 301 | # And the ``ChoiceEntry`` instance to our own internal list. 302 | self.entries.append(choice_entry) 303 | 304 | # Make the value accessible via an attribute (the constant being its name). 305 | setattr(self, choice_entry.constant, choice_entry.value) 306 | 307 | # Fill dicts to access the ``ChoiceEntry`` instance by its constant, value or display.. 308 | self.constants[choice_entry.constant] = choice_entry 309 | self.values[choice_entry.value] = choice_entry 310 | self.displays[choice_entry.display] = choice_entry 311 | 312 | return constants 313 | 314 | def add_choices(self, *choices, **kwargs): 315 | """Add some choices to the current ``Choices`` instance. 316 | 317 | The given choices will be added to the existing choices. 318 | If a ``name`` attribute is passed, a new subset will be created with all the given 319 | choices. 320 | 321 | Note that it's not possible to add new choices to a subset. 322 | 323 | Parameters 324 | ---------- 325 | *choices : list of tuples 326 | It's the list of tuples to add to the ``Choices`` instance, each tuple having three 327 | entries: the constant name, the value, the display name. 328 | 329 | A dict could be added as a 4th entry in the tuple to allow setting arbitrary 330 | arguments to the final ``ChoiceEntry`` created for this choice tuple. 331 | 332 | If the first entry of ``*choices`` is a string, then it will be used as a name for a 333 | new subset that will contain all the given choices. 334 | **kwargs : dict 335 | name : string 336 | Instead of using the first entry of the ``*choices`` to pass a name of a subset to 337 | create, you can pass it via the ``name`` named argument. 338 | 339 | Example 340 | ------- 341 | 342 | >>> MY_CHOICES = Choices() 343 | >>> MY_CHOICES.add_choices(('ZERO', 0, 'zero')) 344 | >>> MY_CHOICES 345 | [('ZERO', 0, 'zero')] 346 | >>> MY_CHOICES.add_choices('SMALL', ('ONE', 1, 'one'), ('TWO', 2, 'two')) 347 | >>> MY_CHOICES 348 | [('ZERO', 0, 'zero'), ('ONE', 1, 'one'), ('TWO', 2, 'two')] 349 | >>> MY_CHOICES.SMALL 350 | [('ONE', 1, 'one'), ('TWO', 2, 'two')] 351 | >>> MY_CHOICES.add_choices(('THREE', 3, 'three'), ('FOUR', 4, 'four'), name='BIG') 352 | >>> MY_CHOICES 353 | [('ZERO', 0, 'zero'), ('ONE', 1, 'one'), ('TWO', 2, 'two'), ('THREE', 3, 'three'), ('FOUR', 4, 'four')] 354 | >>> MY_CHOICES.BIG 355 | [('THREE', 3, 'three'), ('FOUR', 4, 'four')] 356 | 357 | Raises 358 | ------ 359 | RuntimeError 360 | When the ``Choices`` instance is marked as not mutable, which is the case for subsets. 361 | 362 | ValueError 363 | 364 | * if the subset name is defined as first argument and as named argument. 365 | * if some constants have the same name or the same value. 366 | * if at least one constant or value already exists in the instance. 367 | 368 | """ 369 | 370 | # It the ``_mutable`` flag is falsy, which is the case for subsets, we refuse to add 371 | # new choices. 372 | if not self._mutable: 373 | raise RuntimeError("This ``Choices`` instance cannot be updated.") 374 | 375 | # Check for an optional subset name as the first argument (so the first entry of *choices). 376 | subset_name = None 377 | if choices and isinstance(choices[0], six.string_types) and choices[0] != _NO_SUBSET_NAME_: 378 | subset_name = choices[0] 379 | choices = choices[1:] 380 | 381 | # Check for an optional subset name in the named arguments. 382 | if kwargs.get('name', None): 383 | if subset_name: 384 | raise ValueError("The name of the subset cannot be defined as the first " 385 | "argument and also as a named argument") 386 | subset_name = kwargs['name'] 387 | 388 | constants = self._convert_choices(choices) 389 | 390 | # If we have a subset name, create a new subset with all the given constants. 391 | if subset_name: 392 | self.add_subset(subset_name, constants) 393 | 394 | def extract_subset(self, *constants): 395 | """Create a subset of entries 396 | 397 | This subset is a new ``Choices`` instance, with only the wanted constants from the 398 | main ``Choices`` (each "choice entry" in the subset is shared from the main ``Choices``) 399 | 400 | Parameters 401 | ---------- 402 | *constants: list 403 | The constants names of this ``Choices`` object to make available in the subset. 404 | 405 | Returns 406 | ------- 407 | Choices 408 | The newly created subset, which is a ``Choices`` object 409 | 410 | 411 | Example 412 | ------- 413 | 414 | >>> STATES = Choices( 415 | ... ('ONLINE', 1, 'Online'), 416 | ... ('DRAFT', 2, 'Draft'), 417 | ... ('OFFLINE', 3, 'Offline'), 418 | ... ) 419 | >>> STATES 420 | [('ONLINE', 1, 'Online'), ('DRAFT', 2, 'Draft'), ('OFFLINE', 3, 'Offline')] 421 | >>> subset = STATES.extract_subset('DRAFT', 'OFFLINE') 422 | >>> subset 423 | [('DRAFT', 2, 'Draft'), ('OFFLINE', 3, 'Offline')] 424 | >>> subset.DRAFT 425 | 2 426 | >>> subset.for_constant('DRAFT') is STATES.for_constant('DRAFT') 427 | True 428 | >>> subset.ONLINE 429 | Traceback (most recent call last): 430 | ... 431 | AttributeError: 'Choices' object has no attribute 'ONLINE' 432 | 433 | 434 | Raises 435 | ------ 436 | ValueError 437 | If a constant is not defined as a constant in the ``Choices`` instance. 438 | 439 | """ 440 | 441 | # Ensure that all passed constants exists as such in the list of available constants. 442 | bad_constants = set(constants).difference(self.constants) 443 | if bad_constants: 444 | raise ValueError("All constants in subsets should be in parent choice. " 445 | "Missing constants: %s." % list(bad_constants)) 446 | 447 | # Keep only entries we asked for. 448 | choice_entries = [self.constants[c] for c in constants] 449 | 450 | # Create a new ``Choices`` instance with the limited set of entries, and pass the other 451 | # configuration attributes to share the same behavior as the current ``Choices``. 452 | # Also we set ``mutable`` to False to disable the possibility to add new choices to the 453 | # subset. 454 | subset = self.__class__( 455 | *choice_entries, 456 | **{ 457 | 'dict_class': self.dict_class, 458 | 'mutable': False, 459 | } 460 | ) 461 | 462 | return subset 463 | 464 | def add_subset(self, name, constants): 465 | """Add a subset of entries under a defined name. 466 | 467 | This allow to defined a "sub choice" if a django field need to not have the whole 468 | choice available. 469 | 470 | The sub-choice is a new ``Choices`` instance, with only the wanted the constant from the 471 | main ``Choices`` (each "choice entry" in the subset is shared from the main ``Choices``) 472 | The sub-choice is accessible from the main ``Choices`` by an attribute having the given 473 | name. 474 | 475 | Parameters 476 | ---------- 477 | name : string 478 | Name of the attribute that will old the new ``Choices`` instance. 479 | constants: list or tuple 480 | List of the constants name of this ``Choices`` object to make available in the subset. 481 | 482 | 483 | Returns 484 | ------- 485 | Choices 486 | The newly created subset, which is a ``Choices`` object 487 | 488 | 489 | Example 490 | ------- 491 | 492 | >>> STATES = Choices( 493 | ... ('ONLINE', 1, 'Online'), 494 | ... ('DRAFT', 2, 'Draft'), 495 | ... ('OFFLINE', 3, 'Offline'), 496 | ... ) 497 | >>> STATES 498 | [('ONLINE', 1, 'Online'), ('DRAFT', 2, 'Draft'), ('OFFLINE', 3, 'Offline')] 499 | >>> STATES.add_subset('NOT_ONLINE', ('DRAFT', 'OFFLINE',)) 500 | >>> STATES.NOT_ONLINE 501 | [('DRAFT', 2, 'Draft'), ('OFFLINE', 3, 'Offline')] 502 | >>> STATES.NOT_ONLINE.DRAFT 503 | 2 504 | >>> STATES.NOT_ONLINE.for_constant('DRAFT') is STATES.for_constant('DRAFT') 505 | True 506 | >>> STATES.NOT_ONLINE.ONLINE 507 | Traceback (most recent call last): 508 | ... 509 | AttributeError: 'Choices' object has no attribute 'ONLINE' 510 | 511 | 512 | Raises 513 | ------ 514 | ValueError 515 | 516 | * If ``name`` is already an attribute of the ``Choices`` instance. 517 | * If a constant is not defined as a constant in the ``Choices`` instance. 518 | 519 | """ 520 | 521 | # Ensure that the name is not already used as an attribute. 522 | if hasattr(self, name): 523 | raise ValueError("Cannot use '%s' as a subset name. " 524 | "It's already an attribute." % name) 525 | 526 | subset = self.extract_subset(*constants) 527 | 528 | # Make the subset accessible via an attribute. 529 | setattr(self, name, subset) 530 | self.subsets.append(name) 531 | 532 | def for_constant(self, constant): 533 | """Returns the ``ChoiceEntry`` for the given constant. 534 | 535 | Parameters 536 | ---------- 537 | constant: string 538 | Name of the constant for which we want the choice entry. 539 | 540 | Returns 541 | ------- 542 | ChoiceEntry 543 | The instance of ``ChoiceEntry`` for the given constant. 544 | 545 | Raises 546 | ------ 547 | KeyError 548 | If the constant is not an existing one. 549 | 550 | Example 551 | ------- 552 | 553 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 554 | >>> MY_CHOICES.for_constant('FOO') 555 | ('FOO', 1, 'foo') 556 | >>> MY_CHOICES.for_constant('FOO').value 557 | 1 558 | >>> MY_CHOICES.for_constant('QUX') 559 | Traceback (most recent call last): 560 | ... 561 | KeyError: 'QUX' 562 | 563 | """ 564 | 565 | return self.constants[constant] 566 | 567 | def for_value(self, value): 568 | """Returns the ``ChoiceEntry`` for the given value. 569 | 570 | Parameters 571 | ---------- 572 | value: ? 573 | Value for which we want the choice entry. 574 | 575 | Returns 576 | ------- 577 | ChoiceEntry 578 | The instance of ``ChoiceEntry`` for the given value. 579 | 580 | Raises 581 | ------ 582 | KeyError 583 | If the value is not an existing one. 584 | 585 | Example 586 | ------- 587 | 588 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 589 | >>> MY_CHOICES.for_value(1) 590 | ('FOO', 1, 'foo') 591 | >>> MY_CHOICES.for_value(1).display 592 | 'foo' 593 | >>> MY_CHOICES.for_value(3) 594 | Traceback (most recent call last): 595 | ... 596 | KeyError: 3 597 | 598 | """ 599 | 600 | return self.values[value] 601 | 602 | def for_display(self, display): 603 | """Returns the ``ChoiceEntry`` for the given display name. 604 | 605 | Parameters 606 | ---------- 607 | display: string 608 | Display name for which we want the choice entry. 609 | 610 | Returns 611 | ------- 612 | ChoiceEntry 613 | The instance of ``ChoiceEntry`` for the given display name. 614 | 615 | Raises 616 | ------ 617 | KeyError 618 | If the display name is not an existing one. 619 | 620 | Example 621 | ------- 622 | 623 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 624 | >>> MY_CHOICES.for_display('foo') 625 | ('FOO', 1, 'foo') 626 | >>> MY_CHOICES.for_display('foo').constant 627 | 'FOO' 628 | >>> MY_CHOICES.for_display('qux') 629 | Traceback (most recent call last): 630 | ... 631 | KeyError: 'qux' 632 | 633 | """ 634 | 635 | return self.displays[display] 636 | 637 | def has_constant(self, constant): 638 | """Check if the current ``Choices`` object has the given constant. 639 | 640 | Parameters 641 | ---------- 642 | constant: string 643 | Name of the constant we want to check.. 644 | 645 | Returns 646 | ------- 647 | boolean 648 | ``True`` if the constant is present, ``False`` otherwise. 649 | 650 | Example 651 | ------- 652 | 653 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 654 | >>> MY_CHOICES.has_constant('FOO') 655 | True 656 | >>> MY_CHOICES.has_constant('QUX') 657 | False 658 | 659 | """ 660 | 661 | return constant in self.constants 662 | 663 | def has_value(self, value): 664 | """Check if the current ``Choices`` object has the given value. 665 | 666 | Parameters 667 | ---------- 668 | value: ? 669 | Value we want to check. 670 | 671 | Returns 672 | ------- 673 | boolean 674 | ``True`` if the value is present, ``False`` otherwise. 675 | 676 | Example 677 | ------- 678 | 679 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 680 | >>> MY_CHOICES.has_value(1) 681 | True 682 | >>> MY_CHOICES.has_value(3) 683 | False 684 | 685 | """ 686 | 687 | return value in self.values 688 | 689 | def has_display(self, display): 690 | """Check if the current ``Choices`` object has the given display name. 691 | 692 | Parameters 693 | ---------- 694 | display: string 695 | Display name we want to check.. 696 | 697 | Returns 698 | ------- 699 | boolean 700 | ``True`` if the display name is present, ``False`` otherwise. 701 | 702 | Example 703 | ------- 704 | 705 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 706 | >>> MY_CHOICES.has_display('foo') 707 | True 708 | >>> MY_CHOICES.has_display('qux') 709 | False 710 | 711 | """ 712 | 713 | return display in self.displays 714 | 715 | def __contains__(self, item): 716 | """Check if the current ``Choices`` object has the given value. 717 | 718 | Example 719 | ------- 720 | 721 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 722 | >>> 1 in MY_CHOICES 723 | True 724 | >>> 3 in MY_CHOICES 725 | False 726 | 727 | """ 728 | 729 | return self.has_value(item) 730 | 731 | def __getitem__(self, key): 732 | """Return the attribute having the given name for the current instance 733 | 734 | It allows for example to retrieve constant by keys instead of by attributes (as constants 735 | are set as attributes to easily get the matching value.) 736 | 737 | Example 738 | ------- 739 | 740 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 741 | >>> MY_CHOICES['FOO'] 742 | 1 743 | >>> MY_CHOICES['constants'] is MY_CHOICES.constants 744 | True 745 | 746 | """ 747 | 748 | # If the key is an int, call ``super`` to access the list[key] item 749 | if isinstance(key, int): 750 | return super(Choices, self).__getitem__(key) 751 | 752 | if not hasattr(self, key): 753 | raise KeyError("Attribute '%s' not found." % key) 754 | 755 | return getattr(self, key) 756 | 757 | def __repr__(self): 758 | """String representation of this ``Choices`` instance. 759 | 760 | Notes 761 | ----- 762 | It will represent the data passed and store in ``self.entries``, not the data really 763 | stored in the base list object, which is in the format expected by django, ie a list of 764 | tuples with only value and display name. 765 | Here, we display everything. 766 | 767 | Example 768 | ------- 769 | 770 | >>> Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 771 | [('FOO', 1, 'foo'), ('BAR', 2, 'bar')] 772 | 773 | """ 774 | 775 | return '%s' % self.entries 776 | 777 | def __eq__(self, other): 778 | """Override to allow comparison with a tuple of choices, not only a list. 779 | 780 | It also allow to compare with default django choices, ie (value, display name), or 781 | with the format of ``Choices``, ie (constant name, value, display_name). 782 | 783 | Example 784 | ------- 785 | 786 | >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 787 | >>> MY_CHOICES == [('FOO', 1, 'foo'), ('BAR', 2, 'bar')] 788 | True 789 | >>> MY_CHOICES == (('FOO', 1, 'foo'), ('BAR', 2, 'bar')) 790 | True 791 | >>> MY_CHOICES == [(1, 'foo'), (2, 'bar')] 792 | True 793 | >>> MY_CHOICES == ((1, 'foo'), (2, 'bar')) 794 | True 795 | 796 | """ 797 | 798 | # Convert to list if it's a tuple. 799 | if isinstance(other, tuple): 800 | other = list(other) 801 | 802 | # Compare to the list of entries if the first element seems to have a constant 803 | # name as first entry. 804 | if other and len(other[0]) == 3: 805 | return self.entries == other 806 | 807 | return super(Choices, self).__eq__(other) 808 | 809 | # TODO: implement __iadd__ and __add__ 810 | 811 | def __reduce__(self): 812 | """Reducer to make the auto-created classes picklable. 813 | 814 | Returns 815 | ------- 816 | tuple 817 | A tuple as expected by pickle, to recreate the object when calling ``pickle.loads``: 818 | 1. a callable to recreate the object 819 | 2. a tuple with all positioned arguments expected by this callable 820 | 821 | """ 822 | 823 | return ( 824 | # Function to create a ``Choices`` instance 825 | create_choice, 826 | ( 827 | # The ``Choices`` class, or a subclass, used to create the current instance 828 | self.__class__, 829 | # The list of choices 830 | [ 831 | ( 832 | entry.constant.original_value, 833 | entry.value.original_value, 834 | entry.display.original_value, 835 | entry.attributes, 836 | ) 837 | for entry in self.entries 838 | ], 839 | # The list of subsets 840 | [ 841 | ( 842 | # The name 843 | subset_name, 844 | # The list of constants to use in this subset 845 | [ 846 | c.original_value 847 | for c in getattr(self, subset_name).constants.keys() 848 | ] 849 | ) 850 | for subset_name in self.subsets 851 | ], 852 | # Extra kwargs to pass to ``__ini__`` 853 | { 854 | 'dict_class': self.dict_class, 855 | 'mutable': self._mutable, 856 | } 857 | ) 858 | ) 859 | 860 | 861 | class OrderedChoices(Choices): 862 | """Simple subclass of ``Choices`` using ``OrderedDict`` as ``dict_class`` 863 | 864 | Example 865 | ------- 866 | 867 | Start by declaring your ``Choices``: 868 | 869 | >>> ALIGNMENTS = OrderedChoices( 870 | ... ('BAD', 10, 'bad'), 871 | ... ('NEUTRAL', 20, 'neutral'), 872 | ... ('CHAOTIC_GOOD', 30, 'chaotic good'), 873 | ... ('GOOD', 40, 'good'), 874 | ... ) 875 | 876 | >>> ALIGNMENTS.dict_class 877 | 878 | 879 | >>> ALIGNMENTS.constants 880 | OrderedDict([('BAD', ('BAD', 10, 'bad')), ('NEUTRAL', ('NEUTRAL', 20, 'neutral')), ('CHAOTIC_GOOD', ('CHAOTIC_GOOD', 30, 'chaotic good')), ('GOOD', ('GOOD', 40, 'good'))]) 881 | >>> ALIGNMENTS.values 882 | OrderedDict([(10, ('BAD', 10, 'bad')), (20, ('NEUTRAL', 20, 'neutral')), (30, ('CHAOTIC_GOOD', 30, 'chaotic good')), (40, ('GOOD', 40, 'good'))]) 883 | >>> ALIGNMENTS.displays 884 | OrderedDict([('bad', ('BAD', 10, 'bad')), ('neutral', ('NEUTRAL', 20, 'neutral')), ('chaotic good', ('CHAOTIC_GOOD', 30, 'chaotic good')), ('good', ('GOOD', 40, 'good'))]) 885 | 886 | """ 887 | 888 | def __init__(self, *choices, **kwargs): 889 | 890 | # Class to use for dicts 891 | if 'dict_class' not in kwargs: 892 | kwargs['dict_class'] = OrderedDict 893 | 894 | super(OrderedChoices, self).__init__(*choices, **kwargs) 895 | 896 | 897 | class AutoDisplayChoices(OrderedChoices): 898 | """Subclass of ``OrderedChoices`` that will compose the display value based on the constant. 899 | 900 | To compose the display value, it will call a ``display_transform`` function, that is defined 901 | as a class attribute but can be overridden by passing it to the constructor. 902 | 903 | Example 904 | ------- 905 | 906 | >>> ALIGNMENTS = AutoDisplayChoices( 907 | ... ('BAD', 10), 908 | ... ('NEUTRAL', 20), 909 | ... ('CHAOTIC_GOOD', 30, 'THE CHAOS'), 910 | ... ('GOOD', 40, {'additional': 'attributes'}), 911 | ... ) 912 | 913 | >>> ALIGNMENTS.BAD.display 914 | 'Bad' 915 | >>> ALIGNMENTS.NEUTRAL.choice_entry 916 | ('NEUTRAL', 20, 'Neutral') 917 | >>> ALIGNMENTS.CHAOTIC_GOOD.display 918 | 'THE CHAOS' 919 | >>> ALIGNMENTS.GOOD.choice_entry.additional 920 | 'attributes' 921 | 922 | """ 923 | 924 | display_transform = staticmethod(lambda const: const.lower().replace('_', ' ').capitalize()) 925 | 926 | def __init__(self, *choices, **kwargs): 927 | self.display_transform = kwargs.pop('display_transform', None) or self.display_transform 928 | super(AutoDisplayChoices, self).__init__(*choices, **kwargs) 929 | 930 | def _convert_choices(self, choices): 931 | """Auto create display values then call super method""" 932 | 933 | final_choices = [] 934 | for choice in choices: 935 | 936 | if isinstance(choice, ChoiceEntry): 937 | final_choices.append(choice) 938 | continue 939 | 940 | original_choice = choice 941 | choice = list(choice) 942 | length = len(choice) 943 | 944 | assert 2 <= length <= 4, 'Invalid number of entries in %s' % (original_choice,) 945 | 946 | final_choice = [] 947 | 948 | # do we have attributes? 949 | if length > 2 and isinstance(choice[-1], Mapping): 950 | final_choice.append(choice.pop()) 951 | elif length == 4: 952 | attributes = choice.pop() 953 | assert attributes is None or isinstance(attributes, Mapping), 'Last argument must be a dict-like object in %s' % (original_choice,) 954 | if attributes: 955 | final_choice.append(attributes) 956 | 957 | # the constant 958 | final_choice.insert(0, choice.pop(0)) 959 | 960 | # the db value 961 | final_choice.insert(1, choice.pop(0)) 962 | 963 | if len(choice): 964 | # we were given a display value 965 | final_choice.insert(2, choice.pop(0)) 966 | else: 967 | # no display value, we compute it from the constant 968 | final_choice.insert(2, self.display_transform(final_choice[0])) 969 | 970 | final_choices.append(final_choice) 971 | 972 | return super(AutoDisplayChoices, self)._convert_choices(final_choices) 973 | 974 | 975 | class AutoChoices(AutoDisplayChoices): 976 | """Subclass of ``AutoDisplayChoices`` that will also compose the value to be saved based on the constant. 977 | 978 | To compose the display value, it will call a ``display_transform`` function, that is defined 979 | as a class attribute but can be overridden by passing it to the constructor. 980 | 981 | In this class, the ``*choices`` argument can simply be strings, or tuples with one element (or two 982 | to add additional attributes) 983 | 984 | Example 985 | ------- 986 | 987 | >>> ALIGNMENTS = AutoChoices( 988 | ... 'BAD', 989 | ... ('NEUTRAL', ), 990 | ... ('CHAOTIC_GOOD', 'chaos', 'THE CHAOS'), 991 | ... ('GOOD', None, 'Yeah', {'additional': 'attributes'}), 992 | ... ) 993 | 994 | >>> ALIGNMENTS.BAD.value 995 | 'bad' 996 | >>> ALIGNMENTS.BAD.display 997 | 'Bad' 998 | >>> ALIGNMENTS.NEUTRAL.choice_entry 999 | ('NEUTRAL', 'neutral', 'Neutral') 1000 | >>> ALIGNMENTS.CHAOTIC_GOOD.value 1001 | 'chaos' 1002 | >>> ALIGNMENTS.CHAOTIC_GOOD.display 1003 | 'THE CHAOS' 1004 | >>> ALIGNMENTS.GOOD.value 1005 | 'good' 1006 | >>> ALIGNMENTS.GOOD.display 1007 | 'Yeah' 1008 | >>> ALIGNMENTS.GOOD.choice_entry.additional 1009 | 'attributes' 1010 | 1011 | """ 1012 | 1013 | value_transform = staticmethod(lambda const: const.lower()) 1014 | 1015 | def __init__(self, *choices, **kwargs): 1016 | self.value_transform = kwargs.pop('value_transform', None) or self.value_transform 1017 | super(AutoChoices, self).__init__(*choices, **kwargs) 1018 | 1019 | def add_choices(self, *choices, **kwargs): 1020 | """Disallow super method to thing the first argument is a subset name""" 1021 | return super(AutoChoices, self).add_choices(_NO_SUBSET_NAME_, *choices, **kwargs) 1022 | 1023 | def _convert_choices(self, choices): 1024 | """Auto create db values then call super method""" 1025 | 1026 | final_choices = [] 1027 | for choice in choices: 1028 | 1029 | if isinstance(choice, ChoiceEntry): 1030 | final_choices.append(choice) 1031 | continue 1032 | 1033 | original_choice = choice 1034 | if isinstance(choice, six.string_types): 1035 | if choice == _NO_SUBSET_NAME_: 1036 | continue 1037 | choice = [choice, ] 1038 | else: 1039 | choice = list(choice) 1040 | 1041 | length = len(choice) 1042 | 1043 | assert 1 <= length <= 4, 'Invalid number of entries in %s' % (original_choice,) 1044 | 1045 | final_choice = [] 1046 | 1047 | # do we have attributes? 1048 | if length > 1 and isinstance(choice[-1], Mapping): 1049 | final_choice.append(choice.pop()) 1050 | elif length == 4: 1051 | attributes = choice.pop() 1052 | assert attributes is None or isinstance(attributes, Mapping), 'Last argument must be a dict-like object in %s' % (original_choice,) 1053 | if attributes: 1054 | final_choice.append(attributes) 1055 | 1056 | # the constant 1057 | final_choice.insert(0, choice.pop(0)) 1058 | 1059 | if len(choice): 1060 | # we were given a db value 1061 | final_choice.insert(1, choice.pop(0)) 1062 | if len(choice): 1063 | # we were given a display value 1064 | final_choice.insert(2, choice.pop(0)) 1065 | else: 1066 | # set None to compute it later 1067 | final_choice.insert(1, None) 1068 | 1069 | if final_choice[1] is None: 1070 | # no db value, we compute it from the constant 1071 | final_choice[1] = self.value_transform(final_choice[0]) 1072 | 1073 | final_choices.append(final_choice) 1074 | 1075 | return super(AutoChoices, self)._convert_choices(final_choices) 1076 | 1077 | 1078 | def create_choice(klass, choices, subsets, kwargs): 1079 | """Create an instance of a ``Choices`` object. 1080 | 1081 | Parameters 1082 | ---------- 1083 | klass : type 1084 | The class to use to recreate the object. 1085 | choices : list(tuple) 1086 | A list of choices as expected by the ``__init__`` method of ``klass``. 1087 | subsets : list(tuple) 1088 | A tuple with an entry for each subset to create. Each entry is a list with two entries: 1089 | - the name of the subsets 1090 | - a list of the constants to use for this subset 1091 | kwargs : dict 1092 | Extra parameters expected on the ``__init__`` method of ``klass``. 1093 | 1094 | Returns 1095 | ------- 1096 | Choices 1097 | A new instance of ``Choices`` (or other class defined in ``klass``). 1098 | 1099 | """ 1100 | 1101 | obj = klass(*choices, **kwargs) 1102 | for subset in subsets: 1103 | obj.add_subset(*subset) 1104 | return obj 1105 | -------------------------------------------------------------------------------- /extended_choices/tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Tests for the ``extended_choices`` module. 4 | 5 | Notes 6 | ----- 7 | 8 | The documentation format in this file is numpydoc_. 9 | 10 | .. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt 11 | 12 | """ 13 | 14 | from __future__ import unicode_literals 15 | 16 | from copy import copy, deepcopy 17 | 18 | try: 19 | import cPickle as pickle 20 | except ImportError: 21 | import pickle 22 | 23 | from collections import OrderedDict 24 | import unittest 25 | 26 | import django 27 | 28 | 29 | # Minimal django conf to test a real field. 30 | from django.conf import settings 31 | settings.configure(DATABASE_ENGINE='sqlite3') 32 | 33 | from django.core.exceptions import ValidationError 34 | from django.utils.functional import Promise 35 | from django.utils.translation import ugettext_lazy 36 | 37 | from .choices import Choices, OrderedChoices, AutoDisplayChoices, AutoChoices 38 | from .fields import NamedExtendedChoiceFormField 39 | from .helpers import ChoiceAttributeMixin, ChoiceEntry 40 | 41 | 42 | class BaseTestCase(unittest.TestCase): 43 | """Base test case that define a test ``Choices`` instance with a subset.""" 44 | 45 | def setUp(self): 46 | super(BaseTestCase, self).setUp() 47 | 48 | self.MY_CHOICES = None 49 | self.init_choices() 50 | 51 | def init_choices(self): 52 | 53 | self.MY_CHOICES = Choices( 54 | ('ONE', 1, 'One for the money', {'one': 'money'}), 55 | ('TWO', 2, 'Two for the show'), 56 | ('THREE', 3, 'Three to get ready'), 57 | ) 58 | self.MY_CHOICES.add_subset("ODD", ("ONE", "THREE")) 59 | 60 | 61 | class FieldsTestCase(BaseTestCase): 62 | """Tests of the ``NamedExtendedChoiceFormField`` field.""" 63 | 64 | def test_enforce_passing_a_choices(self): 65 | """Test that the field class only accepts a ``Choices`` object for its ``choices`` arg.""" 66 | 67 | # Test with default choices tuple. 68 | with self.assertRaises(ValueError): 69 | NamedExtendedChoiceFormField(choices=( 70 | ('ONE', 1, 'One for the money'), 71 | ('TWO', 2, 'Two for the show'), 72 | ('THREE', 3, 'Three to get ready'), 73 | )) 74 | 75 | # Test with something else unrelated. 76 | with self.assertRaises(ValueError): 77 | NamedExtendedChoiceFormField(choices=1) 78 | 79 | # Test with a ``Choices`` object. 80 | field = NamedExtendedChoiceFormField(choices=self.MY_CHOICES) 81 | self.assertEqual(field.choices, self.MY_CHOICES) 82 | 83 | def test_named_extended_choice_form_field_validation(self): 84 | """Test that it only validation when it receives an existing constant name.""" 85 | 86 | field = NamedExtendedChoiceFormField(choices=self.MY_CHOICES) 87 | 88 | # Should respect the constant case. 89 | self.assertEqual(field.clean('ONE'), 1) 90 | self.assertEqual(field.clean('TWO'), 2) 91 | 92 | with self.assertRaises(ValidationError) as raise_context: 93 | field.clean('one') 94 | self.assertEqual(raise_context.exception.code, 'non-existing-choice') 95 | 96 | # Should not validate with non-existing constant. 97 | with self.assertRaises(ValidationError) as raise_context: 98 | field.clean('FOUR') 99 | self.assertEqual(raise_context.exception.code, 'non-existing-choice') 100 | 101 | # Should fail early if not a string. 102 | with self.assertRaises(ValidationError) as raise_context: 103 | field.clean(1) 104 | self.assertEqual(raise_context.exception.code, 'invalid-choice-type') 105 | 106 | 107 | class ChoicesTestCase(BaseTestCase): 108 | """Test the ``Choices`` class.""" 109 | 110 | def test_should_behave_as_expected_by_django(self): 111 | """Test that it can be used by django, ie a list of tuple (value, display name).""" 112 | 113 | expected = ( 114 | (1, 'One for the money'), 115 | (2, 'Two for the show'), 116 | (3, 'Three to get ready'), 117 | ) 118 | 119 | # Test access to the whole expected tuple 120 | self.assertEqual(self.MY_CHOICES, expected) 121 | self.assertEqual(self.MY_CHOICES.choices, expected) 122 | 123 | # Test access to each tuple 124 | self.assertEqual(self.MY_CHOICES[0], expected[0]) 125 | self.assertEqual(self.MY_CHOICES[2], expected[2]) 126 | 127 | def test_should_be_accepted_by_django(self): 128 | """Test that a django field really accept a ``Choices`` instance.""" 129 | 130 | from django.db.models import IntegerField 131 | field = IntegerField(choices=self.MY_CHOICES, default=self.MY_CHOICES.ONE) 132 | 133 | self.assertEqual(field.choices, self.MY_CHOICES) 134 | 135 | # No errors in ``_check_choices_``, Django 1.7+ 136 | if django.VERSION >= (1, 7): 137 | self.assertEqual(field._check_choices(), []) 138 | 139 | # Test validation 140 | field.validate(1, None) 141 | 142 | with self.assertRaises(ValidationError) as raise_context: 143 | field.validate(4, None) 144 | 145 | # Check exception code, only in Django 1.6+ 146 | if django.VERSION >= (1, 6): 147 | self.assertEqual(raise_context.exception.code, 'invalid_choice') 148 | 149 | def test_constants_attributes_should_return_values(self): 150 | """Test that each constant is an attribute returning the value.""" 151 | 152 | self.assertEqual(self.MY_CHOICES.ONE, 1) 153 | self.assertEqual(self.MY_CHOICES.THREE, 3) 154 | 155 | with self.assertRaises(AttributeError): 156 | self.MY_CHOICES.FOUR 157 | 158 | def test_attributes_should_be_accessed_by_keys(self): 159 | """Test that each constant is accessible by key.""" 160 | 161 | self.assertIs(self.MY_CHOICES['ONE'], self.MY_CHOICES.ONE) 162 | self.assertIs(self.MY_CHOICES['THREE'], self.MY_CHOICES.THREE) 163 | 164 | with self.assertRaises(KeyError): 165 | self.MY_CHOICES['FOUR'] 166 | 167 | def test_entries(self): 168 | """Test that ``entries`` holds ``ChoiceEntry`` instances with correct attributes.""" 169 | 170 | self.assertIsInstance(self.MY_CHOICES.entries[0], ChoiceEntry) 171 | self.assertEqual(self.MY_CHOICES.entries[0], ('ONE', 1, 'One for the money')) 172 | self.assertEqual(self.MY_CHOICES.entries[0].constant, 'ONE') 173 | self.assertEqual(self.MY_CHOICES.entries[0].value, 1) 174 | self.assertEqual(self.MY_CHOICES.entries[0].display, 'One for the money') 175 | 176 | self.assertIsInstance(self.MY_CHOICES.entries[2], ChoiceEntry) 177 | self.assertEqual(self.MY_CHOICES.entries[2], ('THREE', 3, 'Three to get ready')) 178 | self.assertEqual(self.MY_CHOICES.entries[2].constant, 'THREE') 179 | self.assertEqual(self.MY_CHOICES.entries[2].value, 3) 180 | self.assertEqual(self.MY_CHOICES.entries[2].display, 'Three to get ready') 181 | 182 | def test_dicts(self): 183 | """Test that ``constants``, ``values`` and ``displays`` dicts behave as expected.""" 184 | self.assertIsInstance(self.MY_CHOICES.constants, dict) 185 | self.assertIsInstance(self.MY_CHOICES.values, dict) 186 | self.assertIsInstance(self.MY_CHOICES.displays, dict) 187 | 188 | self.assertDictEqual(self.MY_CHOICES.constants, { 189 | 'ONE': ('ONE', 1, 'One for the money'), 190 | 'TWO': ('TWO', 2, 'Two for the show'), 191 | 'THREE': ('THREE', 3, 'Three to get ready'), 192 | }) 193 | self.assertDictEqual(self.MY_CHOICES.values, { 194 | 1: ('ONE', 1, 'One for the money'), 195 | 2: ('TWO', 2, 'Two for the show'), 196 | 3: ('THREE', 3, 'Three to get ready'), 197 | }) 198 | self.assertDictEqual(self.MY_CHOICES.displays, { 199 | 'One for the money': ('ONE', 1, 'One for the money'), 200 | 'Two for the show': ('TWO', 2, 'Two for the show'), 201 | 'Three to get ready': ('THREE', 3, 'Three to get ready'), 202 | }) 203 | 204 | def test_adding_choices(self): 205 | """Test that we can add choices to an existing ``Choices`` instance.""" 206 | self.MY_CHOICES.add_choices( 207 | ('FOUR', 4, 'And four to go'), 208 | ('FIVE', 5, '... but Five is not in the song'), 209 | ) 210 | 211 | # Test django expected tuples 212 | expected = ( 213 | (1, 'One for the money'), 214 | (2, 'Two for the show'), 215 | (3, 'Three to get ready'), 216 | (4, 'And four to go'), 217 | (5, '... but Five is not in the song'), 218 | ) 219 | 220 | self.assertEqual(self.MY_CHOICES, expected) 221 | self.assertEqual(self.MY_CHOICES.choices, expected) 222 | 223 | # Test entries 224 | 225 | self.assertIsInstance(self.MY_CHOICES.entries[3], ChoiceEntry) 226 | self.assertEqual(self.MY_CHOICES.entries[3].constant, 'FOUR') 227 | self.assertEqual(self.MY_CHOICES.entries[3].value, 4) 228 | self.assertEqual(self.MY_CHOICES.entries[3].display, 'And four to go') 229 | 230 | self.assertIsInstance(self.MY_CHOICES.entries[4], ChoiceEntry) 231 | self.assertEqual(self.MY_CHOICES.entries[4].constant, 'FIVE') 232 | self.assertEqual(self.MY_CHOICES.entries[4].value, 5) 233 | self.assertEqual(self.MY_CHOICES.entries[4].display, '... but Five is not in the song') 234 | 235 | # Test dicts 236 | self.assertEqual(len(self.MY_CHOICES.constants), 5) 237 | self.assertEqual(len(self.MY_CHOICES.values), 5) 238 | self.assertEqual(len(self.MY_CHOICES.displays), 5) 239 | 240 | self.assertEqual(self.MY_CHOICES.constants['FOUR'], 241 | ('FOUR', 4, 'And four to go')) 242 | self.assertEqual(self.MY_CHOICES.constants['FIVE'], 243 | ('FIVE', 5, '... but Five is not in the song')) 244 | self.assertEqual(self.MY_CHOICES.values[4], 245 | ('FOUR', 4, 'And four to go')) 246 | self.assertEqual(self.MY_CHOICES.values[5], 247 | ('FIVE', 5, '... but Five is not in the song')) 248 | self.assertEqual(self.MY_CHOICES.displays['And four to go'], 249 | ('FOUR', 4, 'And four to go')) 250 | self.assertEqual(self.MY_CHOICES.displays['... but Five is not in the song'], 251 | ('FIVE', 5, '... but Five is not in the song')) 252 | 253 | def test_adding_choice_to_create_subset(self): 254 | """Test that we can create a subset while adding choices. 255 | 256 | This test test both ways of setting a name for a subset to create when adding choices, 257 | resetting ``MY_CHOICES`` between both tests, and calling ``test_subset`` in both cases. 258 | 259 | """ 260 | 261 | def test_subset(): 262 | # Quick check of the whole ``Choices`` (see ``test_adding_choices`` for full test) 263 | self.assertEqual(len(self.MY_CHOICES), 5) 264 | self.assertEqual(len(self.MY_CHOICES.entries), 5) 265 | self.assertEqual(len(self.MY_CHOICES.constants), 5) 266 | 267 | # Check that we have a subset 268 | self.assertIsInstance(self.MY_CHOICES.EXTENDED, Choices) 269 | 270 | # And that it contains the added choices (see ``test_creating_subset`` for full test) 271 | self.assertEqual(self.MY_CHOICES.EXTENDED, ( 272 | (4, 'And four to go'), 273 | (5, '... but Five is not in the song'), 274 | )) 275 | 276 | # First test by setting the subset as first argument 277 | self.MY_CHOICES.add_choices( 278 | 'EXTENDED', 279 | ('FOUR', 4, 'And four to go'), 280 | ('FIVE', 5, '... but Five is not in the song'), 281 | ) 282 | 283 | test_subset() 284 | 285 | # Reset to remove added choices and subset 286 | self.init_choices() 287 | 288 | # Then test by setting the subset as named argument 289 | self.MY_CHOICES.add_choices( 290 | ('FOUR', 4, 'And four to go'), 291 | ('FIVE', 5, '... but Five is not in the song'), 292 | name='EXTENDED' 293 | ) 294 | 295 | test_subset() 296 | 297 | # Using both first argument and a named argument should fail 298 | with self.assertRaises(ValueError): 299 | self.MY_CHOICES.add_choices( 300 | 'EXTENDED', 301 | ('FOUR', 4, 'And four to go'), 302 | ('FIVE', 5, '... but Five is not in the song'), 303 | name='EXTENDED' 304 | ) 305 | 306 | def test_extracting_subset(self): 307 | """Test that we can extract a subset of choices.""" 308 | 309 | subset = self.MY_CHOICES.extract_subset('ONE', 'TWO') 310 | 311 | self.assertIsInstance(subset, Choices) 312 | 313 | # Test django expected tuples 314 | expected = ( 315 | (1, 'One for the money'), 316 | (2, 'Two for the show'), 317 | ) 318 | 319 | self.assertEqual(subset, expected) 320 | self.assertEqual(subset.choices, expected) 321 | 322 | # Test entries 323 | self.assertEqual(len(subset.entries), 2) 324 | self.assertIsInstance(subset.entries[0], ChoiceEntry) 325 | self.assertEqual(subset.entries[0].constant, 'ONE') 326 | self.assertEqual(subset.entries[0].value, 1) 327 | self.assertEqual(subset.entries[0].display, 'One for the money') 328 | 329 | self.assertIsInstance(subset.entries[1], ChoiceEntry) 330 | self.assertEqual(subset.entries[1].constant, 'TWO') 331 | self.assertEqual(subset.entries[1].value, 2) 332 | self.assertEqual(subset.entries[1].display, 'Two for the show') 333 | 334 | # Test dicts 335 | self.assertEqual(len(subset.constants), 2) 336 | self.assertEqual(len(subset.values), 2) 337 | self.assertEqual(len(subset.displays), 2) 338 | 339 | self.assertIs(subset.constants['ONE'], 340 | self.MY_CHOICES.constants['ONE']) 341 | self.assertIs(subset.constants['TWO'], 342 | self.MY_CHOICES.constants['TWO']) 343 | self.assertIs(subset.values[1], 344 | self.MY_CHOICES.constants['ONE']) 345 | self.assertIs(subset.values[2], 346 | self.MY_CHOICES.constants['TWO']) 347 | self.assertIs(subset.displays['One for the money'], 348 | self.MY_CHOICES.constants['ONE']) 349 | self.assertIs(subset.displays['Two for the show'], 350 | self.MY_CHOICES.constants['TWO']) 351 | 352 | # Test ``in`` 353 | self.assertIn(1, subset) 354 | self.assertNotIn(4, subset) 355 | 356 | def test_creating_subset(self): 357 | """Test that we can add subset of choices.""" 358 | 359 | self.assertIsInstance(self.MY_CHOICES.ODD, Choices) 360 | 361 | # Test django expected tuples 362 | expected = ( 363 | (1, 'One for the money'), 364 | (3, 'Three to get ready'), 365 | ) 366 | 367 | self.assertEqual(self.MY_CHOICES.ODD, expected) 368 | self.assertEqual(self.MY_CHOICES.ODD.choices, expected) 369 | 370 | # Test entries 371 | self.assertEqual(len(self.MY_CHOICES.ODD.entries), 2) 372 | self.assertIsInstance(self.MY_CHOICES.ODD.entries[0], ChoiceEntry) 373 | self.assertEqual(self.MY_CHOICES.ODD.entries[0].constant, 'ONE') 374 | self.assertEqual(self.MY_CHOICES.ODD.entries[0].value, 1) 375 | self.assertEqual(self.MY_CHOICES.ODD.entries[0].display, 'One for the money') 376 | 377 | self.assertIsInstance(self.MY_CHOICES.ODD.entries[1], ChoiceEntry) 378 | self.assertEqual(self.MY_CHOICES.ODD.entries[1].constant, 'THREE') 379 | self.assertEqual(self.MY_CHOICES.ODD.entries[1].value, 3) 380 | self.assertEqual(self.MY_CHOICES.ODD.entries[1].display, 'Three to get ready') 381 | 382 | # Test dicts 383 | self.assertEqual(len(self.MY_CHOICES.ODD.constants), 2) 384 | self.assertEqual(len(self.MY_CHOICES.ODD.values), 2) 385 | self.assertEqual(len(self.MY_CHOICES.ODD.displays), 2) 386 | 387 | self.assertIs(self.MY_CHOICES.ODD.constants['ONE'], 388 | self.MY_CHOICES.constants['ONE']) 389 | self.assertIs(self.MY_CHOICES.ODD.constants['THREE'], 390 | self.MY_CHOICES.constants['THREE']) 391 | self.assertIs(self.MY_CHOICES.ODD.values[1], 392 | self.MY_CHOICES.constants['ONE']) 393 | self.assertIs(self.MY_CHOICES.ODD.values[3], 394 | self.MY_CHOICES.constants['THREE']) 395 | self.assertIs(self.MY_CHOICES.ODD.displays['One for the money'], 396 | self.MY_CHOICES.constants['ONE']) 397 | self.assertIs(self.MY_CHOICES.ODD.displays['Three to get ready'], 398 | self.MY_CHOICES.constants['THREE']) 399 | 400 | # Test ``in`` 401 | self.assertIn(1, self.MY_CHOICES.ODD) 402 | self.assertNotIn(4, self.MY_CHOICES.ODD) 403 | 404 | def test_should_not_be_able_to_add_choices_to_a_subset(self): 405 | """Test that an exception is raised when trying to add new choices to a subset.""" 406 | 407 | # Using a subset created by ``add_subset``. 408 | with self.assertRaises(RuntimeError): 409 | self.MY_CHOICES.ODD.add_choices( 410 | ('FOO', 10, 'foo'), 411 | ('BAR', 20, 'bar'), 412 | ) 413 | 414 | # Using a subset created by ``add_choices``. 415 | self.MY_CHOICES.add_choices( 416 | ('FOUR', 4, 'And four to go'), 417 | ('FIVE', 5, '... but Five is not in the song'), 418 | name='EXTENDED' 419 | ) 420 | with self.assertRaises(RuntimeError): 421 | self.MY_CHOICES.EXTENDED.add_choices( 422 | ('FOO', 10, 'foo'), 423 | ('BAR', 20, 'bar'), 424 | ) 425 | 426 | def test_validating_added_choices(self): 427 | """Test that added choices can be added.""" 428 | 429 | # Cannot add an existing constant. 430 | with self.assertRaises(ValueError): 431 | self.MY_CHOICES.add_choices( 432 | ('ONE', 11, 'Another ONE'), 433 | ('FOUR', 4, 'And four to go'), 434 | ) 435 | 436 | # Cannot add an existing value. 437 | with self.assertRaises(ValueError): 438 | self.MY_CHOICES.add_choices( 439 | ('ONEBIS', 1, 'Another 1'), 440 | ('FOUR', 4, 'And four to go'), 441 | ) 442 | 443 | # Cannot add two choices with the same name. 444 | with self.assertRaises(ValueError): 445 | self.MY_CHOICES.add_choices( 446 | ('FOUR', 4, 'And four to go'), 447 | ('FOUR', 44, 'And four to go, bis'), 448 | ) 449 | 450 | # Cannot add two choices with the same value. 451 | with self.assertRaises(ValueError): 452 | self.MY_CHOICES.add_choices( 453 | ('FOUR', 4, 'And four to go'), 454 | ('FOURBIS', 4, 'And four to go, bis'), 455 | ) 456 | 457 | # Cannot use existing attributes. 458 | with self.assertRaises(ValueError): 459 | self.MY_CHOICES.add_choices( 460 | ('FOUR', 4, 'And four to go'), 461 | ('choices', 123, 'Existing attribute'), 462 | ) 463 | 464 | with self.assertRaises(ValueError): 465 | self.MY_CHOICES.add_choices( 466 | ('FOUR', 4, 'And four to go'), 467 | ('add_choices', 123, 'Existing attribute'), 468 | ) 469 | 470 | def test_validating_subset(self): 471 | """Test that new subset is valid.""" 472 | 473 | # Using a name that is already an attribute 474 | with self.assertRaises(ValueError): 475 | self.MY_CHOICES.add_subset("choices", ("ONE", "THREE")) 476 | 477 | with self.assertRaises(ValueError): 478 | self.MY_CHOICES.add_subset("add_choices", ("ONE", "THREE")) 479 | 480 | # Using an undefined constant 481 | with self.assertRaises(ValueError): 482 | self.MY_CHOICES.add_subset("EVEN", ("TWO", "FOUR")) 483 | 484 | def test_for_methods(self): 485 | """Test the ``for_constant``, ``for_value`` and ``for_display`` methods.""" 486 | 487 | self.assertIs(self.MY_CHOICES.for_constant('ONE'), 488 | self.MY_CHOICES.constants['ONE']) 489 | self.assertIs(self.MY_CHOICES.for_value(2), 490 | self.MY_CHOICES.values[2]) 491 | self.assertIs(self.MY_CHOICES.for_display('Three to get ready'), 492 | self.MY_CHOICES.displays['Three to get ready']) 493 | 494 | with self.assertRaises(KeyError): 495 | self.MY_CHOICES.for_constant('FOUR') 496 | 497 | with self.assertRaises(KeyError): 498 | self.MY_CHOICES.for_value(4) 499 | 500 | with self.assertRaises(KeyError): 501 | self.MY_CHOICES.for_display('And four to go') 502 | 503 | def test_has_methods(self): 504 | """Test the ``has_constant``, ``has_value`` and ``has_display`` methods.""" 505 | 506 | self.assertTrue(self.MY_CHOICES.has_constant('ONE')) 507 | self.assertTrue(self.MY_CHOICES.has_value(2)) 508 | self.assertTrue(self.MY_CHOICES.has_display('Three to get ready')) 509 | 510 | self.assertFalse(self.MY_CHOICES.has_constant('FOUR')) 511 | self.assertFalse(self.MY_CHOICES.has_value(4)) 512 | self.assertFalse(self.MY_CHOICES.has_display('And four to go')) 513 | 514 | def test__contains__(self): 515 | """Test the ``__contains__`` method.""" 516 | 517 | self.assertIn(self.MY_CHOICES.ONE, self.MY_CHOICES) 518 | self.assertTrue(self.MY_CHOICES.__contains__(self.MY_CHOICES.ONE)) 519 | self.assertIn(1, self.MY_CHOICES) 520 | self.assertTrue(self.MY_CHOICES.__contains__(1)) 521 | self.assertIn(3, self.MY_CHOICES) 522 | self.assertTrue(self.MY_CHOICES.__contains__(3)) 523 | self.assertNotIn(4, self.MY_CHOICES) 524 | self.assertFalse(self.MY_CHOICES.__contains__(4)) 525 | 526 | def test__getitem__(self): 527 | """Test the ``__getitem__`` method.""" 528 | 529 | # Access to constants. 530 | self.assertEqual(self.MY_CHOICES['ONE'], 1) 531 | self.assertEqual(self.MY_CHOICES.__getitem__('ONE'), 1) 532 | 533 | self.assertEqual(self.MY_CHOICES['THREE'], 3) 534 | self.assertEqual(self.MY_CHOICES.__getitem__('THREE'), 3) 535 | 536 | with self.assertRaises(KeyError): 537 | self.MY_CHOICES['FOUR'] 538 | with self.assertRaises(KeyError): 539 | self.MY_CHOICES.__getitem__('FOUR') 540 | 541 | one = (1, 'One for the money') 542 | three = (3, 'Three to get ready') 543 | 544 | # Access to default list entries 545 | self.assertEqual(self.MY_CHOICES[0], one) 546 | self.assertEqual(self.MY_CHOICES.__getitem__(0), one) 547 | 548 | self.assertEqual(self.MY_CHOICES[2], three) 549 | self.assertEqual(self.MY_CHOICES.__getitem__(2), three) 550 | 551 | with self.assertRaises(IndexError): 552 | self.MY_CHOICES[3] 553 | with self.assertRaises(IndexError): 554 | self.MY_CHOICES.__getitem__(3) 555 | 556 | def test_it_should_work_with_django_promises(self): 557 | """Test that it works with django promises, like ``ugettext_lazy``.""" 558 | 559 | # Init django, only needed starting from django 1.7 560 | if django.VERSION >= (1, 7): 561 | django.setup() 562 | 563 | choices = Choices( 564 | ('ONE', 1, ugettext_lazy('one')), 565 | ('TWO', 2, ugettext_lazy('two')), 566 | ) 567 | 568 | # Key in ``displays`` dict should be promises 569 | self.assertIsInstance(list(choices.displays.keys())[0], Promise) 570 | 571 | # And that they can be retrieved 572 | self.assertTrue(choices.has_display(ugettext_lazy('one'))) 573 | self.assertEqual(choices.displays[ugettext_lazy('two')].value, 2) 574 | 575 | return 576 | 577 | def test_pickle_choice_attribute(self): 578 | """Test that a choice attribute could be pickled and unpickled.""" 579 | 580 | value = self.MY_CHOICES.ONE 581 | 582 | pickled_value = pickle.dumps(value) 583 | unpickled_value = pickle.loads(pickled_value) 584 | 585 | self.assertEqual(unpickled_value, value) 586 | self.assertEqual(unpickled_value.choice_entry, value.choice_entry) 587 | self.assertEqual(unpickled_value.constant, 'ONE') 588 | self.assertEqual(unpickled_value.display, 'One for the money') 589 | self.assertEqual(unpickled_value.value, 1) 590 | self.assertEqual(unpickled_value.one, 'money') 591 | self.assertEqual(unpickled_value.display.one, 'money') 592 | 593 | def test_pickle_choice_entry(self): 594 | """Test that a choice entry could be pickled and unpickled.""" 595 | 596 | entry = self.MY_CHOICES.ONE.choice_entry 597 | 598 | pickled_entry = pickle.dumps(entry) 599 | unpickled_entry = pickle.loads(pickled_entry) 600 | 601 | self.assertEqual(unpickled_entry, entry) 602 | self.assertEqual(unpickled_entry.constant, 'ONE') 603 | self.assertEqual(unpickled_entry.display, 'One for the money') 604 | self.assertEqual(unpickled_entry.value, 1) 605 | self.assertEqual(unpickled_entry.one, 'money') 606 | self.assertEqual(unpickled_entry.display.one, 'money') 607 | 608 | def test_pickle_choice(self): 609 | """Test that a choices object could be pickled and unpickled.""" 610 | 611 | # Simple choice 612 | pickled_choices = pickle.dumps(self.MY_CHOICES) 613 | unpickled_choices = pickle.loads(pickled_choices) 614 | 615 | self.assertEqual(unpickled_choices, self.MY_CHOICES) 616 | self.assertEqual(unpickled_choices.ONE.one, 'money') 617 | self.assertEqual(unpickled_choices.ONE.display.one, 'money') 618 | 619 | # With a name, extra arguments and subsets 620 | OTHER_CHOICES = Choices( 621 | 'ALL', 622 | ('ONE', 1, 'One for the money'), 623 | ('TWO', 2, 'Two for the show'), 624 | ('THREE', 3, 'Three to get ready'), 625 | dict_class=OrderedDict, 626 | mutable=False 627 | ) 628 | OTHER_CHOICES.add_subset("ODD", ("ONE", "THREE")) 629 | OTHER_CHOICES.add_subset("EVEN", ("TWO", )) 630 | 631 | pickled_choices = pickle.dumps(OTHER_CHOICES) 632 | unpickled_choices = pickle.loads(pickled_choices) 633 | 634 | self.assertEqual(unpickled_choices, OTHER_CHOICES) 635 | self.assertEqual(unpickled_choices.dict_class, OrderedDict) 636 | self.assertFalse(unpickled_choices._mutable) 637 | self.assertEqual(unpickled_choices.subsets, OTHER_CHOICES.subsets) 638 | self.assertEqual(unpickled_choices.ALL, OTHER_CHOICES.ALL) 639 | self.assertEqual(unpickled_choices.ODD, OTHER_CHOICES.ODD) 640 | self.assertEqual(unpickled_choices.EVEN, OTHER_CHOICES.EVEN) 641 | 642 | def test_django_ugettext_lazy(self): 643 | """Test that a choices object using ugettext_lazy could be pickled and copied.""" 644 | 645 | lazy_choices = Choices( 646 | ('ONE', 1, ugettext_lazy('One for the money')), 647 | ('TWO', 2, ugettext_lazy('Two for the show')), 648 | ('THREE', 3, ugettext_lazy('Three to get ready')), 649 | ) 650 | 651 | # try to pickel it, it should not raise 652 | pickled_choices = pickle.dumps(lazy_choices) 653 | unpickled_choices = pickle.loads(pickled_choices) 654 | 655 | self.assertEqual(unpickled_choices, lazy_choices) 656 | 657 | # try to copy it, it should not raise 658 | copied_choices = copy(lazy_choices) 659 | self.assertEqual(copied_choices, lazy_choices) 660 | 661 | # try to deep-copy it, it should not raise 662 | deep_copied_choices = deepcopy(lazy_choices) 663 | self.assertEqual(deep_copied_choices, lazy_choices) 664 | 665 | def test_bool(self): 666 | """Test that having 0 or "" return `False` in a boolean context""" 667 | 668 | bool_choices = Choices( 669 | ('', 0, ''), 670 | ('FOO', 1, 'bar'), 671 | ) 672 | 673 | first = bool_choices.for_value(0) 674 | second = bool_choices.for_value(1) 675 | 676 | self.assertFalse(first.constant) 677 | self.assertFalse(first.value) 678 | self.assertFalse(first.display) 679 | 680 | self.assertTrue(second.constant) 681 | self.assertTrue(second.value) 682 | self.assertTrue(second.display) 683 | 684 | def test_dict_class(self): 685 | """Test that the dict_class argument is taken into account""" 686 | 687 | dict_choices = Choices( 688 | ('FOO', 1, 'foo'), 689 | ('BAR', 2, 'bar') 690 | ) 691 | 692 | self.assertIs(dict_choices.dict_class, dict) 693 | self.assertIsInstance(dict_choices.constants, dict) 694 | self.assertIsInstance(dict_choices.values, dict) 695 | self.assertIsInstance(dict_choices.displays, dict) 696 | 697 | ordered_dict_choices = Choices( 698 | ('FOO', 1, 'foo'), 699 | ('BAR', 2, 'bar'), 700 | dict_class=OrderedDict 701 | ) 702 | 703 | self.assertIs(ordered_dict_choices.dict_class, OrderedDict) 704 | self.assertIsInstance(ordered_dict_choices.constants, OrderedDict) 705 | self.assertIsInstance(ordered_dict_choices.values, OrderedDict) 706 | self.assertIsInstance(ordered_dict_choices.displays, OrderedDict) 707 | 708 | ordered_choices = OrderedChoices( 709 | ('FOO', 1, 'foo'), 710 | ('BAR', 2, 'bar'), 711 | ) 712 | 713 | self.assertIs(ordered_choices.dict_class, OrderedDict) 714 | self.assertIsInstance(ordered_choices.constants, OrderedDict) 715 | self.assertIsInstance(ordered_choices.values, OrderedDict) 716 | self.assertIsInstance(ordered_choices.displays, OrderedDict) 717 | 718 | def test_passing_choice_entry(self): 719 | MY_CHOICES = Choices( 720 | ChoiceEntry(('A', 'aa', 'aaa', {'foo': 'bar'})), 721 | ('B', 'bb', 'bbb'), 722 | ) 723 | self.assertEqual(MY_CHOICES.A.value, 'aa') 724 | self.assertEqual(MY_CHOICES.A.display, 'aaa') 725 | self.assertEqual(MY_CHOICES.B.value, 'bb') 726 | self.assertEqual(MY_CHOICES.B.display, 'bbb') 727 | 728 | def test_accessing_attributes(self): 729 | MY_CHOICES = Choices( 730 | ('FOO', 1, 'foo', {'foo': 'foo1', 'bar': 'bar1'}), 731 | ('BAR', 2, 'bar', {'foo': 'foo2', 'bar': 'bar2'}), 732 | ) 733 | self.assertEqual(MY_CHOICES.FOO.choice_entry.foo, 'foo1') 734 | self.assertEqual(MY_CHOICES.FOO.foo, 'foo1') 735 | self.assertEqual(MY_CHOICES.FOO.constant.foo, 'foo1') 736 | self.assertEqual(MY_CHOICES.FOO.value.foo, 'foo1') 737 | self.assertEqual(MY_CHOICES.FOO.display.foo, 'foo1') 738 | self.assertEqual(MY_CHOICES.FOO.choice_entry.bar, 'bar1') 739 | self.assertEqual(MY_CHOICES.BAR.choice_entry.foo, 'foo2') 740 | self.assertEqual(MY_CHOICES.BAR.foo, 'foo2') 741 | self.assertEqual(MY_CHOICES.BAR.choice_entry.bar, 'bar2') 742 | self.assertEqual(MY_CHOICES.BAR.bar, 'bar2') 743 | 744 | def test_invalid_attributes(self): 745 | for invalid_key in {'constant', 'value', 'display'}: 746 | with self.assertRaises(AssertionError): 747 | Choices(('FOO', '1', 'foo', {invalid_key: 'xxx'})) 748 | 749 | 750 | class ChoiceAttributeMixinTestCase(BaseTestCase): 751 | """Test the ``ChoiceAttributeMixin`` class.""" 752 | 753 | choice_entry = ChoiceEntry(('FOO', 1, 'foo')) 754 | 755 | def test_it_should_respect_the_type(self): 756 | """A class based on ``ChoiceAttributeMixin`` should inherit from the other class too.""" 757 | 758 | class StrChoiceAttribute(ChoiceAttributeMixin, str): 759 | pass 760 | 761 | str_attr = StrChoiceAttribute('f', self.choice_entry) 762 | self.assertEqual(str_attr, 'f') 763 | self.assertIsInstance(str_attr, str) 764 | 765 | class IntChoiceAttribute(ChoiceAttributeMixin, int): 766 | pass 767 | 768 | int_attr = IntChoiceAttribute(1, self.choice_entry) 769 | self.assertEqual(int_attr, 1) 770 | self.assertIsInstance(int_attr, int) 771 | 772 | class FloatChoiceAttribute(ChoiceAttributeMixin, float): 773 | pass 774 | 775 | float_attr = FloatChoiceAttribute(1.5, self.choice_entry) 776 | self.assertEqual(float_attr, 1.5) 777 | self.assertIsInstance(float_attr, float) 778 | 779 | def test_it_should_create_classes_on_the_fly(self): 780 | """Test that ``get_class_for_value works and cache its results.""" 781 | 782 | # Empty list of cached classes to really test. 783 | ChoiceAttributeMixin._classes_by_type = {} 784 | 785 | # Create a class on the fly. 786 | IntClass = ChoiceAttributeMixin.get_class_for_value(1) 787 | self.assertEqual(IntClass.__name__, 'IntChoiceAttribute') 788 | self.assertIn(int, IntClass.mro()) 789 | self.assertDictEqual(ChoiceAttributeMixin._classes_by_type, { 790 | int: IntClass 791 | }) 792 | 793 | # Using the same type should return the same class as before. 794 | IntClass2 = ChoiceAttributeMixin.get_class_for_value(2) 795 | self.assertIs(IntClass2, IntClass2) 796 | 797 | int_attr = IntClass(1, self.choice_entry) 798 | self.assertEqual(int_attr, 1) 799 | self.assertIsInstance(int_attr, int) 800 | 801 | # Create another class on the fly. 802 | FloatClass = ChoiceAttributeMixin.get_class_for_value(1.5) 803 | self.assertEqual(FloatClass.__name__, 'FloatChoiceAttribute') 804 | self.assertIn(float, FloatClass.mro()) 805 | self.assertDictEqual(ChoiceAttributeMixin._classes_by_type, { 806 | int: IntClass, 807 | float: FloatClass 808 | }) 809 | 810 | float_attr = FloatClass(1.5, self.choice_entry) 811 | self.assertEqual(float_attr, 1.5) 812 | self.assertIsInstance(float_attr, float) 813 | 814 | def test_it_should_access_choice_entry_attributes(self): 815 | """Test that an instance can access the choice entry and its attributes.""" 816 | IntClass = ChoiceAttributeMixin.get_class_for_value(1) 817 | attr = IntClass(1, self.choice_entry) 818 | 819 | # We should access the choice entry. 820 | self.assertEqual(attr.choice_entry, self.choice_entry) 821 | 822 | # And the attributes of the choice entry. 823 | self.assertEqual(attr.constant, self.choice_entry.constant) 824 | self.assertEqual(attr.value, self.choice_entry.value) 825 | self.assertEqual(attr.display, self.choice_entry.display) 826 | 827 | def test_it_should_work_with_django_promises(self): 828 | """Test that it works with django promises, like ``ugettext_lazy``.""" 829 | 830 | # Init django, only needed starting from django 1.7 831 | if django.VERSION >= (1, 7): 832 | django.setup() 833 | 834 | value = ugettext_lazy('foo') 835 | klass = ChoiceAttributeMixin.get_class_for_value(value) 836 | attr = klass(value, self.choice_entry) 837 | 838 | self.assertIsInstance(attr, Promise) 839 | self.assertEqual(attr, ugettext_lazy('foo')) 840 | 841 | 842 | class ChoiceEntryTestCase(BaseTestCase): 843 | """Test the ``ChoiceEntry`` class.""" 844 | 845 | def test_it_should_act_as_a_normal_tuple(self): 846 | """Test that a ``ChoiceEntry`` instance acts as a real tuple.""" 847 | 848 | # Test without additional attributes 849 | choice_entry = ChoiceEntry(('FOO', 1, 'foo')) 850 | self.assertIsInstance(choice_entry, tuple) 851 | self.assertEqual(choice_entry, ('FOO', 1, 'foo')) 852 | 853 | # Test with additional attributes 854 | choice_entry = ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2})) 855 | self.assertIsInstance(choice_entry, tuple) 856 | self.assertEqual(choice_entry, ('FOO', 1, 'foo')) 857 | 858 | def test_it_should_have_new_attributes(self): 859 | """Test that new specific attributes are accessible anv valid.""" 860 | 861 | choice_entry = ChoiceEntry(('FOO', 1, 'foo')) 862 | 863 | self.assertEqual(choice_entry.constant, 'FOO') 864 | self.assertEqual(choice_entry.value, 1) 865 | self.assertEqual(choice_entry.display, 'foo') 866 | self.assertEqual(choice_entry.choice, (1, 'foo')) 867 | 868 | def test_new_attributes_are_instances_of_choices_attributes(self): 869 | """Test that the new attributes are instances of ``ChoiceAttributeMixin``.""" 870 | 871 | choice_entry = ChoiceEntry(('FOO', 1, 'foo')) 872 | 873 | # 3 attributes should be choice attributes 874 | self.assertIsInstance(choice_entry.constant, ChoiceAttributeMixin) 875 | self.assertIsInstance(choice_entry.value, ChoiceAttributeMixin) 876 | self.assertIsInstance(choice_entry.display, ChoiceAttributeMixin) 877 | 878 | # As a choice attribute allow to access the other attributes of the choice entry, 879 | # check that it's really possible 880 | 881 | self.assertIs(choice_entry.constant.constant, choice_entry.constant) 882 | self.assertIs(choice_entry.constant.value, choice_entry.value) 883 | self.assertIs(choice_entry.constant.display, choice_entry.display) 884 | 885 | self.assertIs(choice_entry.value.constant, choice_entry.constant) 886 | self.assertIs(choice_entry.value.value, choice_entry.value) 887 | self.assertIs(choice_entry.value.display, choice_entry.display) 888 | 889 | self.assertIs(choice_entry.display.constant, choice_entry.constant) 890 | self.assertIs(choice_entry.display.value, choice_entry.value) 891 | self.assertIs(choice_entry.display.display, choice_entry.display) 892 | 893 | # Extreme test 894 | self.assertIs(choice_entry.display.value.constant.value.display.display.constant, 895 | choice_entry.constant) 896 | 897 | def test_additional_attributes_are_saved(self): 898 | """Test that a dict passed as 4th tuple entry set its entries as attributes.""" 899 | 900 | choice_entry = ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2})) 901 | self.assertEqual(choice_entry.bar, 1) 902 | self.assertEqual(choice_entry.baz, 2) 903 | 904 | def test_it_should_check_number_of_entries(self): 905 | """Test that it allows only tuples with 3 entries + optional attributes dict.""" 906 | 907 | # Less than 3 shouldn't work 908 | with self.assertRaises(AssertionError): 909 | ChoiceEntry(('foo',)) 910 | with self.assertRaises(AssertionError): 911 | ChoiceEntry((1, 'foo')) 912 | 913 | # More than 4 shouldn't work. 914 | with self.assertRaises(AssertionError): 915 | ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2}, 'QUZ')) 916 | with self.assertRaises(AssertionError): 917 | ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2}, 'QUZ', 'QUX')) 918 | 919 | def test_it_should_work_with_django_promises(self): 920 | """Test that ``ChoiceEntry`` class works with django promises, like ``ugettext_lazy``.""" 921 | 922 | # Init django, only needed starting from django 1.7 923 | if django.VERSION >= (1, 7): 924 | django.setup() 925 | 926 | choice_entry = ChoiceEntry(('FOO', 1, ugettext_lazy('foo'))) 927 | 928 | self.assertIsInstance(choice_entry.display, Promise) 929 | self.assertEqual(choice_entry.display, ugettext_lazy('foo')) 930 | 931 | self.assertEqual(choice_entry.display.constant, 'FOO') 932 | self.assertEqual(choice_entry.display.value, 1) 933 | self.assertEqual(choice_entry.display.display, ugettext_lazy('foo')) 934 | 935 | def test_it_should_raise_with_none_value(self): 936 | """Test that it's clear that we don't support None values.""" 937 | 938 | with self.assertRaises(ValueError): 939 | ChoiceEntry(('FOO', None, 'foo')) 940 | 941 | 942 | class AutoDisplayChoicesTestCase(BaseTestCase): 943 | 944 | def test_normal_usage(self): 945 | 946 | MY_CHOICES = AutoDisplayChoices( 947 | ('SIMPLE', 1), 948 | ('NOT_SIMPLE', 2), 949 | ) 950 | 951 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'Simple') 952 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'Not simple') 953 | 954 | def test_pass_transform_function(self): 955 | 956 | MY_CHOICES = AutoDisplayChoices( 957 | ('SIMPLE', 1), 958 | ('NOT_SIMPLE', 2), 959 | display_transform=lambda const: const.lower() 960 | ) 961 | 962 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'simple') 963 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'not_simple') 964 | 965 | def test_override_transform_function(self): 966 | 967 | class MyAutoDisplayChoices(AutoDisplayChoices): 968 | display_transform = staticmethod(lambda const: const.lower()) 969 | 970 | MY_CHOICES = MyAutoDisplayChoices( 971 | ('SIMPLE', 1), 972 | ('NOT_SIMPLE', 2), 973 | ) 974 | 975 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'simple') 976 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'not_simple') 977 | 978 | MY_CHOICES = MyAutoDisplayChoices( 979 | ('SIMPLE', 1), 980 | ('NOT_SIMPLE', 2), 981 | display_transform=lambda const: const.title() 982 | ) 983 | 984 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'Simple') 985 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'Not_Simple') 986 | 987 | def test_adding_subset(self): 988 | 989 | MY_CHOICES = AutoDisplayChoices(('A', 'a'), ('B', 'b'), ('C', 'c')) 990 | MY_CHOICES.add_subset('AB', ['A', 'B']) 991 | 992 | self.assertEqual(MY_CHOICES.AB.constants, { 993 | 'A': MY_CHOICES.A.choice_entry, 994 | 'B': MY_CHOICES.B.choice_entry, 995 | }) 996 | 997 | def test_passing_choice_entry(self): 998 | MY_CHOICES = AutoDisplayChoices( 999 | ChoiceEntry(('A', 'aa', 'aaa', {'foo': 'bar'})), 1000 | ('B', 'bb'), 1001 | ) 1002 | self.assertEqual(MY_CHOICES.A.value, 'aa') 1003 | self.assertEqual(MY_CHOICES.A.display, 'aaa') 1004 | self.assertEqual(MY_CHOICES.B.value, 'bb') 1005 | self.assertEqual(MY_CHOICES.B.display, 'B') 1006 | 1007 | def test_passing_not_only_constant(self): 1008 | MY_CHOICES = AutoDisplayChoices( 1009 | ChoiceEntry(('A', 'aa', 'aaa', {'foo': 'bara'})), 1010 | ('E', 'ee'), 1011 | ('F', 'ff', {'foo': 'barf'}), 1012 | ('G', 'gg', 'ggg'), 1013 | ('H', 'hh', 'hhh', {'foo': 'barh'}), 1014 | ) 1015 | 1016 | self.assertEqual(MY_CHOICES.A.value, 'aa') 1017 | self.assertEqual(MY_CHOICES.A.display, 'aaa') 1018 | self.assertEqual(MY_CHOICES.A.foo, 'bara') 1019 | self.assertEqual(MY_CHOICES.E.value, 'ee') 1020 | self.assertEqual(MY_CHOICES.E.display, 'E') 1021 | self.assertEqual(MY_CHOICES.F.value, 'ff') 1022 | self.assertEqual(MY_CHOICES.F.display, 'F') 1023 | self.assertEqual(MY_CHOICES.F.foo, 'barf') 1024 | self.assertEqual(MY_CHOICES.G.value, 'gg') 1025 | self.assertEqual(MY_CHOICES.G.display, 'ggg') 1026 | self.assertEqual(MY_CHOICES.H.value, 'hh') 1027 | self.assertEqual(MY_CHOICES.H.display, 'hhh') 1028 | self.assertEqual(MY_CHOICES.H.foo, 'barh') 1029 | 1030 | MY_CHOICES.add_subset('ALL', ['A', 'E', 'F', 'G', 'H']) 1031 | self.assertEqual(MY_CHOICES.ALL.constants, MY_CHOICES.constants) 1032 | 1033 | 1034 | class AutoChoicesTestCase(BaseTestCase): 1035 | 1036 | def test_normal_usage(self): 1037 | 1038 | MY_CHOICES = AutoChoices( 1039 | 'SIMPLE', 1040 | ('NOT_SIMPLE', ), 1041 | ) 1042 | 1043 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'Simple') 1044 | self.assertEqual(MY_CHOICES.SIMPLE.value, 'simple') 1045 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'Not simple') 1046 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.value, 'not_simple') 1047 | 1048 | def test_pass_transform_functions(self): 1049 | 1050 | MY_CHOICES = AutoChoices( 1051 | 'SIMPLE', 1052 | ('NOT_SIMPLE', ), 1053 | display_transform=lambda const: const.lower(), 1054 | value_transform=lambda const: const[::-1] 1055 | ) 1056 | 1057 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'simple') 1058 | self.assertEqual(MY_CHOICES.SIMPLE.value, 'ELPMIS') 1059 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'not_simple') 1060 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.value, 'ELPMIS_TON') 1061 | 1062 | def test_override_transform_functions(self): 1063 | 1064 | class MyAutoChoices(AutoChoices): 1065 | display_transform = staticmethod(lambda const: const.lower()) 1066 | value_transform = staticmethod(lambda const: const.lower()[::-1]) 1067 | 1068 | MY_CHOICES = MyAutoChoices( 1069 | 'SIMPLE', 1070 | ('NOT_SIMPLE', ), 1071 | ) 1072 | 1073 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'simple') 1074 | self.assertEqual(MY_CHOICES.SIMPLE.value, 'elpmis') 1075 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'not_simple') 1076 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.value, 'elpmis_ton') 1077 | 1078 | MY_CHOICES = MyAutoChoices( 1079 | 'SIMPLE', 1080 | ('NOT_SIMPLE', ), 1081 | display_transform=lambda const: const.title(), 1082 | value_transform=lambda const: const.title()[::-1] 1083 | ) 1084 | 1085 | self.assertEqual(MY_CHOICES.SIMPLE.display, 'Simple') 1086 | self.assertEqual(MY_CHOICES.SIMPLE.value, 'elpmiS') 1087 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.display, 'Not_Simple') 1088 | self.assertEqual(MY_CHOICES.NOT_SIMPLE.value, 'elpmiS_toN') 1089 | 1090 | def test_adding_subset(self): 1091 | 1092 | MY_CHOICES = AutoChoices('A', 'B', 'C') 1093 | MY_CHOICES.add_subset('AB', ['A', 'B']) 1094 | 1095 | self.assertEqual(MY_CHOICES.AB.constants, { 1096 | 'A': MY_CHOICES.A.choice_entry, 1097 | 'B': MY_CHOICES.B.choice_entry, 1098 | }) 1099 | 1100 | def test_passing_not_only_constant(self): 1101 | MY_CHOICES = AutoChoices( 1102 | ChoiceEntry(('A', 'aa', 'aaa', {'foo': 'bara'})), 1103 | 'B', 1104 | ('C', ), 1105 | ('D', {'foo': 'bard'}), 1106 | ('E', 'ee'), 1107 | ('F', 'ff', {'foo': 'barf'}), 1108 | ('G', 'gg', 'ggg'), 1109 | ('H', 'hh', 'hhh', {'foo': 'barh'}), 1110 | ('I', None, 'iii'), 1111 | ('J', None, 'jjj', {'foo': 'barj'}), 1112 | ) 1113 | 1114 | self.assertEqual(MY_CHOICES.A.value, 'aa') 1115 | self.assertEqual(MY_CHOICES.A.display, 'aaa') 1116 | self.assertEqual(MY_CHOICES.A.foo, 'bara') 1117 | self.assertEqual(MY_CHOICES.B.value, 'b') 1118 | self.assertEqual(MY_CHOICES.B.display, 'B') 1119 | self.assertEqual(MY_CHOICES.C.value, 'c') 1120 | self.assertEqual(MY_CHOICES.C.display, 'C') 1121 | self.assertEqual(MY_CHOICES.D.value, 'd') 1122 | self.assertEqual(MY_CHOICES.D.display, 'D') 1123 | self.assertEqual(MY_CHOICES.D.foo, 'bard') 1124 | self.assertEqual(MY_CHOICES.E.value, 'ee') 1125 | self.assertEqual(MY_CHOICES.E.display, 'E') 1126 | self.assertEqual(MY_CHOICES.F.value, 'ff') 1127 | self.assertEqual(MY_CHOICES.F.display, 'F') 1128 | self.assertEqual(MY_CHOICES.F.foo, 'barf') 1129 | self.assertEqual(MY_CHOICES.G.value, 'gg') 1130 | self.assertEqual(MY_CHOICES.G.display, 'ggg') 1131 | self.assertEqual(MY_CHOICES.H.value, 'hh') 1132 | self.assertEqual(MY_CHOICES.H.display, 'hhh') 1133 | self.assertEqual(MY_CHOICES.H.foo, 'barh') 1134 | self.assertEqual(MY_CHOICES.I.value, 'i') 1135 | self.assertEqual(MY_CHOICES.I.display, 'iii') 1136 | self.assertEqual(MY_CHOICES.J.value, 'j') 1137 | self.assertEqual(MY_CHOICES.J.display, 'jjj') 1138 | self.assertEqual(MY_CHOICES.J.foo, 'barj') 1139 | 1140 | MY_CHOICES.add_subset('ALL', ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']) 1141 | self.assertEqual(MY_CHOICES.ALL.constants, MY_CHOICES.constants) 1142 | 1143 | 1144 | if __name__ == "__main__": 1145 | unittest.main() 1146 | --------------------------------------------------------------------------------