Commits

jason kirtland committed 25d15cc

Added SparseDict, a Dict-subtype useful for pure data situations.

Comments (0)

Files changed (5)

docs/source/schema/dicts.rst

      named, of, using,
      from_defaults, from_flat
 
+``SparseDict``
+--------
+
+.. autoclass:: SparseDict
+  :show-inheritance:
+
+Configurable Attributes
+-----------------------
+
+.. autoattribute:: SparseDict.minimum_fields
+
+

flatland/__init__.py

                 'Skip',
                 'SkipAll',
                 'SkipAllFalse',
+                'SparseDict',
                 'String',
                 'Time',
                 'Unevaluated',

flatland/schema/__init__.py

     Mapping,
     MultiValue,
     Sequence,
+    SparseDict,
     )
 from .compound import (
     Compound,

flatland/schema/containers.py

 
 from flatland.util import (
     Unspecified,
+    assignable_class_property,
     autodocument_from_superclasses,
     class_cloner,
     keyslice_pairs,
             raise KeyError(key)
         raise TypeError('%s keys are immutable.' % type(self).__name__)
 
+    def may_contain(self, key):
+        """Return True if the element schema allows a field named **key**."""
+        return key in self
+
     def clear(self):
         raise TypeError('%s keys are immutable.' % type(self).__name__)
 
         seen = set()
         converted = True
         for key, value in pairs:
-            if key not in self:
+            if not self.may_contain(key):
                 raise KeyError(
                     '%s %r schema does not allow key %r' % (
                         type(self).__name__, self.name, key))
             plen = len(prefix)
             for key, value in pairs:
                 if key == prefix:
-                    # No flat representation of dicts, ignore.
+                    # No flat representation of mappings, ignore.
                     pass
                 if key.startswith(prefix):
                     # accept child element
         if not possibles:
             return
 
-        # FIXME: pivot on length of pairs: top loop either fields or pairs
-        # FIXME2: wtf does that mean
-
-        for field in self:
+        for schema in self.field_schema:
+            field = schema.name
             accum = []
             for key, value in possibles:
                 if key.startswith(field):
                     accum.append((key, value))
             if accum:
-                self[field].set_flat(accum, sep)
+                if dict.__contains__(self, field):
+                    self[field].set_flat(accum, sep)
+                else:
+                    self[field] = schema()
+                    self[field].set_flat(accum, sep=sep)
 
     def set_default(self):
         default = self.default_value
         """Mappings are never empty."""
         return False
 
+    @assignable_class_property
+    def field_schema_mapping(instance, cls):
+        """A name -> schema mapping generated from :attr:`field_schema`."""
+        if instance is not None:
+            field_schema = instance.field_schema
+        else:
+            field_schema = cls.field_schema
+        return dict((schema.name, schema) for schema in field_schema)
+
+    def _field_schema_for(self, key):
+        """Return the schema for field ``*key* or None."""
+        for schema in self.field_schema:
+            if schema.name == key:
+                return schema
+        return None
+
 
 class Dict(Mapping, dict):
     """A mapping Container with named members."""
 
     policy = 'subset'
-    """TODO: doc policy = subset
+    """One of 'strict', 'subset' or 'duck'.  Default 'subset'.
 
     See :ref:`set_policy`
     """
         else:
             policy = self.policy
 
+        fields = self.field_schema_mapping
         seen = set()
         converted = True
         for key, value in pairs:
-            if key not in self:
+            if key not in fields:
                 if policy != 'duck':
                     raise KeyError(
                         'Dict %r schema does not allow key %r' % (
                             self.name, key))
                 continue
-            converted &= self[key].set(value)
+            if dict.__contains__(self, key):
+                converted &= self[key].set(value)
+            else:
+                self[key] = el = fields[key]()
+                converted &= el.set(value)
             seen.add(key)
 
         if policy == 'strict':
-            required = set(self.iterkeys())
+            required = set(fields.iterkeys())
             if seen != required:
                 missing = required - seen
                 raise TypeError(
                 include=include, omit=omit, rename=rename, key=key))
 
 
+class SparseDict(Dict):
+    """A Mapping which may contain a subset of the schema's allowed keys.
+
+    This differs from :class:`Dict` in that new instances are not created with
+    empty values for all possible keys.  In addition, mutating operations are
+    allowed so long as the operations operate within the schema.  For example,
+    you may :meth:`pop` and ``del`` members of the mapping.
+
+    """
+
+    #: The subset of fields to autovivify on instantiation.
+    #:
+
+    #: May be ``None`` or ``'required'``.  If ``None``, mappings will be
+    #: created empty and mutation operations are unrestricted within the
+    #: bounds of the :attr:`field_schema`.  If ``required``, fields with
+    #: :attr:`optional` of ``False`` will always be present after
+    #: instantiation, and attempts to remove them from the mapping with ``del``
+    #: and friends will raise ``TypeError``.
+    minimum_fields = None  # 'required'
+
+    def may_contain(self, key):
+        return key in self or self._field_schema_for(key) is not None
+
+    def _reset(self):
+        dict.clear(self)
+        for member_schema in self.field_schema:
+            key = member_schema.name
+            if self.minimum_fields is None or member_schema.optional:
+                continue
+            dict.__setitem__(
+                self, key, member_schema(parent=self))
+
+    def __setitem__(self, key, value):
+        schema = self._field_schema_for(key)
+        if not dict.__contains__(self, key):
+            if schema is None:
+                raise TypeError('May not set unknown key %r on %s %r' %
+                                (key, type(self).__name__, self.name))
+            elif isinstance(value, schema):
+                dict.__setitem__(self, key, value)
+                return
+            dict.__setitem__(self, key, schema(value))
+        elif isinstance(value, schema):
+            value.parent = self
+            dict.__setitem__(self, key, value)
+        else:
+            self[key].set(value)
+
+    def __delitem__(self, key):
+        if self.minimum_fields is None:
+            try:
+                dict.__delitem__(self, key)
+                return
+            except KeyError:
+                if not self.may_contain(key):
+                    raise TypeError(
+                        'May not request del for unknown key %r on %s %r' %
+                        (key, type(self).__name__, self.name))
+                raise
+        if key in self:
+            optional = self[key].optional
+        else:
+            schema = self._field_schema_for(key)
+            if schema is None:
+                raise TypeError(
+                    'May not request del for unknown key %r on %s %r' %
+                    (key, type(self).__name__, self.name))
+            optional = schema.optional
+        if not optional:
+            raise TypeError('May not delete required key %r on %s %r' %
+                            (key, type(self).__name__, self.name))
+        dict.__delitem__(self, key)
+
+    def clear(self):
+        self._reset()
+
+    def popitem(self):
+        raise NotImplementedError
+
+    def pop(self, key):
+        if key not in self:
+            raise KeyError(key)
+        if self.minimum_fields == 'required' and not self[key].optional:
+            raise TypeError('May not pop required key %r on %s %r' %
+                            (key, type(self).__name__, self.name))
+        return dict.pop(self, key)
+
+    def setdefault(self, key, default=None):
+        if not self.may_contain(key):
+            raise TypeError('Key %r not allowed in %s %r' %
+                            (key, type(self).__name__, self.name))
+
+        if key in self:
+            child = self[key]
+            if not child.is_empty:
+                return child.value
+        else:
+            self[key] = child = self.field_schema_mapping[key]()
+        child.set(default)
+        return default
+
+    @property
+    def is_empty(self):
+        for _ in self.iterkeys():
+            return False
+        return True
+
+    def set_default(self):
+        default = self.default_value
+        if default is not None and default is not Unspecified:
+            self.set(default)
+        elif self.minimum_fields is None:
+            self._reset()
+        elif self.minimum_fields == 'required':
+            self._reset()
+            for schema in self.field_schema:
+                if schema.optional:
+                    continue
+                self[schema.name] = schema.from_defaults()
+        else:
+            raise RuntimeError("Unknown minimum_fields setting %r" %
+                               (self.minimum_fields,))
+
+
 for cls_name in __all__:
     autodocument_from_superclasses(globals()[cls_name])
 del cls_name

tests/schema/test_dicts.py

     Dict,
     Integer,
     String,
+    SparseDict,
     )
 from flatland.util import Unspecified, keyslice_pairs
 from tests._util import (
 
 
 class DictSetTest(object):
+    schema = Dict
     policy = Unspecified
     x_default = Unspecified
     y_default = Unspecified
         if self.y_default is not Unspecified:
             y_kw['default'] = self.y_default
 
-        return Dict.named(u's').using(**dictkw).of(
+        return self.schema.named(u's').using(**dictkw).of(
             Integer.named(u'x').using(**x_kw),
             Integer.named(u'y').using(**y_kw))
 
     y_default = 20
 
 
+class TestEmptySparseDictRequiredSet(DictSetTest):
+    schema = SparseDict.using(minimum_fields='required')
+
+
 def test_dict_valid_policies():
     schema = Dict.of(Integer)
     el = schema()
     yield same_, {u'x': u'X', u'y': u'Y'}, dict(omit=[u'x'])
     yield same_, {u'x': u'X', u'y': u'Y'}, dict(omit=[u'x'],
                                                 rename={u'y': u'z'})
+
+
+def test_sparsedict_key_mutability():
+    schema = SparseDict.of(Integer.named(u'x'), Integer.named(u'y'))
+    el = schema()
+
+    ok, bogus = u'x', u'z'
+
+    el[ok] = 123
+    assert el[ok].value == 123
+    assert_raises(TypeError, el.__setitem__, bogus, 123)
+
+    del el[ok]
+    assert ok not in el
+    assert_raises(TypeError, el.__delitem__, bogus)
+
+    assert el.setdefault(ok, 456)
+    assert_raises(TypeError, el.setdefault, bogus, 456)
+
+    el[ok] = 123
+    assert el.pop(ok)
+    assert_raises(KeyError, el.pop, bogus)
+
+    assert_raises(NotImplementedError, el.popitem)
+    el.clear()
+    assert not el
+
+
+def test_sparsedict_operations():
+    schema = SparseDict.of(Integer.named(u'x'), Integer.named(u'y'))
+    el = schema()
+
+    el[u'x'] = 123
+    del el[u'x']
+    assert_raises(KeyError, el.__delitem__, u'x')
+
+    assert el.setdefault(u'x', 123) == 123
+    assert el.setdefault(u'x', 456) == 123
+
+    assert el.setdefault(u'y', 123) == 123
+    assert el.setdefault(u'y', 456) == 123
+
+    assert schema().is_empty
+    assert not schema().validate()
+
+    opt_schema = schema.using(optional=True)
+    assert opt_schema().validate()
+
+
+def test_sparsedict_required_operations():
+    schema = SparseDict.using(minimum_fields='required').\
+                        of(Integer.named(u'opt').using(optional=True),
+                           Integer.named(u'req'))
+
+    el = schema({u'opt': 123, u'req': 456})
+
+    del el[u'opt']
+    assert_raises(KeyError, el.__delitem__, u'opt')
+    assert_raises(TypeError, el.__delitem__, u'req')
+
+    el = schema()
+    assert el.setdefault(u'opt', 123) == 123
+    assert el.setdefault(u'opt', 456) == 123
+
+    assert el.setdefault(u'req', 123) == 123
+    assert el.setdefault(u'req', 456) == 123
+
+    assert not schema().is_empty
+    assert not schema().validate()
+
+
+def test_sparsedict_set_default():
+    schema = SparseDict.of(Integer.named(u'x').using(default=123),
+                           Integer.named(u'y'))
+    el = schema()
+
+    el.set_default()
+    assert el.value == {}
+
+
+def test_sparsedict_required_set_default():
+    schema = SparseDict.using(minimum_fields='required').\
+                        of(Integer.named(u'x').using(default=123),
+                           Integer.named(u'y').using(default=456,
+                                                     optional=True),
+                           Integer.named(u'z').using(optional=True))
+    el = schema()
+
+    el.set_default()
+    assert el.value == {u'x': 123}
+
+
+def test_sparsedict_bogus_set_default():
+    schema = SparseDict.using(minimum_fields='bogus').\
+                        of(Integer.named(u'x'))
+    el = schema()
+    assert_raises(RuntimeError, el.set_default)
+
+
+def test_sparsedict_required_key_mutability():
+    schema = SparseDict.of(Integer.named(u'x').using(optional=True),
+                           Integer.named(u'y')).\
+                        using(minimum_fields='required')
+    el = schema()
+    ok, required, bogus = u'x', u'y', u'z'
+
+    assert ok not in el
+    assert required in el
+    assert bogus not in el
+
+    el[ok] = 123
+    assert el[ok].value == 123
+    el[required] = 456
+    assert el[required].value == 456
+    assert_raises(TypeError, el.__setitem__, bogus, 123)
+
+    del el[ok]
+    assert ok not in el
+    assert_raises(TypeError, el.__delitem__, required)
+    assert_raises(TypeError, el.__delitem__, bogus)
+
+    assert el.setdefault(ok, 456)
+    assert el.setdefault(required, 789)
+    assert_raises(TypeError, el.setdefault, bogus, 456)
+
+    el[ok] = 123
+    assert el.pop(ok)
+    el[required] = 456
+    assert_raises(TypeError, el.pop, required)
+    assert_raises(KeyError, el.pop, bogus)
+
+    assert_raises(NotImplementedError, el.popitem)
+
+    el.clear()
+    assert el.keys() == [required]
+
+
+def test_sparsedict_from_flat():
+    schema = SparseDict.of(Integer.named(u'x'),
+                           Integer.named(u'y'))
+
+    el = schema.from_flat([])
+    assert el.items() == []
+
+    el = schema.from_flat([(u'x', u'123')])
+    assert el.value == {u'x': 123}
+
+    el = schema.from_flat([(u'x', u'123'), (u'z', u'456')])
+    assert el.value == {u'x': 123}
+
+
+def test_sparsedict_required_from_flat():
+    schema = SparseDict.of(Integer.named(u'x'),
+                           Integer.named(u'y').using(optional=True)).\
+                        using(minimum_fields='required')
+
+    el = schema.from_flat([])
+    assert el.value == {u'x': None}
+
+    el = schema.from_flat([(u'x', u'123')])
+    assert el.value == {u'x': 123}
+
+    el = schema.from_flat([(u'y', u'456'), (u'z', u'789')])
+    assert el.value == {u'x': None, u'y': 456}
+
+
+def test_sparsedict_required_validation():
+    schema = SparseDict.of(Integer.named(u'x'),
+                           Integer.named(u'y').using(optional=True)).\
+                        using(minimum_fields='required')
+
+    el = schema()
+    assert not el.validate()
+
+    el = schema({u'y': 456})
+    assert not el.validate()
+
+    el = schema({u'x': 123, u'y': 456})
+    assert el.validate()