Skip to content

Commit

Permalink
Make SimpleVocabulary.fromItems() accept triples to assign titles
Browse files Browse the repository at this point in the history
Fixes #18

Some other vocabulary fixes while I was in there:

- Make TreeVocubalary.fromDict only actually assign titles when a
  title is given. This makes it consistent with
  SimpleVocubalary.fromItems().

- Give equality and hash methods to SimpleTerm and SimpleVocubalary.
  This was a confusing stumbling block in the implementation of #51.
  • Loading branch information
jamadden committed Sep 1, 2018
1 parent 665e2e7 commit 5c65d7b
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 24 deletions.
10 changes: 10 additions & 0 deletions CHANGES.rst
Expand Up @@ -97,6 +97,16 @@
- Make ``Iterable`` and ``Container`` properly implement ``IIterable``
and ``IContainer``, respectively.

- Make ``SimpleVocabulary.fromItems`` accept triples to allow
specifying the title of terms. See `issue 18
<https://github.com/zopefoundation/zope.schema/issues/18>`_.

- Make ``TreeVocabulary.fromDict`` only create
``ITitledTokenizedTerms`` when a title is actually provided.

- Make ``SimpleVocabulary`` and ``SimpleTerm`` have value-based
equality and hashing methods.

4.5.0 (2017-07-10)
==================

Expand Down
7 changes: 2 additions & 5 deletions src/zope/schema/tests/test__field.py
Expand Up @@ -705,16 +705,13 @@ def _getTargetClass(self):
from zope.schema._field import Choice
return Choice

from zope.schema.vocabulary import SimpleVocabulary
# SimpleVocabulary uses identity semantics for equality
_default_vocabulary = SimpleVocabulary.fromValues([1, 2, 3])

def _makeOneFromClass(self, cls, *args, **kwargs):
if (not args
and 'vocabulary' not in kwargs
and 'values' not in kwargs
and 'source' not in kwargs):
kwargs['vocabulary'] = self._default_vocabulary
from zope.schema.vocabulary import SimpleVocabulary
kwargs['vocabulary'] = SimpleVocabulary.fromValues([1, 2, 3])
return super(ChoiceTests, self)._makeOneFromClass(cls, *args, **kwargs)

def _getTargetInterface(self):
Expand Down
110 changes: 106 additions & 4 deletions src/zope/schema/tests/test_vocabulary.py
Expand Up @@ -66,6 +66,41 @@ def test_unicode_non_ascii_value(self):
self.assertFalse(ITitledTokenizedTerm.providedBy(term))


def test__eq__and__hash__(self):
from zope import interface

term = self._makeOne('value')
# Equal to itself
self.assertEqual(term, term)
# Not equal to a different class
self.assertNotEqual(term, object())
self.assertNotEqual(object(), term)

term2 = self._makeOne('value')
# Equal to another with the same value
self.assertEqual(term, term2)
# equal objects hash the same
self.assertEqual(hash(term), hash(term2))

# Providing tokens or titles that differ
# changes equality
term = self._makeOne('value', 'token')
self.assertNotEqual(term, term2)
self.assertNotEqual(hash(term), hash(term2))

term2 = self._makeOne('value', 'token')
self.assertEqual(term, term2)
self.assertEqual(hash(term), hash(term2))

term = self._makeOne('value', 'token', 'title')
self.assertNotEqual(term, term2)
self.assertNotEqual(hash(term), hash(term2))

term2 = self._makeOne('value', 'token', 'title')
self.assertEqual(term, term2)
self.assertEqual(hash(term), hash(term2))


class SimpleVocabularyTests(unittest.TestCase):

def _getTargetClass(self):
Expand Down Expand Up @@ -102,8 +137,8 @@ class IStupid(Interface):
self.assertTrue(value in vocabulary)
self.assertFalse('ABC' in vocabulary)
for term in vocabulary:
self.assertTrue(vocabulary.getTerm(term.value) is term)
self.assertTrue(vocabulary.getTermByToken(term.token) is term)
self.assertIs(vocabulary.getTerm(term.value), term)
self.assertIs(vocabulary.getTermByToken(term.token), term)

def test_fromValues(self):
from zope.interface import Interface
Expand All @@ -119,7 +154,7 @@ class IStupid(Interface):
self.assertTrue(ITokenizedTerm.providedBy(term))
self.assertEqual(term.value, value)
for value in VALUES:
self.assertTrue(value in vocabulary)
self.assertIn(value, vocabulary)

def test_fromItems(self):
from zope.interface import Interface
Expand All @@ -136,7 +171,30 @@ class IStupid(Interface):
self.assertEqual(term.token, item[0])
self.assertEqual(term.value, item[1])
for item in ITEMS:
self.assertTrue(item[1] in vocabulary)
self.assertIn(item[1], vocabulary)

def test_fromItems_triples(self):
from zope.interface import Interface
from zope.schema.interfaces import ITitledTokenizedTerm

class IStupid(Interface):
pass

ITEMS = [
('one', 1, 'title 1'),
('two', 2, 'title 2'),
('three', 3, 'title 3'),
('fore!', 4, 'title four')
]
vocabulary = self._getTargetClass().fromItems(ITEMS)
self.assertEqual(len(vocabulary), len(ITEMS))
for item, term in zip(ITEMS, vocabulary):
self.assertTrue(ITitledTokenizedTerm.providedBy(term))
self.assertEqual(term.token, item[0])
self.assertEqual(term.value, item[1])
self.assertEqual(term.title, item[2])
for item in ITEMS:
self.assertIn(item[1], vocabulary)

def test_createTerm(self):
from zope.schema.vocabulary import SimpleTerm
Expand Down Expand Up @@ -204,6 +262,38 @@ def createTerm(cls, value):
for term in vocab:
self.assertEqual(term.value + 1, term.nextvalue)

def test__eq__and__hash__(self):
from zope import interface

values = [1, 4, 2, 9]
vocabulary = self._getTargetClass().fromValues(values)

# Equal to itself
self.assertEqual(vocabulary, vocabulary)
# Not to other classes
self.assertNotEqual(vocabulary, object())
self.assertNotEqual(object(), vocabulary)

# Equal to another object with the same values
vocabulary2 = self._getTargetClass().fromValues(values)
self.assertEqual(vocabulary, vocabulary2)
self.assertEqual(hash(vocabulary), hash(vocabulary2))

# Changing the values or the interfaces changes
# equality
class IFoo(interface.Interface):
"an interface"

vocabulary = self._getTargetClass().fromValues(values, IFoo)
self.assertNotEqual(vocabulary, vocabulary2)
# Interfaces are not taken into account in the hash; that's
# OK: equal hashes do not imply equal objects
self.assertEqual(hash(vocabulary), hash(vocabulary2))

vocabulary2 = self._getTargetClass().fromValues(values, IFoo)
self.assertEqual(vocabulary, vocabulary2)
self.assertEqual(hash(vocabulary), hash(vocabulary2))


# Test _createTermTree via TreeVocabulary.fromDict

Expand Down Expand Up @@ -256,6 +346,18 @@ def business_tree(self):
def tree_vocab_3(self):
return self._getTargetClass().fromDict(self.business_tree())

def test_only_titled_if_triples(self):
from zope.schema.interfaces import ITitledTokenizedTerm
no_titles = self.tree_vocab_2()
for term in no_titles:
self.assertIsNone(term.title)
self.assertFalse(ITitledTokenizedTerm.providedBy(term))

all_titles = self.tree_vocab_3()
for term in all_titles:
self.assertIsNotNone(term.title)
self.assertTrue(ITitledTokenizedTerm.providedBy(term))

def test_implementation(self):
from zope.interface.verify import verifyObject
from zope.interface.common.mapping import IEnumerableMapping
Expand Down
89 changes: 74 additions & 15 deletions src/zope/schema/vocabulary.py
Expand Up @@ -17,6 +17,7 @@

from zope.interface import directlyProvides
from zope.interface import implementer
from zope.interface import providedBy

from zope.schema._compat import text_type
from zope.schema.interfaces import ITitledTokenizedTerm
Expand All @@ -32,14 +33,20 @@

@implementer(ITokenizedTerm)
class SimpleTerm(object):
"""Simple tokenized term used by SimpleVocabulary."""
"""
Simple tokenized term used by SimpleVocabulary.
.. versionchanged:: 4.6.0
Implement equality and hashing based on the value, token and title.
"""

def __init__(self, value, token=None, title=None):
"""Create a term for *value* and *token*. If *token* is
omitted, str(value) is used for the token, escaping any
non-ASCII characters.
If *title* is provided, term implements `ITitledTokenizedTerm`.
If *title* is provided, term implements
:class:`zope.schema.interfaces.ITitledTokenizedTerm`.
"""
self.value = value
if token is None:
Expand All @@ -64,10 +71,35 @@ def __init__(self, value, token=None, title=None):
if title is not None:
directlyProvides(self, ITitledTokenizedTerm)

def __eq__(self, other):
if other is self:
return True

if not isinstance(other, SimpleTerm):
return False

return (
self.value == other.value
and self.token == other.token
and self.title == other.title
)

def __ne__(self, other):
return not self.__eq__(other)

def __hash__(self):
return hash((self.value, self.token, self.title))


@implementer(IVocabularyTokenized)
class SimpleVocabulary(object):
"""Vocabulary that works from a sequence of terms."""
"""
Vocabulary that works from a sequence of terms.
.. versionchanged:: 4.6.0
Implement equality and hashing based on the terms list
and interfaces implemented by this object.
"""

def __init__(self, terms, *interfaces, **kwargs):
"""Initialize the vocabulary given a list of terms.
Expand All @@ -80,14 +112,14 @@ def __init__(self, terms, *interfaces, **kwargs):
By default, ValueErrors are thrown if duplicate values or tokens
are passed in. If you want to swallow these exceptions, pass
in swallow_duplicates=True. In this case, the values will
in ``swallow_duplicates=True``. In this case, the values will
override themselves.
"""
self.by_value = {}
self.by_token = {}
self._terms = terms
swallow_dupes = kwargs.get('swallow_duplicates', False)
for term in self._terms:
swallow_dupes = kwargs.get('swallow_duplicates', False)
if not swallow_dupes:
if term.value in self.by_value:
raise ValueError(
Expand All @@ -102,27 +134,34 @@ def __init__(self, terms, *interfaces, **kwargs):

@classmethod
def fromItems(cls, items, *interfaces):
"""Construct a vocabulary from a list of (token, value) pairs.
"""
Construct a vocabulary from a list of (token, value) pairs or
(token, value, title) triples. The list does not have to be
homogeneous.
The order of the items is preserved as the order of the terms
in the vocabulary. Terms are created by calling the class
method createTerm() with the pair (value, token).
in the vocabulary. Terms are created by calling the class
method :meth:`createTerm`` with the pair or triple.
One or more interfaces may also be provided so that alternate
widgets may be bound without subclassing.
.. versionchanged:: 4.6.0
Allow passing in triples to set item titles.
"""
terms = [cls.createTerm(value, token) for (token, value) in items]
terms = [cls.createTerm(item[1], item[0], *item[2:])
for item in items]
return cls(terms, *interfaces)

@classmethod
def fromValues(cls, values, *interfaces):
"""Construct a vocabulary from a simple list.
Values of the list become both the tokens and values of the
terms in the vocabulary. The order of the values is preserved
as the order of the terms in the vocabulary. Tokens are
created by calling the class method createTerm() with the
value as the only parameter.
terms in the vocabulary. The order of the values is preserved
as the order of the terms in the vocabulary. Tokens are
created by calling the class method :meth:`createTerm()` with
the value as the only parameter.
One or more interfaces may also be provided so that alternate
widgets may be bound without subclassing.
Expand Down Expand Up @@ -169,6 +208,21 @@ def __len__(self):
"""See zope.schema.interfaces.IIterableVocabulary"""
return len(self.by_value)

def __eq__(self, other):
if other is self:
return True

if not isinstance(other, SimpleVocabulary):
return False

return self._terms == other._terms and providedBy(self) == providedBy(other)

def __ne__(self, other):
return not self.__eq__(other)

def __hash__(self):
return hash(tuple(self._terms))


def _createTermTree(ttree, dict_):
""" Helper method that creates a tree-like dict with ITokenizedTerm
Expand All @@ -177,7 +231,7 @@ def _createTermTree(ttree, dict_):
See fromDict for more details.
"""
for key in sorted(dict_.keys()):
term = SimpleTerm(key[1], key[0], key[-1])
term = SimpleTerm(key[1], key[0], *key[2:])
ttree[term] = TreeVocabulary.terms_factory()
_createTermTree(ttree[term], dict_[key])
return ttree
Expand Down Expand Up @@ -272,7 +326,8 @@ def fromDict(cls, dict_, *interfaces):
OrderedDict), that has tuples for keys.
The tuples should have either 2 or 3 values, i.e:
(token, value, title) or (token, value)
(token, value, title) or (token, value). Only tuples that have
three values will create a :class:`zope.schema.interfaces.ITitledTokenizedTerm`.
For example, a dict with 2-valued tuples:
Expand All @@ -290,6 +345,10 @@ def fromDict(cls, dict_, *interfaces):
}
One or more interfaces may also be provided so that alternate
widgets may be bound without subclassing.
.. versionchanged:: 4.6.0
Only create ``ITitledTokenizedTerm`` when a title is actually
provided.
"""
return cls(_createTermTree(cls.terms_factory(), dict_), *interfaces)

Expand Down

0 comments on commit 5c65d7b

Please sign in to comment.