diff --git a/README.rst b/README.rst index df23e3a..09a09a0 100644 --- a/README.rst +++ b/README.rst @@ -1,33 +1,33 @@ -|PyPI Version| |Build Status| +|PyPI Version| |Build Status| |Doc Status| django-extended-choices ======================= -A little application to improve django choices (or whatever: no dependencies) ------------------------------------------------------------------------------ +A little application to improve django choices +---------------------------------------------- -django-extended-choices aims to provide a better (ie for me) and more readable -way of using choices_ in django_ +``django-extended-choices`` aims to provide a better and more readable +way of using choices_ in django_. ------------- Installation ------------ -You can install directly via pip (since version `0.3`):: +You can install directly via pip (since version ```0.3``):: $ pip install django-extended-choices -Or from the github_ repository (`master` branch by default):: +Or from the Github_ repository (``master`` branch by default):: $ git clone git://github.com/twidi/django-extended-choices.git $ cd django-extended-choices $ sudo python setup.py install ------ Usage ----- -The aim is to replace this:: +The aim is to replace this: + +.. code-block:: python STATE_ONLINE = 1 STATE_DRAFT = 2 @@ -51,7 +51,9 @@ The aim is to replace this:: print(Content.objects.filter(state=STATE_ONLINE)) -by this :: +by this: + +.. code-block:: python from extended_choices import Choices @@ -64,42 +66,107 @@ by this :: class Content(models.Model): title = models.CharField(max_length=255) content = models.TextField() - state = models.PositiveSmallIntegerField(choices=STATES.CHOICES, default=STATES.DRAFT) + state = models.PositiveSmallIntegerField(choices=STATES, default=STATES.DRAFT) def __unicode__(self): - return u'Content "%s" (state=%s)' % (self.title, STATES.CHOICES_DICT[self.state]) + return u'Content "%s" (state=%s)' % (self.title, STATES.for_value(self.state).display) - print(Content.objects.filter(state=STATES._ONLINE)) + print(Content.objects.filter(state=STATES.ONLINE)) As you can see there is only one declaration for all states with, for each state, in order: -* the pseudo-constant name which can be used (`STATES.ONLINE` replaces the previous `STATE_ONLINE`) +* the pseudo-constant name which can be used (``STATES.ONLINE`` replaces the previous ``STATE_ONLINE``) * the value to use as key in database - which could equally be a string -* the name to be displayed - and you can wrap the text in `ugettext_lazy()` if you need i18n +* the name to be displayed - and you can wrap the text in ``ugettext_lazy()`` if you need i18n And then, you can use: -* `STATES.CHOICES`, to use with `choices=` in fields declarations -* `STATES.CHOICES_DICT`, a dict to get the value to display with the key used in database -* `STATES.REVERTED_CHOICES_DICT`, a dict to get the key from the displayable value (can be useful in some case) -* `STATES.CHOICES_CONST_DICT`, a dict to get value from constant name -* `STTES.REVERTED_CHOICES_CONST_DICT`, a dict to get constant name from value +* ``STATES``, or ``STATES.choices``, to use with ``choices=`` in fields declarations +* ``STATES.for_constant(constant)``, to get the choice entry from the constant name +* ``STATES.for_value(constant)``, to get the choice entry from the key used in database +* ``STATES.for_display(constant)``, to get the choice entry from the displayable value (can be useful in some case) + +Each choice entry obtained by ``for_constant``, ``for_value`` and ``for_display`` return a tuple as +given to the ``Choices`` constructor, but with additional attributes: + +.. code-block:: python -Note that each of these attribute can be accessed via a dict key (`STATES['ONLINE']` for example) if + >>> entry = STATES.for_constant('ONLINE') + >>> entry == ('ONLINE', 1, 'Online') + True + >>> entry.constant + 'ONLINE' + >>> entry.value + 1 + >>> entry.display + 'Online' + +These attributes are chainable (with a weird example to see chainability): + +.. code-block:: python + + >>> entry.constant.value + 1 + >>> entry.constant.value.value.display.constant.display + 'Online' + +Note that constants can be accessed via a dict key (``STATES['ONLINE']`` for example) if you want to fight your IDE that may warn you about undefined attributes. -You can check whether a value is in `STATES` directly:: +You can check whether a value is in a ``Choices`` object directly: - def is_online(self): - # it's an example, we could have test STATES.ONLINE - return self.state in STATES +.. code-block:: python + + >>> 1 in STATES + True + >>> 42 in STATES + False + + +You can even iterate on a ``Choices`` objects to get choices as seen by Django: + +.. code-block:: python + + >>> for choice in STATES: + ... print(choice) + (1, 'Online') + (2, 'Draf') + (3, 'Offline') + +To get all choice entries as given to the ``Choices`` object, you can use the ``entries`` +attribute: + +.. code-block:: python + + >>> for choice_entry in STATES.entries: + ... print(choice_entry) + ('ONLINE', 1, 'Online'), + ('DRAFT', 2, 'Draft'), + ('OFFLINE', 3, 'Offline'), + +Or the following dicts, using constants, value or display name as keys, and the matching +choice entry as values: + +* ``STATES.constants`` +* ``STATES.values`` +* ``STATES.displays`` + + +.. code-block:: python -`not in` ? Yes, you can use `in` and even iterate on Choices objects ! + >>> STATES.constants['ONLINE'] is STATES.for_constant('ONLINE') + True + >>> STATES.values[2] is STATES.for_value(2) + True + >>> STATES.displays['Offline'] is STATES.for_display('Offline') + True +If you want these dicts to be ordered, you can pass the dict class to use to the +``Choices`` constructor: -If you want dicts to be ordered, you can pass the dict class to use to the `Choices` constructor:: +.. code-block:: python from collections import OrderedDict STATES = Choices( @@ -109,65 +176,141 @@ If you want dicts to be ordered, you can pass the dict class to use to the `Choi dict_class = OrderedDict ) +You can check if a constant, value, or display name exists: -You can create subsets of choices within the sane variable:: +.. code-block:: python - STATES = Choices( - ('ONLINE', 1, 'Online'), - ('DRAFT', 2, 'Draft'), - ('OFFLINE', 3, 'Offline'), + >>> STATES.has_constant('ONLINE') + True + >>> STATES.has_value(1) + True + >>> STATES.has_display('Online') + True + +You can create subsets of choices within the sane ``Choices`` instance: + +.. code-block:: python + + >>> STATES.add_subset('NOT_ONLINE', ('DRAFT', 'OFFLINE',)) + >>> STATES.NOT_ONLINE + (2, 'Draft') + (3, 'Offline') + +Now, ``STATES.NOT_ONLINE`` is a real ``Choices`` instance, with a subset of the main ``STATES`` +instance. + +You can use it to generate choices for when you only want a subset of choices available: + +.. code-block:: python + + offline_state = models.PositiveSmallIntegerField( + choices=STATES.NOT_ONLINE, + default=STATES.DRAFT ) - STATES.add_subset('NOT_ONLINE', ('DRAFT', 'OFFLINE',)) +As the subset is a real ``Choices`` instance, you have the same attributes and methods: -Now, `STATES.NOT_ONLINE` is a real `Choices` object, with a subset of the main `STATES` choices. +.. code-block:: python -You can use it to generate choices for when you only want a subset of choices available:: + >>> STATES.NOT_ONLINE.for_constant('OFFLINE').value + 3 + >>> STATES.NOT_ONLINE.for_value(1).constant + Traceback (most recent call last): + ... + KeyError: 3 + >>> list(STATES.NOT_ONLINE.constants.keys()) + ['DRAFT', 'OFFLINE] + >>> STATES.NOT_ONLINE.has_display('Online') + False - offline_state = models.PositiveSmallIntegerField(choices=STATES.NOT_ONLINE, default=STATES.DRAFT) +You can create as many subsets as you want, reusing the same constants if needed: -You also get: +.. code-block:: python -* `STATES.NOT_ONLINE_DICT`, a dict to get the value to display with the key used in database -* `STATES.REVERTED_NOT_ONLINE_DICT`, a dict to get the key from the displayable value (can be useful in some case) -* `STATES.NOT_ONLINE_CONST_DICT`, a dict to get value from constant name -* `STATES.REVERTED_NOT_ONLINE_CONST_DICT`, a dict to get constant name from value + STATES.add_subset('NOT_OFFLINE', ('ONLINE', 'DRAFT')) -If you want to check membership in subset you could do:: +If you want to check membership in a subset you could do: + +.. code-block:: python def is_online(self): - # it's an example, we could have test STATES.ONLINE + # it's an example, we could have just tested with STATES.ONLINE return self.state not in STATES.NOT_ONLINE_DICT ------ +You can add choice entries in many steps using ``add_choices``, possibly creating subsets at +the same time. + +To construct the same ``Choices`` as before, we could have done: + +.. code-block:: python + + STATES = Choices() + STATES.add_choices( + ('ONLINE', 1, 'Online) + ) + STATES.add_choices( + ('DRAFT', 2, 'Draft'), + ('OFFLINE', 3, 'Offline'), + name='NOT_ONLINE' + ) + Notes ----- -* You also have a very basic field (`NamedExtendedChoiceFormField`) in `extended_choices.fields` which accept constant names instead of values +* You also have a very basic field (``NamedExtendedChoiceFormField```) in ``extended_choices.fields`` which accept constant names instead of values * Feel free to read the source to learn more about this little django app. -* You can declare your choices where you want. My usage is in the models.py file, just before the class declaration. +* You can declare your choices where you want. My usage is in the ``models.py`` file, just before the class declaration. ------- -Future ------- +Compatibility +------------- -* Next version (1.0 ?) will **NOT** be compatible with 0.X ones, because all the names (`*_DICT`) will be renamed to be easier to memorize (using names "ala" `as_dict`...) +The version 1 provides a totally new API, but stays fully compatible with the previous one +(``0.4.1``). So it adds a lot of attributes in each ``Choices`` instance: + +* ``CHOICES`` +* ``CHOICES_DICT`` +* ``REVERTED_CHOICES_DICT`` +* ``CHOICES_CONST_DICT`` + +(And 4 more for each subset) + +If you don't want it, simply set the argument ``retro_compatibility`` to ``False`` when creating +a ``Choices`` instance: + +.. code-block:: python + + STATES = Choices( + ('ONLINE', 1, 'Online'), + ('DRAFT', 2, 'Draft'), + ('OFFLINE', 3, 'Offline'), + retro_compatibility=False + ) + +This flag is currently ``True`` by default, and it will not be changed for at least 6 months +counting from the publication of this version 1 (1st of May, 2015, so until the 1st of November, +2015, AT LEAST, the compatibility will be on by default). + +Then, the flag will stay but will be off by default. To keep compatibility, you'll have to +pass the ``retro_compatibility`` argument and set it to ``True``. + +Then, after another period of 6 months minimum, the flag and all the retro_compatibility code +will be removed (so not before 1st of May, 2016). + +Note that you can stay to a specific version by pinning it in your requirements. -------- License ------- -Licensed under the General Public License (GPL). See the `License` file included +Licensed under the General Public License (GPL). See the ``LICENSE`` file included +Python 3? +--------- ------------ -Source code ------------ +Of course! We support python 2.6, 2.7, 3.3 and 3.4, for Django version 1.4.x to 1.8.x, +respecting the `django matrix`_ (except for python 2.5 and 3.2) -The source code is available on github_ ------ Tests ----- @@ -176,30 +319,67 @@ To run tests from the code source, create a virtualenv or activate one, install python -m extended_choices.tests ---------- -Python 3? ---------- +We also provides some quick doctests in the code documentation. To execute them:: -Of course! We support python 2.6, 2.7, 3.3 and 3.4 + python -m extended_choices.choices -For Django version 1.4.x to 1.8.x, respecting the `django matrix`_ (except for python 2.5 and 3.2) ------- +Source code +----------- + +The source code is available on Github_ + + +Developing +---------- + +If you want to participate to the development of this library, you'll need ``django`` +installed in your virtualenv. If you don't have it, simply run:: + + pip install -r requirements-dev.txt + +Don't forget to run the tests ;) + +Feel free to propose a pull request on Github_! + +A few minutes after your pull request, tests will be executed on TravisCi_ for all the versions +of python and django we support. + + +Documentation +------------- + +You can find the documentation on ReadTheDoc_ + +To update the documentation, you'll need some tools:: + + pip install -r requirements-makedoc.txt + +Then go to the ``docs`` directory, and run:: + + make html + Author ------ Written by Stephane "Twidi" Angel (http://twidi.com), originally for http://www.liberation.fr .. _choices: http://docs.djangoproject.com/en/1.5/ref/models/fields/#choices .. _django: http://www.djangoproject.com/ -.. _github: https://github.com/twidi/django-extended-choices +.. _Github: https://github.com/twidi/django-extended-choices .. _django matrix: https://docs.djangoproject.com/en/1.8/faq/install/#what-python-version-can-i-use-with-django +.. _TravisCi: https://travis-ci.org/twidi/django-extended-choices/pull_requests +.. _RedTheDoc: http://django-extended-choices.readthedocs.org -.. |PyPI Version| image:: https://pypip.in/v/django-extended-choices/badge.png +.. |PyPI Version| image:: https://img.shields.io/pypi/v/django-extended-choices.png :target: https://pypi.python.org/pypi/django-extended-choices + :alt: PyPI Version .. |Build Status| image:: https://travis-ci.org/twidi/django-extended-choices.png :target: https://travis-ci.org/twidi/django-extended-choices + :alt: Build Status on Travis CI +.. |Doc Status| image:: https://readthedocs.org/projects/django-extended-choices/badge/?version=latest + :target: http://django-extended-choices.readthedocs.org + :alt: Documentation Status on ReadTheDoc .. image:: https://d2weczhvl823v0.cloudfront.net/twidi/django-extended-choices/trend.png :alt: Bitdeli badge :target: https://bitdeli.com/free - diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..505b459 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(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/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-extended-choices.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-extended-choices.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/django-extended-choices" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-extended-choices" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/source/README.rst b/docs/source/README.rst new file mode 120000 index 0000000..c768ff7 --- /dev/null +++ b/docs/source/README.rst @@ -0,0 +1 @@ +../../README.rst \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..4e957ac --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +# +# django-extended-choices documentation build configuration file, created by +# sphinx-quickstart on Sat May 2 18:38:53 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +DIRNAME = os.path.dirname(__file__) +sys.path.insert(0, os.path.abspath(os.path.join(DIRNAME, '..', '..'))) +import sphinx_rtd_theme + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.viewcode', + 'sphinxcontrib.napoleon', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'django-extended-choices' +copyright = u'2015, Stephane "Twidi" Angel' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.0' +# The full version, including alpha/beta/rc tags. +release = '1.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +#html_theme = 'default' +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'django-extended-choicesdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'django-extended-choices.tex', u'django-extended-choices Documentation', + u'Stephane "Twidi" Angel', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'django-extended-choices', u'django-extended-choices Documentation', + [u'Stephane "Twidi" Angel'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'django-extended-choices', u'django-extended-choices Documentation', + u'Stephane "Twidi" Angel', 'django-extended-choices', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..eb16543 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,34 @@ +.. django-extended-choices documentation master file, created by + sphinx-quickstart on Sat May 2 18:38:53 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to django-extended-choices's documentation! +=================================================== + +What is it? +----------- + +.. automodule:: extended_choices + + +Documentation contents +---------------------- + +.. toctree:: + :maxdepth: 4 + + Readme + Module "extended_choices.choices" + Module "extended_choices.fields" + Module "extended_choices.helpers" + + + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/source/modules/choices.rst b/docs/source/modules/choices.rst new file mode 100644 index 0000000..a8929ae --- /dev/null +++ b/docs/source/modules/choices.rst @@ -0,0 +1,10 @@ +extended_choices.choices module +=============================== + +.. toctree:: + :maxdepth: 4 + +.. automodule:: extended_choices.choices + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/fields.rst b/docs/source/modules/fields.rst new file mode 100644 index 0000000..7283d21 --- /dev/null +++ b/docs/source/modules/fields.rst @@ -0,0 +1,10 @@ +extended_choices.fields module +============================== + +.. toctree:: + :maxdepth: 4 + +.. automodule:: extended_choices.fields + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/helpers.rst b/docs/source/modules/helpers.rst new file mode 100644 index 0000000..aa66f58 --- /dev/null +++ b/docs/source/modules/helpers.rst @@ -0,0 +1,10 @@ +extended_choices.helpers module +=============================== + +.. toctree:: + :maxdepth: 4 + +.. automodule:: extended_choices.helpers + :members: + :undoc-members: + :show-inheritance: diff --git a/extended_choices/__init__.py b/extended_choices/__init__.py index a8b52b4..4ac1aa9 100644 --- a/extended_choices/__init__.py +++ b/extended_choices/__init__.py @@ -7,4 +7,4 @@ __author__ = 'Stephane "Twidi" Ange;' __contact__ = "s.angel@twidi.com" __homepage__ = "https://pypi.python.org/pypi/django-extended-choices" -__version__ = "0.4.1" \ No newline at end of file +__version__ = "1.0" diff --git a/extended_choices/choices.py b/extended_choices/choices.py index bc46a65..4962b4c 100644 --- a/extended_choices/choices.py +++ b/extended_choices/choices.py @@ -1,155 +1,776 @@ +"""Provides a ``Choices`` class to help using "choices" in Django fields. + +The aim is to replace: + +.. code-block:: python + + STATE_ONLINE = 1 + STATE_DRAFT = 2 + STATE_OFFLINE = 3 + + STATE_CHOICES = ( + (STATE_ONLINE, 'Online'), + (STATE_DRAFT, 'Draft'), + (STATE_OFFLINE, 'Offline'), + ) + + STATE_DICT = dict(STATE_CHOICES) + + class Content(models.Model): + title = models.CharField(max_length=255) + content = models.TextField() + state = models.PositiveSmallIntegerField(choices=STATE_CHOICES, default=STATE_DRAFT) + + def __unicode__(self): + return u'Content "%s" (state=%s)' % (self.title, STATE_DICT[self.state]) + + print(Content.objects.filter(state=STATE_ONLINE)) + +By this: + +.. code-block:: python + + from extended_choices import Choices + + STATES = Choices( + ('ONLINE', 1, 'Online'), + ('DRAFT', 2, 'Draft'), + ('OFFLINE', 3, 'Offline'), + ) + + class Content(models.Model): + title = models.CharField(max_length=255) + content = models.TextField() + state = models.PositiveSmallIntegerField(choices=STATES, default=STATES.DRAFT) + + def __unicode__(self): + return u'Content "%s" (state=%s)' % (self.title, STATES.for_value(self.state).display) + + print(Content.objects.filter(state=STATES.ONLINE)) + + +Notes +----- + +The documentation format in this file is numpydoc_. + +.. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt + +""" + from __future__ import unicode_literals -from builtins import str -from builtins import object +from past.builtins import basestring +from .helpers import ChoiceAttributeMixin, ChoiceEntry -class Choices(object): - """ - Helper class for choices fields in Django. +__all__ = ['Choices'] + + +class Choices(list): + """Helper class for choices fields in Django + + A choice entry has three representation: constant name, value and + display name). So ``Choices`` takes list of such tuples. - A choice value has three representation (constant name, value and - string). So Choices takes list of such tuples. + It's easy to get the constant, value or display name given one of these value. See in + example. - Here is an example of Choices use: + Parameters + ---------- + *choices : list of tuples + It's the list of tuples to add to the ``Choices`` instance, each tuple having three + entries: the constant name, the value, the display name. - >>> CHOICES_ALIGNEMENT = Choices( + A dict could be added as a 4th entry in the tuple to allow setting arbitrary + arguments to the final ``ChoiceEntry`` created for this choice tuple. + + name : string, optional + If set, a subset will be created containing all the constants. It could be used if you + construct your ``Choices`` instance with many calls to ``add_choices``. + dict_class : type, optional + ``dict`` by default, it's the dict class to use to create dictionnaries (``constants``, + ``values`` and ``displays``. Could be set for example to ``OrderedSet``. + retro_compatibility : boolean, optional + ``True`` by default, it makes the ``Choices`` object compatible with version < 1. + + If set to ``False``, all the attributes created for this purpose wont be created. + + Example + ------- + + Start by declaring your ``Choices``: + + >>> ALIGNMENTS = Choices( ... ('BAD', 10, 'bad'), ... ('NEUTRAL', 20, 'neutral'), ... ('CHAOTIC_GOOD', 30, 'chaotic good'), ... ('GOOD', 40, 'good'), ... ) - >>> CHOICES_ALIGNEMENT.BAD + + Then you can use it in a django field, Notice its usage in ``choices`` and ``default``: + + >>> from django.conf import settings + >>> try: + ... settings.configure(DATABASE_ENGINE='sqlite3') + ... except: pass + >>> from django.db.models import IntegerField + >>> field = IntegerField(choices=ALIGNMENTS, # use ``ALIGNMENTS`` or ``ALIGNMENTS.choices``. + ... default=ALIGNMENTS.NEUTRAL) + + The ``Choices`` returns a list as expected by django: + + >>> ALIGNMENTS == ((10, u'bad'), (20, u'neutral'), (30, u'chaotic good'), (40, u'good')) + True + + But represents it with the constants: + + >>> repr(ALIGNMENTS) + "[(u'BAD', 10, u'bad'), (u'NEUTRAL', 20, u'neutral'), (u'CHAOTIC_GOOD', 30, u'chaotic good'), (u'GOOD', 40, u'good')]" + + Use ``choices`` which is a simple list to represent it as such: + + >>> ALIGNMENTS.choices + ((10, u'bad'), (20, u'neutral'), (30, u'chaotic good'), (40, u'good')) + + + And you can access value by their constant, or as you want: + + >>> ALIGNMENTS.BAD 10 - >>> CHOICES_ALIGNEMENT.CHOICES_DICT[30] - 'chaotic good' - >>> CHOICES_ALIGNEMENT.REVERTED_CHOICES_DICT['good'] + >>> ALIGNMENTS.BAD.display + u'bad' + >>> 40 in ALIGNMENTS + True + >>> ALIGNMENTS.has_constant('BAD') + True + >>> ALIGNMENTS.has_value(20) + True + >>> ALIGNMENTS.has_display('good') + True + >>> ALIGNMENTS.for_value(10) + (u'BAD', 10, u'bad') + >>> ALIGNMENTS.for_value(10).constant + u'BAD' + >>> ALIGNMENTS.for_display('good').value 40 - >>> CHOICES_ALIGNEMENT.CHOICES_CONST_DICT['NEUTRAL'] - 20 - >>> CHOICES_ALIGNEMENT.REVERTED_CHOICES_CONST_DICT[20] - 'NEUTRAL' - - As you can see in the above example usage, Choices objects gets five - attributes: - - one attribute built after constant names provided in the tuple (like BAD, - NEUTRAL etc...) - - a CHOICES_DICT that match value to string - - a REVERTED_CHOICES_DICT that match string to value - - a CHOICES_CONST_DICT that match constant to value - - a REVERTED_CHOICES_CONST_DICT that match value to constant - - You can also check membership of choices directly: - - >>> 10 in CHOICES_ALIGNEMENT + >>> ALIGNMENTS.for_constant('NEUTRAL').display + u'neutral' + >>> ALIGNMENTS.constants + {u'CHAOTIC_GOOD': (u'CHAOTIC_GOOD', 30, u'chaotic good'), u'BAD': (u'BAD', 10, u'bad'), u'GOOD': (u'GOOD', 40, u'good'), u'NEUTRAL': (u'NEUTRAL', 20, u'neutral')} + >>> ALIGNMENTS.values + {40: (u'GOOD', 40, u'good'), 10: (u'BAD', 10, u'bad'), 20: (u'NEUTRAL', 20, u'neutral'), 30: (u'CHAOTIC_GOOD', 30, u'chaotic good')} + >>> ALIGNMENTS.displays + {u'bad': (u'BAD', 10, u'bad'), u'good': (u'GOOD', 40, u'good'), u'neutral': (u'NEUTRAL', 20, u'neutral'), u'chaotic good': (u'CHAOTIC_GOOD', 30, u'chaotic good')} + + You can create subsets of choices: + + >>> ALIGNMENTS.add_subset('WESTERN',('BAD', 'GOOD')) + >>> ALIGNMENTS.WESTERN.choices + ((10, u'bad'), (40, u'good')) + >>> ALIGNMENTS.BAD in ALIGNMENTS.WESTERN True - >>> 11 in CHOICES_ALIGNEMENT + >>> ALIGNMENTS.NEUTRAL in ALIGNMENTS.WESTERN False - If you want to create subset of choices, you can - use the add_subset method - This method take a name, and then the constants you want to - have in this subset: + To use it in another field (only the values in the subset will be available), or for checks: - >>> CHOICES_ALIGNEMENT.add_subset('WESTERN',('BAD', 'GOOD')) - >>> CHOICES_ALIGNEMENT.WESTERN - ((10, 'bad'), (40, 'good')) - >>> CHOICES_ALIGNEMENT.BAD in CHOICES_ALIGNEMENT.WESTERN_DICT + >>> def is_western(value): + ... return value in ALIGNMENTS.WESTERN + >>> is_western(40) True - >>> CHOICES_ALIGNEMENT.REVERTED_WESTERN_DICT['bad'] - 10 + """ + # Allow to easily change the ``ChoiceEntry`` class to use in subclasses. + ChoiceEntryClass = ChoiceEntry + def __init__(self, *choices, **kwargs): - # allow usage of collections.OrdereDdict for example + + # Init the list as empty. Entries will be formatted for django and added in + # ``add_choices``. + super(Choices, self).__init__() + + # Class to use for dicts. self.dict_class = kwargs.get('dict_class', dict) - self.CHOICES = tuple() - self.CHOICES_DICT = self.dict_class() - self.REVERTED_CHOICES_DICT = self.dict_class() - # self.CHOICES_CONST_DICT['const'] is the same as getattr(self, 'const') - self.CHOICES_CONST_DICT = self.dict_class() - self.REVERTED_CHOICES_CONST_DICT = self.dict_class() - # For retrocompatibility - name = kwargs.get('name', 'CHOICES') - if name != "CHOICES": - self.add_choices(name, *choices) - else: - self._build_choices(*choices) + # List of ``ChoiceEntry``, one for each choice in this instance. + self.entries = [] + + # Dicts to access ``ChoiceEntry`` instances by constant, value or display value. + self.constants = self.dict_class() + self.values = self.dict_class() + self.displays = self.dict_class() + + # Will be removed one day. See the "compatibility" section in the documentation. + self.retro_compatibility = kwargs.get('retro_compatibility', True) + if self.retro_compatibility: + # Hold the list of tuples as expected by django. + self.CHOICES = tuple() + # To get display strings from their values. + self.CHOICES_DICT = self.dict_class() + # To get values from their display strings. + self.REVERTED_CHOICES_DICT = self.dict_class() + # To get values from their constant names. + self.CHOICES_CONST_DICT = self.dict_class() + # To get constant names from their values. + self.REVERTED_CHOICES_CONST_DICT = self.dict_class() + + # For now this instance is mutable: we need to add the given choices. + self._mutable = True + self.add_choices(*choices, name=kwargs.get('name', None)) + + # Now we can set ``_mutable`` to its correct value. + self._mutable = kwargs.get('mutable', True) + + @property + def choices(self): + """Property that returns a tuple formatted as expected by Django. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.choices + ((1, u'foo'), (2, u'bar')) - def __contains__(self, item): """ - Make smarter to check if a value is valid for a Choices. + return tuple(self) + + def add_choices(self, *choices, **kwargs): + """Add some choices to the current ``Choices`` instance. + + The given choices will be added to the existing choices. + If a ``name`` attribute is passed, a new subset will be created with all the given + choices. + + Note that it's not possible to add new choices to a subset. + + Parameters + ---------- + *choices : list of tuples + It's the list of tuples to add to the ``Choices`` instance, each tuple having three + entries: the constant name, the value, the display name. + + A dict could be added as a 4th entry in the tuple to allow setting arbitrary + arguments to the final ``ChoiceEntry`` created for this choice tuple. + + If the first entry of ``*choices`` is a string, then it will be used as a name for a + new subset that will contain all the given choices. + + name : string + Instead of using the first entry of the ``*choices`` to pass a name of a subset to + create, you can pass it via the ``name`` named argument. + + Example + ------- + + >>> MY_CHOICES = Choices() + >>> MY_CHOICES.add_choices(('ZERO', 0, 'zero')) + >>> MY_CHOICES + [(u'ZERO', 0, u'zero')] + >>> MY_CHOICES.add_choices('SMALL', ('ONE', 1, 'one'), ('TWO', 2, 'two')) + >>> MY_CHOICES + [(u'ZERO', 0, u'zero'), (u'ONE', 1, u'one'), (u'TWO', 2, u'two')] + >>> MY_CHOICES.SMALL + [(u'ONE', 1, u'one'), (u'TWO', 2, u'two')] + >>> MY_CHOICES.add_choices(('THREE', 3, 'three'), ('FOUR', 4, 'four'), name='BIG') + >>> MY_CHOICES + [(u'ZERO', 0, u'zero'), (u'ONE', 1, u'one'), (u'TWO', 2, u'two'), (u'THREE', 3, u'three'), (u'FOUR', 4, u'four')] + >>> MY_CHOICES.BIG + [(u'THREE', 3, u'three'), (u'FOUR', 4, u'four')] + + Raises + ------ + RuntimeError + When the ``Choices`` instance is marked as not mutable, which is the case for subsets. + + ValueError + + * if the subset name is defined as first argument and as named argument. + * if some constants have the same name or the same value. + * if at least one constant or value already exists in the instance. + """ - return item in self.CHOICES_DICT - def __iter__(self): - return self.CHOICES.__iter__() + # It the ``_mutable`` flag is falsy, which is the case for subsets, we refuse to add + # new choices. + if not self._mutable: + raise RuntimeError("This ``Choices`` instance cannot be updated.") - def __getitem__(self, key): - if not hasattr(self, key): - raise KeyError("Key Error : '" + str(key) + "' not found") - return getattr(self, key) + # Check for an optional subset name as the first argument (so the first entry of *choices). + subset_name = None + if choices and isinstance(choices[0], basestring): + subset_name = choices[0] + choices = choices[1:] + + # Check for an optional subset name in the named arguments. + if 'name' in kwargs: + if subset_name: + raise ValueError("The name of the subset cannot be defined as the first " + "argument and also as a named argument") + subset_name = kwargs['name'] + + # Check that each new constant is unique. + constants = [c[0] for c in choices] + constants_doubles = [c for c in constants if constants.count(c) > 1] + if constants_doubles: + raise ValueError("You cannot declare two constants with the same constant name. " + "Problematic constants: %s " % list(set(constants_doubles))) + + # Check that none of the new constants already exists. + bad_constants = set(constants).intersection(self.constants) + if bad_constants: + raise ValueError("You cannot add existing constants. " + "Existing constants: %s." % list(bad_constants)) + + # Check that none of the constant is an existing attributes + bad_constants = [c for c in constants if hasattr(self, c)] + if bad_constants: + raise ValueError("You cannot add constants that already exists as attributes. " + "Existing attributes: %s." % list(bad_constants)) + + # Check that each new value is unique. + values = [c[1] for c in choices] + values_doubles = [c for c in values if values.count(c) > 1] + if values_doubles: + raise ValueError("You cannot declare two choices with the same name." + "Problematic values: %s " % list(set(values_doubles))) + + # Check that none of the new values already exists. + bad_values = set(values).intersection(self.values) + if bad_values: + raise ValueError("You cannot add existing values. " + "Existing values: %s." % list(bad_values)) + + # We can now add eqch choice. + for choice_tuple in choices: + + # Convert the choice tuple in a ``ChoiceEntry`` instance if it's not already done. + # It allows to share choice entries between a ``Choices`` instance and its subsets. + if not isinstance(choice_tuple, self.ChoiceEntryClass): + choice_entry = self.ChoiceEntryClass(choice_tuple) + else: + choice_entry = choice_tuple + + # Append to the main list the choice as expected by django: (value, display name). + self.append(choice_entry.choice) + # And the ``ChoiceEntry`` instance to our own internal list. + self.entries.append(choice_entry) + + # Make the value accessible via an attribute (the constant being its name). + setattr(self, choice_entry.constant, choice_entry.value) - def _build_choices(self, *choices): - CHOICES = list(self.CHOICES) # for retrocompatibility - # we may have to call _build_choices - # more than one time and so append the - # new choices to the already existing ones - for choice in choices: - const, value, string = choice - if hasattr(self, const): - raise ValueError("You cannot declare two constants " - "with the same name! %s " % str(choice)) - if value in self.CHOICES_DICT: - raise ValueError("You cannot declare two constants " - "with the same value! %s " % str(choice)) - setattr(self, const, value) - CHOICES.append((value, string)) - self.CHOICES_DICT[value] = string - self.REVERTED_CHOICES_DICT[string] = value - self.CHOICES_CONST_DICT[const] = value - self.REVERTED_CHOICES_CONST_DICT[value] = const - # CHOICES must be a tuple (to be immutable) - setattr(self, "CHOICES", tuple(CHOICES)) - - def add_choices(self, name="CHOICES", *choices): - self._build_choices(*choices) - if name != "CHOICES": - # for retrocompatibility - # we make a subset with new choices - constants_for_subset = [] - for choice in choices: - const, value, string = choice - constants_for_subset.append(const) - self.add_subset(name, constants_for_subset) + # Fill dicts to access the ``ChoiceEntry`` instance by its constant, value or display.. + self.constants[choice_entry.constant] = choice_entry + self.values[choice_entry.value] = choice_entry + self.displays[choice_entry.display] = choice_entry + + # Will be removed one day. See the "compatibility" section in the documentation. + if self.retro_compatibility: + # To get display strings from their values. + self.CHOICES_DICT[choice_entry.value] = choice_entry.display + # To get values from their display strings. + self.REVERTED_CHOICES_DICT[choice_entry.display] = choice_entry.value + # To get values from their constant names. + self.CHOICES_CONST_DICT[choice_entry.constant] = choice_entry.value + # To get constant names from their values. + self.REVERTED_CHOICES_CONST_DICT[choice_entry.value] = choice_entry.constant + + # Will be removed one day. See the "compatibility" section in the documentation. + if self.retro_compatibility: + # Hold the list of tuples as expected by django. + self.CHOICES = self.choices + + # If we have a subset name, create a new subset with all the given constants. + if subset_name and (not self.retro_compatibility or subset_name != 'CHOICES'): + self.add_subset(subset_name, constants) def add_subset(self, name, constants): + """Add a subset of entries under a defined name. + + This allow to defined a "sub choice" if a django field need to not have the whole + choice available. + + The sub-choice is a new ``Choices`` instance, with only the wanted the constant from the + main ``Choices`` (each "choice entry" in the subset is shared from the main ``Choices``. + The sub-choice is accessible from the main ``Choices`` by an attribute having the given + name. + + Parameters + ---------- + name : string + Name of the attribute that will old the new ``Choices`` instance. + constants: list + List of the constants name of this ``Choices`` object to make available in the subset. + + + Example + ------- + + >>> STATES = Choices( + ... ('ONLINE', 1, 'Online'), + ... ('DRAFT', 2, 'Draft'), + ... ('OFFLINE', 3, 'Offline'), + ... ) + >>> STATES + [(u'ONLINE', 1, u'Online'), (u'DRAFT', 2, u'Draft'), (u'OFFLINE', 3, u'Offline')] + >>> STATES.add_subset('NOT_ONLINE', ('DRAFT', 'OFFLINE',)) + >>> STATES.NOT_ONLINE + [(u'DRAFT', 2, u'Draft'), (u'OFFLINE', 3, u'Offline')] + >>> STATES.NOT_ONLINE.DRAFT + 2 + >>> STATES.NOT_ONLINE.for_constant('DRAFT') is STATES.for_constant('DRAFT') + True + >>> STATES.NOT_ONLINE.ONLINE + Traceback (most recent call last): + ... + AttributeError: 'Choices' object has no attribute 'ONLINE' + + + Raises + ------ + ValueError + + * If ``name`` is already an attribute of the ``Choices`` instance. + * If a constant is not defined as a constant in the ``Choices`` instance. + + """ + + # Ensure that the name is not already used as an attribute. if hasattr(self, name): - raise ValueError("Cannot use %s as a subset name." + raise ValueError("Cannot use '%s' as a subset name. " "It's already an attribute." % name) - SUBSET = [] - SUBSET_DICT = self.dict_class() # retrocompatibility - REVERTED_SUBSET_DICT = self.dict_class() # retrocompatibility - SUBSET_CONST_DICT = self.dict_class() - REVERTED_SUBSET_CONST_DICT = self.dict_class() - for const in constants: - value = getattr(self, const) - string = self.CHOICES_DICT[value] - SUBSET.append((value, string)) - SUBSET_DICT[value] = string # retrocompatibility - REVERTED_SUBSET_DICT[string] = value # retrocompatibility - SUBSET_CONST_DICT[const] = value - REVERTED_SUBSET_CONST_DICT[value] = const - # Maybe we should make a @property instead - setattr(self, name, tuple(SUBSET)) - - # For retrocompatibility - setattr(self, '%s_DICT' % name, SUBSET_DICT) - setattr(self, 'REVERTED_%s_DICT' % name, REVERTED_SUBSET_DICT) - setattr(self, '%s_CONST_DICT' % name, SUBSET_CONST_DICT) - setattr(self, 'REVERTED_%s_CONST_DICT' % name, REVERTED_SUBSET_CONST_DICT) + + # Ensure that all passed constants exists as such in the list of available constants. + bad_constants = set(constants).difference(self.constants) + if bad_constants: + raise ValueError("All constants in subsets should be in parent choice. " + "Missing constants: %s." % list(bad_constants)) + + # Keep only entries we asked for. + choice_entries = [self.constants[c] for c in constants] + + # Create a new ``Choices`` instance with the limited set of entries, and pass the other + # configuration attributes to share the same behavior as the current ``Choices``. + # Also we set ``mutable`` to False to disable the possibility to add new choices to the + # subset. + subset = self.__class__( + *choice_entries, **{ + 'dict_class': self.dict_class, + 'retro_compatibility': self.retro_compatibility, + 'mutable': False, + }) + + # Make the subset accessible via an attribute. + setattr(self, name, subset) + + # Will be removed one day. See the "compatibility" section in the documentation. + if self.retro_compatibility: + # To get display strings from their values. + SUBSET_DICT = self.dict_class() + # To get values from their display strings. + REVERTED_SUBSET_DICT = self.dict_class() + # To get values from their constant names. + SUBSET_CONST_DICT = self.dict_class() + # To get constant names from their values. + REVERTED_SUBSET_CONST_DICT = self.dict_class() + + for choice_entry in choice_entries: + SUBSET_DICT[choice_entry.value] = choice_entry.display + REVERTED_SUBSET_DICT[choice_entry.display] = choice_entry.value + SUBSET_CONST_DICT[choice_entry.constant] = choice_entry.value + REVERTED_SUBSET_CONST_DICT[choice_entry.value] = choice_entry.constant + + # Prefix each quick-access dict by the name of the subset + setattr(self, '%s_DICT' % name, SUBSET_DICT) + setattr(self, 'REVERTED_%s_DICT' % name, REVERTED_SUBSET_DICT) + setattr(self, '%s_CONST_DICT' % name, SUBSET_CONST_DICT) + setattr(self, 'REVERTED_%s_CONST_DICT' % name, REVERTED_SUBSET_CONST_DICT) + + + def for_constant(self, constant): + """Returns the ``ChoiceEntry`` for the given constant. + + Parameters + ---------- + constant: string + Name of the constant for which we want the choice entry. + + Returns + ------- + ChoiceEntry + The instance of ``ChoiceEntry`` for the given constant. + + Raises + ------ + KeyError + If the constant is not an existing one. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.for_constant('FOO') + (u'FOO', 1, u'foo') + >>> MY_CHOICES.for_constant('FOO').value + 1 + >>> MY_CHOICES.for_constant('QUX') + Traceback (most recent call last): + ... + KeyError: u'QUX' + + """ + + return self.constants[constant] + + def for_value(self, value): + """Returns the ``ChoiceEntry`` for the given value. + + Parameters + ---------- + value: ? + Value for which we want the choice entry. + + Returns + ------- + ChoiceEntry + The instance of ``ChoiceEntry`` for the given value. + + Raises + ------ + KeyError + If the value is not an existing one. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.for_value(1) + (u'FOO', 1, u'foo') + >>> MY_CHOICES.for_value(1).display + u'foo' + >>> MY_CHOICES.for_value(3) + Traceback (most recent call last): + ... + KeyError: 3 + + """ + + return self.values[value] + + def for_display(self, display): + """Returns the ``ChoiceEntry`` for the given display name. + + Parameters + ---------- + display: string + Display name for which we want the choice entry. + + Returns + ------- + ChoiceEntry + The instance of ``ChoiceEntry`` for the given display name. + + Raises + ------ + KeyError + If the display name is not an existing one. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.for_display('foo') + (u'FOO', 1, u'foo') + >>> MY_CHOICES.for_display('foo').constant + u'FOO' + >>> MY_CHOICES.for_display('qux') + Traceback (most recent call last): + ... + KeyError: u'qux' + + """ + + return self.displays[display] + + def has_constant(self, constant): + """Check if the current ``Choices`` object has the given constant. + + Parameters + ---------- + constant: string + Name of the constant we want to check.. + + Returns + ------- + boolean + ``True`` if the constant is present, ``False`` otherwise. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.has_constant('FOO') + True + >>> MY_CHOICES.has_constant('QUX') + False + + """ + + return constant in self.constants + + def has_value(self, value): + """Check if the current ``Choices`` object has the given value. + + Parameters + ---------- + value: ? + Value we want to check. + + Returns + ------- + boolean + ``True`` if the value is present, ``False`` otherwise. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.has_value(1) + True + >>> MY_CHOICES.has_value(3) + False + + """ + + return value in self.values + + def has_display(self, display): + """Check if the current ``Choices`` object has the given display name. + + Parameters + ---------- + display: string + Display name we want to check.. + + Returns + ------- + boolean + ``True`` if the display name is present, ``False`` otherwise. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES.has_display('foo') + True + >>> MY_CHOICES.has_display('qux') + False + + """ + + return display in self.displays + + def __contains__(self, item): + """Check if the current ``Choices`` object has the given value. + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> 1 in MY_CHOICES + True + >>> 3 in MY_CHOICES + False + + """ + + return self.has_value(item) + + def __getitem__(self, key): + """Return the attribute having the given name for the current instance + + It allows for example to retrieve constant by keys instead of by attributes (as constants + are set as attributes to easily get the matching value.) + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES['FOO'] + 1 + >>> MY_CHOICES['constants'] is MY_CHOICES.constants + True + + """ + + # If the key is an int, call ``super`` to access the list[key] item + if isinstance(key, int): + return super(Choices, self).__getitem__(key) + + if not hasattr(self, key): + raise KeyError("Attribute '%s' not found." % key) + + return getattr(self, key) + + def __repr__(self): + """String representation of this ``Choices`` instance. + + Notes + ----- + It will represent the data passed and store in ``self.entries``, not the data really + stored in the base list object, which is in the format expected by django, ie a list of + tuples with only value and display name. + Here, we display everything. + + Example + ------- + + >>> Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + [(u'FOO', 1, u'foo'), (u'BAR', 2, u'bar')] + + """ + + return '%s' % self.entries + + def __eq__(self, other): + """Override to allow comparison with a tuple of choices, not only a list. + + It also allow to compare with default django choices, ie (value, display name), or + with the format of ``Choices``, ie (constant name, value, display_name). + + Example + ------- + + >>> MY_CHOICES = Choices(('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + >>> MY_CHOICES == [('FOO', 1, 'foo'), ('BAR', 2, 'bar')] + True + >>> MY_CHOICES == (('FOO', 1, 'foo'), ('BAR', 2, 'bar')) + True + >>> MY_CHOICES == [(1, 'foo'), (2, 'bar')] + True + >>> MY_CHOICES == ((1, 'foo'), (2, 'bar')) + True + + """ + + # Convert to list if it's a tuple. + if isinstance(other, tuple): + other = list(other) + + # Compare to the list of entries if the first element seems to have a constant + # name as first entry. + if other and len(other[0]) == 3: + return self.entries == other + + return super(Choices, self).__eq__(other) + + # TODO: implement __iadd__ and __add__ + if __name__ == '__main__': import doctest - doctest.testmod() + doctest.testmod(report=True) + from . import helpers + doctest.testmod(m=helpers, report=True) \ No newline at end of file diff --git a/extended_choices/fields.py b/extended_choices/fields.py index b08762a..6c5cae4 100644 --- a/extended_choices/fields.py +++ b/extended_choices/fields.py @@ -1,3 +1,14 @@ +"""Provides a form field for django to use constants instead of values as available values. + +Notes +----- + +The documentation format in this file is `numpydoc`_. + +.. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt + +""" + from __future__ import unicode_literals from past.builtins import basestring @@ -6,34 +17,47 @@ from . import Choices + class NamedExtendedChoiceFormField(forms.Field): - """ - Special fields, where the values are the constants names instead of the - integer (could be useful for example for an API). - Should not be very userful in normal HTML form life, but we need one because - we use forms to do REST parameters validation. + """Field to use with choices where values are constant names instead of choice values. + + Should not be very useful in normal HTML form, but if API validation is done via a form, it + will to have more readable constants in the API that values """ def __init__(self, choices, *args, **kwargs): - """ - Choices must be instance of ``extended_choices.Choice``. - """ + """Override to ensure that the ``choices`` argument is a ``Choices`` object.""" + super(NamedExtendedChoiceFormField, self).__init__(*args, **kwargs) + if not isinstance(choices, Choices): - raise ValueError("choices must be an instance of extended_choices.Choices") + raise ValueError("`choices` must be an instance of `extended_choices.Choices`.") + self.choices = choices def to_python(self, value): - """ - Convert the named value to the internal integer. - """ - # is_required is checked in validate - if value is None: return None + """Convert the constant to the real choice value.""" + + # ``is_required`` is already checked in ``validate``. + if value is None: + return None + + # Validate the type. if not isinstance(value, basestring): - raise forms.ValidationError("Invalid value format.") + raise forms.ValidationError( + "Invalid value type (should be a string).", + code='invalid-choice-type', + ) + + # Get the constant from the choices object, raising if it doesn't exist. try: - final = getattr(self.choices, value.upper()) + final = getattr(self.choices, value) except AttributeError: - raise forms.ValidationError("Invalid value.") + available = '[%s]' % ', '.join(self.choices.constants) + raise forms.ValidationError( + "Invalid value (not in available choices. Available ones are: %s" % available, + code='non-existing-choice', + ) + return final diff --git a/extended_choices/helpers.py b/extended_choices/helpers.py new file mode 100644 index 0000000..1617839 --- /dev/null +++ b/extended_choices/helpers.py @@ -0,0 +1,225 @@ +"""Provides classes used to construct a full ``Choices`` instance. + +Notes +----- + +The documentation format in this file is numpydoc_. + +.. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt + +""" + +from __future__ import unicode_literals + +from builtins import object + + +class ChoiceAttributeMixin(object): + """Base class to represent an attribute of a ``ChoiceEntry``. + + Used for ``constant``, ``name``, and ``display``. + + It must be used as a mixin with another type, and the final class will be a type with + added attributes to access the ``ChoiceEntry`` instance and its attributes. + + Attributes + ---------- + choice_entry : instance of ``ChoiceEntry`` + The ``ChoiceEntry`` instance that hold the current value, used to access its constant, + value and display name. + constant : property + Returns the choice field holding the constant of the attached ``ChoiceEntry``. + value : property + Returns the choice field holding the value of the attached ``ChoiceEntry``. + display : property + Returns the choice field holding the display name of the attached ``ChoiceEntry``. + + Example + ------- + + Classes can be created manually: + + >>> class IntChoiceAttribute(ChoiceAttributeMixin, int): pass + >>> field = IntChoiceAttribute(1, ChoiceEntry(('FOO', 1, 'foo'))) + >>> field + 1 + >>> field.constant, field.value, field.display + (u'FOO', 1, u'foo') + >>> field.choice_entry + (u'FOO', 1, u'foo') + + Or via the ``get_class_for_value`` class method: + + >>> klass = ChoiceAttributeMixin.get_class_for_value(1.5) + >>> klass.__name__ + 'FloatChoiceAttribute' + >>> float in klass.mro() + True + + """ + + def __new__(cls, *args, **kwargs): + """Construct the object (the other class used with this mixin). + + Notes + ----- + + Only passes the very first argument to the ``super`` constructor. + All others are not needed for the other class, only for this mixin. + + """ + return super(ChoiceAttributeMixin, cls).__new__(cls, *args[:1]) + + def __init__(self, value, choice_entry): + """Initiate the object to save the value and the choice entry. + + Parameters + ---------- + value : ? + Value to pass to the ``super`` constructor (for the other class using this mixin) + choice_entry: ChoiceEntry + The ``ChoiceEntry`` instance that hold the current value, used to access its constant, + value and display name. + + Notes + ----- + + Call the ``super`` constructor with only the first value, as the other class doesn't + expect the ``choice_entry`` parameter. + + """ + + super(ChoiceAttributeMixin, self).__init__() + self.choice_entry = choice_entry + + @property + def constant(self): + """Property that returns the ``constant`` attribute of the attached ``ChoiceEntry``.""" + return self.choice_entry.constant + + @property + def value(self): + """Property that returns the ``value`` attribute of the attached ``ChoiceEntry``.""" + return self.choice_entry.value + + @property + def display(self): + """Property that returns the ``display`` attribute of the attached ``ChoiceEntry``.""" + return self.choice_entry.display + + @classmethod + def get_class_for_value(cls, value): + """Class method to construct a class based on this mixin and the type of the given value. + + Parameters + ---------- + value: ? + The value from which to extract the type to create the new class. + + Notes + ----- + The create classes are cached (in ``cls.__classes_by_type``) to avoid recreating already + created classes. + """ + type_ = value.__class__ + + # Create a new class only if it wasn't already created for this type. + if type_ not in cls._classes_by_type: + # Compute the name of the class with the name of the type. + class_name = str('%sChoiceAttribute' % type_.__name__.capitalize()) + # Create a new class and save it in the cache. + cls._classes_by_type[type_] = type(class_name, (cls, type_), {}) + + # Return the class from the cache based on the type. + return cls._classes_by_type[type_] + + _classes_by_type = {} + + +class ChoiceEntry(tuple): + """Represents a choice in a ``Choices`` object, with easy access to its attribute. + + Expecting a tuple with three entries. (constant, value, display name), it will add three + attributes to access then: ``constant``, ``value`` and ``display``. + + By passing a dict after these three first entries, in the tuple, it's alose possible to + add some other attributes to the ``ChoiceEntry` instance``. + + Parameters + ---------- + tuple_ : tuple + A tuple with three entries, the name of the constant, the value, and the display name. + A dict could be added as a fourth entry to add additional attributes. + + + Example + ------- + + >>> entry = ChoiceEntry(('FOO', 1, 'foo')) + >>> entry + (u'FOO', 1, u'foo') + >>> (entry.constant, entry.value, entry.display) + (u'FOO', 1, u'foo') + >>> entry.choice + (1, u'foo') + + You can also pass attributes to add to the instance to create: + + >>> entry = ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2})) + >>> entry + (u'FOO', 1, u'foo') + >>> entry.bar + 1 + >>> entry.baz + 2 + + Raises + ------ + AssertionError + If the number of entries in the tuple is not expected. Must be 3 or 4. + + """ + + # Allow to easily change the mixin to use in subclasses. + ChoiceAttributeMixin = ChoiceAttributeMixin + + def __new__(cls, tuple_): + """Construct the tuple with 3 entries, and save optional attributes from the 4th one.""" + + # Ensure we have exactly 3 entries in the tuple and an optional dict. + assert 3 <= len(tuple_) <= 4, 'Invalid number of entries in %s' % (tuple_,) + + # Call the ``tuple`` constructor with only the real tuple entries. + obj = super(ChoiceEntry, cls).__new__(cls, tuple_[:3]) + + # Save all special attributes. + obj.constant = obj._get_choice_attribute(tuple_[0]) + obj.value = obj._get_choice_attribute(tuple_[1]) + obj.display = obj._get_choice_attribute(tuple_[2]) + + # Add an attribute holding values as expected by django. + obj.choice = (obj.value, obj.display) + + # Add additional attributes. + if len(tuple_) == 4: + for key, value in tuple_[3].items(): + setattr(obj, key, value) + + return obj + + def _get_choice_attribute(self, value): + """Get a choice attribute for the given value. + + Parameters + ---------- + value: ? + The value for which we want a choice attribute. + + Returns + ------- + An instance of a class based on ``ChoiceAttributeMixin`` for the given value. + + """ + + klass = self.ChoiceAttributeMixin.get_class_for_value(value) + return klass(value, self) diff --git a/extended_choices/models.py b/extended_choices/models.py deleted file mode 100644 index e69de29..0000000 diff --git a/extended_choices/tests.py b/extended_choices/tests.py index 571564b..70bb3df 100644 --- a/extended_choices/tests.py +++ b/extended_choices/tests.py @@ -1,111 +1,782 @@ #!/usr/bin/env python +"""Tests for the ``extended_choices`` module. + +Notes +----- + +The documentation format in this file is numpydoc_. + +.. _numpydoc: https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt + +""" + from __future__ import unicode_literals -import sys +import os, sys + if sys.version_info >= (2, 7): import unittest else: import unittest2 as unittest -from django import forms +import django + +# Minimal django conf to test a real field. +from django.conf import settings +settings.configure(DATABASE_ENGINE='sqlite3') + +from django.core.exceptions import ValidationError from .choices import Choices from .fields import NamedExtendedChoiceFormField +from .helpers import ChoiceAttributeMixin, ChoiceEntry -MY_CHOICES = Choices( - ('ONE', 1, 'One for the money'), - ('TWO', 2, 'Two for the show'), - ('THREE', 3, 'Three to get ready'), -) -MY_CHOICES.add_subset("ODD", ("ONE", "THREE")) +class BaseTestCase(unittest.TestCase): + """Base test case that define a test ``Choices`` instance with a subset.""" + + def setUp(self): + super(BaseTestCase, self).setUp() + + self.MY_CHOICES = None + self.init_choices() + + def init_choices(self): + + self.MY_CHOICES = Choices( + ('ONE', 1, 'One for the money'), + ('TWO', 2, 'Two for the show'), + ('THREE', 3, 'Three to get ready'), + ) + self.MY_CHOICES.add_subset("ODD", ("ONE", "THREE")) + + +class FieldsTestCase(BaseTestCase): + """Tests of the ``NamedExtendedChoiceFormField`` field.""" + + def test_enforce_passing_a_choices(self): + """Test that the field class only accepts a ``Choices`` object for its ``choices`` arg.""" + + # Test with default choices tuple. + with self.assertRaises(ValueError): + NamedExtendedChoiceFormField(choices=( + ('ONE', 1, 'One for the money'), + ('TWO', 2, 'Two for the show'), + ('THREE', 3, 'Three to get ready'), + )) + + # Test with something else unrelated. + with self.assertRaises(ValueError): + NamedExtendedChoiceFormField(choices=1) + + # Test with a ``Choices`` object. + field = NamedExtendedChoiceFormField(choices=self.MY_CHOICES) + self.assertEqual(field.choices, self.MY_CHOICES) + + def test_named_extended_choice_form_field_validation(self): + """Test that it only validation when it receives an existing constant name.""" + + field = NamedExtendedChoiceFormField(choices=self.MY_CHOICES) + + # Should respect the constant case. + self.assertEqual(field.clean('ONE'), 1) + self.assertEqual(field.clean('TWO'), 2) + + with self.assertRaises(ValidationError) as raise_context: + field.clean('one') + self.assertEqual(raise_context.exception.code, 'non-existing-choice') + + # Should not validate with non-existing constant. + with self.assertRaises(ValidationError) as raise_context: + field.clean('FOUR') + self.assertEqual(raise_context.exception.code, 'non-existing-choice') + + # Shoud fail early if not a string. + with self.assertRaises(ValidationError) as raise_context: + field.clean(1) + self.assertEqual(raise_context.exception.code, 'invalid-choice-type') + + +class ChoicesTestCase(BaseTestCase): + """Test the ``Choices`` class.""" + + + def test_should_behave_as_expected_by_django(self): + """Test that it can be used by django, ie a list of tuple (value, display name).""" + + expected = ( + (1, 'One for the money'), + (2, 'Two for the show'), + (3, 'Three to get ready'), + ) + + # Test access to the whole expected tuple + self.assertEqual(self.MY_CHOICES, expected) + self.assertEqual(self.MY_CHOICES.choices, expected) + + # Test access to each tuple + self.assertEqual(self.MY_CHOICES[0], expected[0]) + self.assertEqual(self.MY_CHOICES[2], expected[2]) + + def test_should_be_accepted_by_django(self): + """Test that a django field really accept a ``Choices`` instance.""" + + from django.db.models import IntegerField + field = IntegerField(choices=self.MY_CHOICES, default=self.MY_CHOICES.ONE) + + self.assertEqual(field.choices, self.MY_CHOICES) + + # No errors in ``_check_choices_``, Django 1.7+ + if django.VERSION >= (1, 7): + self.assertEqual(field._check_choices(), []) + + # Test validation + field.validate(1, None) + + with self.assertRaises(ValidationError) as raise_context: + field.validate(4, None) + + # Check exception code, only in Django 1.6+ + if django.VERSION >= (1, 6): + self.assertEqual(raise_context.exception.code, 'invalid_choice') + + + def test_constants_attributes_should_return_values(self): + """Test that each constant is an attribute returning the value.""" + + self.assertEqual(self.MY_CHOICES.ONE, 1) + self.assertEqual(self.MY_CHOICES.THREE, 3) + + with self.assertRaises(AttributeError): + self.MY_CHOICES.FOUR + + def test_attributes_should_be_accessed_by_keys(self): + """Test that each constant is accessible by key.""" + + self.assertIs(self.MY_CHOICES['ONE'], self.MY_CHOICES.ONE) + self.assertIs(self.MY_CHOICES['THREE'], self.MY_CHOICES.THREE) + + with self.assertRaises(KeyError): + self.MY_CHOICES['FOUR'] + + def test_entries(self): + """Test that ``entries`` holds ``ChoiceEntry`` instances with correct attributes.""" + + self.assertIsInstance(self.MY_CHOICES.entries[0], ChoiceEntry) + self.assertEqual(self.MY_CHOICES.entries[0], ('ONE', 1, 'One for the money')) + self.assertEqual(self.MY_CHOICES.entries[0].constant, 'ONE') + self.assertEqual(self.MY_CHOICES.entries[0].value, 1) + self.assertEqual(self.MY_CHOICES.entries[0].display, 'One for the money') + + self.assertIsInstance(self.MY_CHOICES.entries[2], ChoiceEntry) + self.assertEqual(self.MY_CHOICES.entries[2], ('THREE', 3, 'Three to get ready')) + self.assertEqual(self.MY_CHOICES.entries[2].constant, 'THREE') + self.assertEqual(self.MY_CHOICES.entries[2].value, 3) + self.assertEqual(self.MY_CHOICES.entries[2].display, 'Three to get ready') + + def test_dicts(self): + """Test that ``constants``, ``values`` and ``displays`` dicts behave as expected.""" + self.assertIsInstance(self.MY_CHOICES.constants, dict) + self.assertIsInstance(self.MY_CHOICES.values, dict) + self.assertIsInstance(self.MY_CHOICES.displays, dict) + + self.assertDictEqual(self.MY_CHOICES.constants, { + 'ONE': ('ONE', 1, 'One for the money'), + 'TWO': ('TWO', 2, 'Two for the show'), + 'THREE': ('THREE', 3, 'Three to get ready'), + }) + self.assertDictEqual(self.MY_CHOICES.values, { + 1: ('ONE', 1, 'One for the money'), + 2: ('TWO', 2, 'Two for the show'), + 3: ('THREE', 3, 'Three to get ready'), + }) + self.assertDictEqual(self.MY_CHOICES.displays, { + 'One for the money': ('ONE', 1, 'One for the money'), + 'Two for the show': ('TWO', 2, 'Two for the show'), + 'Three to get ready': ('THREE', 3, 'Three to get ready'), + }) + + def test_adding_choices(self): + """Test that we can add choices to an existing ``Choices`` instance.""" + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('FIVE', 5, '... but Five is not in the song'), + ) + + # Test django expected tuples + expected = ( + (1, 'One for the money'), + (2, 'Two for the show'), + (3, 'Three to get ready'), + (4, 'And four to go'), + (5, '... but Five is not in the song'), + ) + + self.assertEqual(self.MY_CHOICES, expected) + self.assertEqual(self.MY_CHOICES.choices, expected) + + # Test entries + + self.assertIsInstance(self.MY_CHOICES.entries[3], ChoiceEntry) + self.assertEqual(self.MY_CHOICES.entries[3].constant, 'FOUR') + self.assertEqual(self.MY_CHOICES.entries[3].value, 4) + self.assertEqual(self.MY_CHOICES.entries[3].display, 'And four to go') + + self.assertIsInstance(self.MY_CHOICES.entries[4], ChoiceEntry) + self.assertEqual(self.MY_CHOICES.entries[4].constant, 'FIVE') + self.assertEqual(self.MY_CHOICES.entries[4].value, 5) + self.assertEqual(self.MY_CHOICES.entries[4].display, '... but Five is not in the song') + + # Test dicts + self.assertEqual(len(self.MY_CHOICES.constants), 5) + self.assertEqual(len(self.MY_CHOICES.values), 5) + self.assertEqual(len(self.MY_CHOICES.displays), 5) + + self.assertEqual(self.MY_CHOICES.constants['FOUR'], + ('FOUR', 4, 'And four to go')) + self.assertEqual(self.MY_CHOICES.constants['FIVE'], + ('FIVE', 5, '... but Five is not in the song')) + self.assertEqual(self.MY_CHOICES.values[4], + ('FOUR', 4, 'And four to go')) + self.assertEqual(self.MY_CHOICES.values[5], + ('FIVE', 5, '... but Five is not in the song')) + self.assertEqual(self.MY_CHOICES.displays['And four to go'], + ('FOUR', 4, 'And four to go')) + self.assertEqual(self.MY_CHOICES.displays['... but Five is not in the song'], + ('FIVE', 5, '... but Five is not in the song')) + + def test_adding_choice_to_create_subset(self): + """Test that we can create a subset while adding choices. + + This test test both ways of setting a name for a subset to create when adding choices, + resetting ``MY_CHOICES`` between both tests, and calling ``test_subset`` in both cases. -class FieldsTests(unittest.TestCase): - """ - Testing the fields - """ - def test_named_extended_choice_form_field(self): - """ - Should accept only string, and should return the integer value. """ - field = NamedExtendedChoiceFormField(choices=MY_CHOICES) - # Should work with lowercase - self.assertEqual(field.clean("one"), 1) - # Should word with uppercase - self.assertEqual(field.clean("ONE"), 1) - # Should not validate with wrong name - self.assertRaises(forms.ValidationError, field.clean, "FOUR") - # Should not validate with integer - self.assertRaises(forms.ValidationError, field.clean, 1) - - -class ChoicesTests(unittest.TestCase): - """ - Testing the choices + + def test_subset(): + # Quick check of the whole ``Choices`` (see ``test_adding_choices`` for full test) + self.assertEqual(len(self.MY_CHOICES), 5) + self.assertEqual(len(self.MY_CHOICES.entries), 5) + self.assertEqual(len(self.MY_CHOICES.constants), 5) + + # Check that we have a subset + self.assertIsInstance(self.MY_CHOICES.EXTENDED, Choices) + + # And that it contains the added choices (see ``test_creating_subset`` for full test) + self.assertEqual(self.MY_CHOICES.EXTENDED, ( + (4, 'And four to go'), + (5, '... but Five is not in the song'), + )) + + + # First test by setting the subset as first argument + self.MY_CHOICES.add_choices('EXTENDED', + ('FOUR', 4, 'And four to go'), + ('FIVE', 5, '... but Five is not in the song'), + ) + + test_subset() + + # Reset to remove added choices and subset + self.init_choices() + + # Then test by setting the subset as named argument + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('FIVE', 5, '... but Five is not in the song'), + name='EXTENDED' + ) + + test_subset() + + # Using both first argument and a named argument should fail + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + 'EXTENDED', + ('FOUR', 4, 'And four to go'), + ('FIVE', 5, '... but Five is not in the song'), + name='EXTENDED' + ) + + def test_creating_subset(self): + """Test that we can add subset of choices.""" + + self.assertIsInstance(self.MY_CHOICES.ODD, Choices) + + # Test django expected tuples + expected = ( + (1, 'One for the money'), + (3, 'Three to get ready'), + ) + + self.assertEqual(self.MY_CHOICES.ODD, expected) + self.assertEqual(self.MY_CHOICES.ODD.choices, expected) + + # Test entries + self.assertEqual(len(self.MY_CHOICES.ODD.entries), 2) + self.assertIsInstance(self.MY_CHOICES.ODD.entries[0], ChoiceEntry) + self.assertEqual(self.MY_CHOICES.ODD.entries[0].constant, 'ONE') + self.assertEqual(self.MY_CHOICES.ODD.entries[0].value, 1) + self.assertEqual(self.MY_CHOICES.ODD.entries[0].display, 'One for the money') + + self.assertIsInstance(self.MY_CHOICES.ODD.entries[1], ChoiceEntry) + self.assertEqual(self.MY_CHOICES.ODD.entries[1].constant, 'THREE') + self.assertEqual(self.MY_CHOICES.ODD.entries[1].value, 3) + self.assertEqual(self.MY_CHOICES.ODD.entries[1].display, 'Three to get ready') + + # Test dicts + self.assertEqual(len(self.MY_CHOICES.ODD.constants), 2) + self.assertEqual(len(self.MY_CHOICES.ODD.values), 2) + self.assertEqual(len(self.MY_CHOICES.ODD.displays), 2) + + self.assertIs(self.MY_CHOICES.ODD.constants['ONE'], + self.MY_CHOICES.constants['ONE']) + self.assertIs(self.MY_CHOICES.ODD.constants['THREE'], + self.MY_CHOICES.constants['THREE']) + self.assertIs(self.MY_CHOICES.ODD.values[1], + self.MY_CHOICES.constants['ONE']) + self.assertIs(self.MY_CHOICES.ODD.values[3], + self.MY_CHOICES.constants['THREE']) + self.assertIs(self.MY_CHOICES.ODD.displays['One for the money'], + self.MY_CHOICES.constants['ONE']) + self.assertIs(self.MY_CHOICES.ODD.displays['Three to get ready'], + self.MY_CHOICES.constants['THREE']) + + # Test ``in`` + self.assertIn(1, self.MY_CHOICES.ODD) + self.assertNotIn(4, self.MY_CHOICES.ODD) + + def test_should_not_be_able_to_add_choices_to_a_subset(self): + """Test that an exception is raised when trying to add new choices to a subset.""" + + # Using a subset created by ``add_subset``. + with self.assertRaises(RuntimeError): + self.MY_CHOICES.ODD.add_choices( + ('FOO', 10, 'foo'), + ('BAR', 20, 'bar'), + ) + + # Using a subset created by ``add_choices``. + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('FIVE', 5, '... but Five is not in the song'), + name='EXTENDED' + ) + with self.assertRaises(RuntimeError): + self.MY_CHOICES.EXTENDED.add_choices( + ('FOO', 10, 'foo'), + ('BAR', 20, 'bar'), + ) + + def test_validating_added_choices(self): + """Test that added choices can be added.""" + + # Cannot add an existing constant. + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + ('ONE', 11, 'Another ONE'), + ('FOUR', 4, 'And four to go'), + ) + + # Cannot add an existing value. + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + ('ONEBIS', 1, 'Another 1'), + ('FOUR', 4, 'And four to go'), + ) + + # Cannot add two choices with the same name. + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('FOUR', 44, 'And four to go, bis'), + ) + + # Cannot add two choices with the same value. + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('FOURBIS', 4, 'And four to go, bis'), + ) + + # Cannot use existing attributes. + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('choices', 123, 'Existing attribute'), + ) + + with self.assertRaises(ValueError): + self.MY_CHOICES.add_choices( + ('FOUR', 4, 'And four to go'), + ('add_choices', 123, 'Existing attribute'), + ) + + def test_validating_subset(self): + """Test that new subset is valid.""" + + # Using a name that is already an attribute + with self.assertRaises(ValueError): + self.MY_CHOICES.add_subset("choices", ("ONE", "THREE")) + + with self.assertRaises(ValueError): + self.MY_CHOICES.add_subset("add_choices", ("ONE", "THREE")) + + # Using an undefined constant + with self.assertRaises(ValueError): + self.MY_CHOICES.add_subset("EVEN", ("TWO", "FOUR")) + + def test_for_methods(self): + """Test the ``for_constant``, ``for_value`` and ``for_display`` methods.""" + + self.assertIs(self.MY_CHOICES.for_constant('ONE'), + self.MY_CHOICES.constants['ONE']) + self.assertIs(self.MY_CHOICES.for_value(2), + self.MY_CHOICES.values[2]) + self.assertIs(self.MY_CHOICES.for_display('Three to get ready'), + self.MY_CHOICES.displays['Three to get ready']) + + with self.assertRaises(KeyError): + self.MY_CHOICES.for_constant('FOUR') + + with self.assertRaises(KeyError): + self.MY_CHOICES.for_value(4) + + with self.assertRaises(KeyError): + self.MY_CHOICES.for_display('And four to go') + + def test_has_methods(self): + """Test the ``has_constant``, ``has_value`` and ``has_display`` methods.""" + + self.assertTrue(self.MY_CHOICES.has_constant('ONE')) + self.assertTrue(self.MY_CHOICES.has_value(2)) + self.assertTrue(self.MY_CHOICES.has_display('Three to get ready')) + + self.assertFalse(self.MY_CHOICES.has_constant('FOUR')) + self.assertFalse(self.MY_CHOICES.has_value(4)) + self.assertFalse(self.MY_CHOICES.has_display('And four to go')) + + def test__contains__(self): + """Test the ``__contains__`` method.""" + + self.assertIn(1, self.MY_CHOICES) + self.assertTrue(self.MY_CHOICES.__contains__(1)) + self.assertIn(3, self.MY_CHOICES) + self.assertTrue(self.MY_CHOICES.__contains__(3)) + self.assertNotIn(4, self.MY_CHOICES) + self.assertFalse(self.MY_CHOICES.__contains__(4)) + + def test__getitem__(self): + """Test the ``__getitem__`` method.""" + + + # Access to constants. + self.assertEqual(self.MY_CHOICES['ONE'], 1) + self.assertEqual(self.MY_CHOICES.__getitem__('ONE'), 1) + + self.assertEqual(self.MY_CHOICES['THREE'], 3) + self.assertEqual(self.MY_CHOICES.__getitem__('THREE'), 3) + + with self.assertRaises(KeyError): + self.MY_CHOICES['FOUR'] + with self.assertRaises(KeyError): + self.MY_CHOICES.__getitem__('FOUR') + + one = (1, 'One for the money') + three = (3, 'Three to get ready') + + # Access to default list entries + self.assertEqual(self.MY_CHOICES[0], one) + self.assertEqual(self.MY_CHOICES.__getitem__(0), one) + + self.assertEqual(self.MY_CHOICES[2], three) + self.assertEqual(self.MY_CHOICES.__getitem__(2), three) + + with self.assertRaises(IndexError): + self.MY_CHOICES[3] + with self.assertRaises(IndexError): + self.MY_CHOICES.__getitem__(3) + + +class ChoiceAttributeMixinTestCase(BaseTestCase): + """Test the ``ChoiceAttributeMixin`` class.""" + + choice_entry = ChoiceEntry(('FOO', 1, 'foo')) + + def test_it_should_respect_the_type(self): + """A class based on ``ChoiceAttributeMixin`` should inherit from the other class too.""" + + class StrChoiceAttribute(ChoiceAttributeMixin, str): + pass + + str_attr = StrChoiceAttribute('f', self.choice_entry) + self.assertEqual(str_attr, 'f') + self.assertIsInstance(str_attr, str) + + class IntChoiceAttribute(ChoiceAttributeMixin, int): + pass + + int_attr = IntChoiceAttribute(1, self.choice_entry) + self.assertEqual(int_attr, 1) + self.assertIsInstance(int_attr, int) + + class FloatChoiceAttribute(ChoiceAttributeMixin, float): + pass + + float_attr = FloatChoiceAttribute(1.5, self.choice_entry) + self.assertEqual(float_attr, 1.5) + self.assertIsInstance(float_attr, float) + + def test_it_should_create_classes_on_the_fly(self): + """Test that ``get_class_for_value works and cache its results.""" + + # Empty list of cached classes to really test. + ChoiceAttributeMixin._classes_by_type = {} + + # Create a class on the fly. + IntClass = ChoiceAttributeMixin.get_class_for_value(1) + self.assertEqual(IntClass.__name__, 'IntChoiceAttribute') + self.assertIn(int, IntClass.mro()) + self.assertDictEqual(ChoiceAttributeMixin._classes_by_type, { + int: IntClass + }) + + # Using the same type should return the same class as before. + IntClass2 = ChoiceAttributeMixin.get_class_for_value(2) + self.assertIs(IntClass2, IntClass2) + + int_attr = IntClass(1, self.choice_entry) + self.assertEqual(int_attr, 1) + self.assertIsInstance(int_attr, int) + + # Create another class on the fly. + FloatClass = ChoiceAttributeMixin.get_class_for_value(1.5) + self.assertEqual(FloatClass.__name__, 'FloatChoiceAttribute') + self.assertIn(float, FloatClass.mro()) + self.assertDictEqual(ChoiceAttributeMixin._classes_by_type, { + int: IntClass, + float: FloatClass + }) + + float_attr = FloatClass(1.5, self.choice_entry) + self.assertEqual(float_attr, 1.5) + self.assertIsInstance(float_attr, float) + + def test_it_should_access_choice_entry_attributes(self): + """Test that an instance can access the choice entry and its attributes.""" + IntClass = ChoiceAttributeMixin.get_class_for_value(1) + attr = IntClass(1, self.choice_entry) + + # We should acces the choice entry. + self.assertEqual(attr.choice_entry, self.choice_entry) + + # And the attributes of the choice entry. + self.assertEqual(attr.constant, self.choice_entry.constant) + self.assertEqual(attr.value, self.choice_entry.value) + self.assertEqual(attr.display, self.choice_entry.display) + + +class ChoiceEntryTestCase(BaseTestCase): + """Test the ``ChoiceEntry`` class.""" + + def test_it_should_act_as_a_normal_tuple(self): + """Test that a ``ChoiceEntry`` instance acts as a real tuple.""" + + # Test without additional attributes + choice_entry = ChoiceEntry(('FOO', 1, 'foo')) + self.assertIsInstance(choice_entry, tuple) + self.assertEqual(choice_entry, ('FOO', 1, 'foo')) + + # Test with additional attributes + choice_entry = ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2})) + self.assertIsInstance(choice_entry, tuple) + self.assertEqual(choice_entry, ('FOO', 1, 'foo')) + + def test_it_should_have_new_attributes(self): + """Test that new specific attributes are accessible anv valid.""" + + choice_entry = ChoiceEntry(('FOO', 1, 'foo')) + + self.assertEqual(choice_entry.constant, 'FOO') + self.assertEqual(choice_entry.value, 1) + self.assertEqual(choice_entry.display, 'foo') + self.assertEqual(choice_entry.choice, (1, 'foo')) + + def test_new_attributes_are_instances_of_choices_attributes(self): + """Test that the new attributes are instances of ``ChoiceAttributeMixin``.""" + + choice_entry = ChoiceEntry(('FOO', 1, 'foo')) + + # 3 attributes should be choice attributes + self.assertIsInstance(choice_entry.constant, ChoiceAttributeMixin) + self.assertIsInstance(choice_entry.value, ChoiceAttributeMixin) + self.assertIsInstance(choice_entry.display, ChoiceAttributeMixin) + + # As a choice attribute allow to access the other attributes of the choice entry, + # check that it's really possible + + self.assertIs(choice_entry.constant.constant, choice_entry.constant) + self.assertIs(choice_entry.constant.value, choice_entry.value) + self.assertIs(choice_entry.constant.display, choice_entry.display) + + self.assertIs(choice_entry.value.constant, choice_entry.constant) + self.assertIs(choice_entry.value.value, choice_entry.value) + self.assertIs(choice_entry.value.display, choice_entry.display) + + self.assertIs(choice_entry.display.constant, choice_entry.constant) + self.assertIs(choice_entry.display.value, choice_entry.value) + self.assertIs(choice_entry.display.display, choice_entry.display) + + # Extreme test + self.assertIs(choice_entry.display.value.constant.value.display.display.constant, + choice_entry.constant) + + def test_additional_attributes_are_saved(self): + """Test that a dict passed as 4th tuple entry set its entries as attributes.""" + + choice_entry = ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2})) + self.assertEqual(choice_entry.bar, 1) + self.assertEqual(choice_entry.baz, 2) + + def test_it_should_check_number_of_entries(self): + """Test that it allows only tuples with 3 entries + optional attributes dict.""" + + # Less than 3 shouldn't work + with self.assertRaises(AssertionError): + ChoiceEntry(('foo',)) + with self.assertRaises(AssertionError): + ChoiceEntry((1, 'foo')) + + # More than 4 shouldn't work. + with self.assertRaises(AssertionError): + ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2}, 'QUZ')) + with self.assertRaises(AssertionError): + ChoiceEntry(('FOO', 1, 'foo', {'bar': 1, 'baz': 2}, 'QUZ', 'QUX')) + + +class OldChoicesTestCase(BaseTestCase): + """Test of tje ``Choices`` implementation as defined on version 0.4.1, for retro-compatibility. + + Existing tests from this old version are untouched, but some checks where added, as well + as comments and spacing. + """ + def test_attributes_and_keys(self): - self.assertEqual(MY_CHOICES.ONE, MY_CHOICES['ONE']) + """Test that constants cab be accessed either by attribute or key.""" + + self.assertEqual(self.MY_CHOICES.ONE, self.MY_CHOICES['ONE']) + with self.assertRaises(AttributeError): - MY_CHOICES.FORTY_TWO + self.MY_CHOICES.FORTY_TWO + with self.assertRaises(KeyError): - MY_CHOICES['FORTY_TWO'] + self.MY_CHOICES['FORTY_TWO'] - # should work for all attributes - self.assertEqual(MY_CHOICES.CHOICES, MY_CHOICES['CHOICES']) + # Key access should work for all attributes. + self.assertEqual(self.MY_CHOICES.CHOICES, self.MY_CHOICES['CHOICES']) def test_simple_choice(self): - self.assertEqual(MY_CHOICES.CHOICES,( + """Test all ways to access data on a ``Choices`` object.""" + + self.assertEqual(self.MY_CHOICES,( + (1, "One for the money"), + (2, "Two for the show"), + (3, "Three to get ready"), + )) + + # Equivalent to above. + self.assertEqual(self.MY_CHOICES.CHOICES,( (1, "One for the money"), (2, "Two for the show"), (3, "Three to get ready"), )) - self.assertEqual(MY_CHOICES.CHOICES_DICT, { + + # Get display strings from their values. + self.assertEqual(self.MY_CHOICES.CHOICES_DICT, { 1: 'One for the money', 2: 'Two for the show', 3: 'Three to get ready' }) - self.assertEqual(MY_CHOICES.REVERTED_CHOICES_DICT,{ + + # Get values from their display strings. + self.assertEqual(self.MY_CHOICES.REVERTED_CHOICES_DICT,{ 'One for the money': 1, 'Three to get ready': 3, 'Two for the show': 2 }) - self.assertEqual(MY_CHOICES.CHOICES_CONST_DICT,{ + + # Get values from their constant names. + self.assertEqual(self.MY_CHOICES.CHOICES_CONST_DICT,{ 'ONE': 1, 'TWO': 2, 'THREE': 3 }) - self.assertEqual(MY_CHOICES.REVERTED_CHOICES_CONST_DICT, { + + # Get constant names from their values. + self.assertEqual(self.MY_CHOICES.REVERTED_CHOICES_CONST_DICT, { 1: 'ONE', 2: 'TWO', 3: 'THREE' }) def test__contains__(self): - self.assertTrue(MY_CHOICES.ONE in MY_CHOICES) + """Test the ``__contains__`` method. + + Should return ``True`` if a given value is in the `Choices`` object. + + """ + + self.assertTrue(self.MY_CHOICES.ONE in self.MY_CHOICES) + self.assertTrue(1 in self.MY_CHOICES) + self.assertFalse(42 in self.MY_CHOICES) def test__iter__(self): - self.assertEqual([k for k, v in MY_CHOICES], [1, 2, 3]) + """Test the ``__iter__`` method. + + Each iteration should return a tuple with value and display string. + + """ + + self.assertEqual([c for c in self.MY_CHOICES], [ + (1, 'One for the money'), + (2, 'Two for the show'), + (3, 'Three to get ready'), + ]) def test_subset(self): - self.assertEqual(MY_CHOICES.ODD,( + """Test the ``add_subset`` method.""" + + self.assertEqual(self.MY_CHOICES.ODD,( (1, 'One for the money'), (3, 'Three to get ready') )) - self.assertEqual(MY_CHOICES.ODD_CONST_DICT,{'ONE': 1, 'THREE': 3}) + self.assertEqual(self.MY_CHOICES.ODD_CONST_DICT, {'ONE': 1, 'THREE': 3}) def test_unique_values(self): - self.assertRaises(ValueError, Choices, ('TWO', 4, 'Deux'), ('FOUR', 4, 'Quatre')) + """Test that an exception is raised when constants with the same value are added.""" + + with self.assertRaises(ValueError): + Choices(('TWO', 4, 'Deux'), ('FOUR', 4, 'Quatre')) def test_unique_constants(self): - self.assertRaises(ValueError, Choices, ('TWO', 2, 'Deux'), ('TWO', 4, 'Quatre')) + """Test that an exception is raised when constants with the same name are added.""" + + with self.assertRaises(ValueError): + Choices(('TWO', 2, 'Deux'), ('TWO', 4, 'Quatre')) def test_retrocompatibility(self): + """Test that features introduced in the very first version are still working.""" + + # Passing a name on the constructor should create a first subset with all values. OTHER_CHOICES = Choices( ('TWO', 2, 'Deux'), ('FOUR', 4, 'Quatre'), name="EVEN" ) + + # ``add_choices`` will add some choices and set them in a new subset. OTHER_CHOICES.add_choices("ODD", ('ONE', 1, 'Un'), ('THREE', 3, 'Trois'), @@ -120,6 +791,9 @@ def test_retrocompatibility(self): self.assertEqual(OTHER_CHOICES.EVEN, ((2, 'Deux'), (4, 'Quatre'))) def test_dict_class(self): + """Test that the dict class to use can be set on the constructor.""" + + # For the test, use an ordered dict, from python or django depending on the python version. if sys.version_info >= (2, 7): from collections import OrderedDict else: @@ -133,6 +807,7 @@ def test_dict_class(self): ) OTHER_CHOICES.add_subset("ODD", ("ONE", "THREE")) + # Check that all dict attributes are from the correct class. for attr in ( # normal choice 'CHOICES_DICT', @@ -145,7 +820,7 @@ def test_dict_class(self): 'ODD_CONST_DICT', 'REVERTED_ODD_CONST_DICT', ): - self.assertFalse(isinstance(getattr(MY_CHOICES, attr), OrderedDict)) + self.assertFalse(isinstance(getattr(self.MY_CHOICES, attr), OrderedDict)) self.assertTrue(isinstance(getattr(OTHER_CHOICES, attr), OrderedDict)) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..cd3130d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt + +django \ No newline at end of file diff --git a/requirements-makedoc.txt b/requirements-makedoc.txt new file mode 100644 index 0000000..df6cf68 --- /dev/null +++ b/requirements-makedoc.txt @@ -0,0 +1,5 @@ +-r requirements-dev.txt + +sphinx +sphinxcontrib-napoleon +sphinx_rtd_theme \ No newline at end of file diff --git a/setup.py b/setup.py index a3aa600..e52aaf1 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ def read_relative_file(filename): setup( name="django-extended-choices", - version="0.4.1", + version="1.0", license="GPL", description="Little helper application to improve django choices" "(for fields)", @@ -29,9 +29,18 @@ def read_relative_file(filename): install_requires=install_requires, packages=find_packages(), include_package_data=True, + extras_require= { + 'dev': ['django'], + 'makedoc': ['django', 'sphinx', 'sphinxcontrib-napoleon', 'sphinx_rtd_theme'], + }, classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Framework :: Django", + "Framework :: Django :: 1.4", + "Framework :: Django :: 1.5", + "Framework :: Django :: 1.6", + "Framework :: Django :: 1.7", + "Framework :: Django :: 1.8", "Operating System :: OS Independent", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License (GPL)", @@ -42,5 +51,7 @@ def read_relative_file(filename): "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", ] )