[PATCH 03/12] Add a deb822.RestrictedWrapper class.

John Wright jsw at debian.org
Sun Aug 31 21:26:09 UTC 2014


From: John Wright <jsw at google.com>

This can be used to expose read-only access to a Deb822 instance's field
values as strings, while restricting write access to some fields, which
are exposed via properties, possibly with custom conversion to a
different data type.

Using this for new deb822-based classes will be more flexible and more
reliable than subclassing Deb822 directly.
  - Parsing and dumping will always work as long as the data is
    well-formed 822-format (even if the format-specific fields do not
    match their particular format).
  - If there is a problem converting the custom class for a given field
    to string (including validation errors), that is uncovered
    immediately when the field is set instead of when someone asks for
    the string representation of the whole object.
  - Much simpler interaction with Deb822 itself - it is only accessed
    through the collections.MutableMap interface and the 'dump' method,
    so users don't need to worry about how their custom code interacts
    with other internal methods of Deb822.

I wish we had done something like this for the current subclasses (e.g.
Packages and Sources).  It's a bit difficult to change that now, but we
can at least make new stuff wrap instead of inherit.
---
 debian/changelog     |   3 +
 lib/debian/deb822.py | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 tests/test_deb822.py | 127 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 300 insertions(+), 1 deletion(-)

diff --git a/debian/changelog b/debian/changelog
index 38932f1..96dd58f 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -22,6 +22,9 @@ python-debian (0.1.23) UNRELEASED; urgency=medium
     data after the signed section (Closes: #695932).
   * Drop support for python2.5.  (This allows us to do fewer import hacks
     in deb822, and probably other modules as well.)
+  * Add a deb822.RestrictedWrapper class, for exposing read-only access
+    to a Deb822 instance's field values as strings, while restricting
+    write access to some fields, which are exposed via properties.
 
  -- Stuart Prescott <stuart at debian.org>  Fri, 13 Jun 2014 00:27:59 +1000
 
diff --git a/lib/debian/deb822.py b/lib/debian/deb822.py
index 187ae40..ec3f011 100644
--- a/lib/debian/deb822.py
+++ b/lib/debian/deb822.py
@@ -37,7 +37,6 @@ except (ImportError, AttributeError):
 
 import chardet
 import collections
-import os
 import re
 import subprocess
 import sys
@@ -66,6 +65,14 @@ GPGV_DEFAULT_KEYRINGS = frozenset(['/usr/share/keyrings/debian-keyring.gpg'])
 GPGV_EXECUTABLE = '/usr/bin/gpgv'
 
 
+class Error(Exception):
+    """Base class for custom exceptions in this module."""
+
+
+class RestrictedFieldError(Error):
+    """Raised when modifying the raw value of a field is not allowed."""
+
+
 class TagSectionWrapper(collections.Mapping):
     """Wrap a TagSection object, using its find_raw method to get field values
 
@@ -1357,6 +1364,168 @@ class Packages(Deb822, _PkgRelationMixin):
                                     use_apt_pkg, shared_storage, encoding)
 
 
+class _ClassInitMeta(type):
+    """Metaclass for classes that can be initialized at creation time.
+
+    Implement the method
+
+      @classmethod
+      def _class_init(cls, new_attrs):
+        pass
+
+    on a class, and apply this metaclass to it.  The _class_init method will be
+    called right after the class is created.  The 'new_attrs' param is a dict
+    containing the attributes added in the definition of the class.
+    """
+
+    def __init__(cls, name, bases, attrs):
+        super(_ClassInitMeta, cls).__init__(name, bases, attrs)
+        cls._class_init(attrs)
+
+
+class RestrictedField(collections.namedtuple(
+        'RestrictedField', 'name from_str to_str allow_none')):
+    """Placeholder for a property providing access to a restricted field.
+
+    Use this as an attribute when defining a subclass of RestrictedWrapper.
+    It will be replaced with a property.  See the RestrictedWrapper
+    documentation for an example.
+    """
+
+    def __new__(cls, name, from_str=None, to_str=None, allow_none=True):
+        """Create a new RestrictedField placeholder.
+
+        The getter that will replace this returns (or applies the given to_str
+        function to) None for fields that do not exist in the underlying data
+        object.
+
+        :param field_name: The name of the deb822 field.
+        :param from_str: The function to apply for getters (default is to return
+            the string directly).
+        :param to_str: The function to apply for setters (default is to use the
+            value directly).  If allow_none is True, this function may return
+            None, in which case the underlying key is deleted.
+        :param allow_none: Whether it is allowed to set the value to None
+            (which results in the underlying key being deleted).
+        """
+        return super(RestrictedField, cls).__new__(
+            cls, name, from_str=from_str, to_str=to_str,
+            allow_none=allow_none)
+
+
+ at six.add_metaclass(_ClassInitMeta)
+class RestrictedWrapper(object):
+    """Base class to wrap a Deb822 object, restricting write access to some keys.
+
+    The underlying data is hidden internally.  Subclasses may keep a reference
+    to the data before giving it to this class's constructor, if necessary, but
+    RestrictedProperty should cover most use-cases.  The dump method from
+    Deb822 is directly proxied.
+
+    Typical usage:
+
+        class Foo(object):
+            def __init__(self, ...):
+                # ...
+
+            @staticmethod
+            def from_str(self, s):
+                # Parse s...
+                return Foo(...)
+
+            def to_str(self):
+                # Return in string format.
+                return ...
+
+        class MyClass(deb822.RestrictedWrapper):
+            def __init__(self):
+                data = deb822.Deb822()
+                data['Bar'] = 'baz'
+                super(MyClass, self).__init__(data)
+
+            foo = deb822.RestrictedProperty(
+                    'Foo', from_str=Foo.from_str, to_str=Foo.to_str)
+
+            bar = deb822.RestrictedProperty('Bar', allow_none=False)
+
+        d = MyClass()
+        d['Bar'] # returns 'baz'
+        d['Bar'] = 'quux' # raises RestrictedFieldError
+        d.bar = 'quux'
+        d.bar # returns 'quux'
+        d['Bar'] # returns 'quux'
+
+        d.foo = Foo(...)
+        d['Foo'] # returns string representation of foo
+    """
+
+    @classmethod
+    def _class_init(cls, new_attrs):
+        restricted_fields = []
+        for attr_name, val in new_attrs.items():
+            if isinstance(val, RestrictedField):
+                restricted_fields.append(val.name.lower())
+                cls.__init_restricted_field(attr_name, val)
+        cls.__restricted_fields = frozenset(restricted_fields)
+
+    @classmethod
+    def __init_restricted_field(cls, attr_name, field):
+        def getter(self):
+            val = self.__data.get(field.name)
+            if field.from_str is not None:
+                return field.from_str(val)
+            return val
+
+        def setter(self, val):
+            if val is not None and field.to_str is not None:
+                val = field.to_str(val)
+            if val is None:
+                if field.allow_none:
+                    if field.name in self.__data:
+                        del self.__data[field.name]
+                else:
+                    raise TypeError('value must not be None')
+            else:
+                self.__data[field.name] = val
+
+        setattr(cls, attr_name, property(getter, setter, None, field.name))
+
+    def __init__(self, data):
+        """Initializes the wrapper over 'data', a Deb822 object."""
+        super(RestrictedWrapper, self).__init__()
+        self.__data = data
+
+    def __getitem__(self, key):
+        return self.__data[key]
+
+    def __setitem__(self, key, value):
+        if key.lower() in self.__restricted_fields:
+            raise RestrictedFieldError(
+                '%s may not be modified directly; use the associated'
+                ' property' % key)
+        self.__data[key] = value
+
+    def __delitem__(self, key):
+        if key.lower() in self.__restricted_fields:
+            raise RestrictedFieldError(
+                '%s may not be modified directly; use the associated'
+                ' property' % key)
+        del self.__data[key]
+
+    def __iter__(self):
+        return iter(self.__data)
+
+    def __len__(self):
+        return len(self.__data)
+
+    def dump(self, *args, **kwargs):
+        """Calls dump() on the underlying data object.
+
+        See Deb822.dump for more information.
+        """
+        return self.__data.dump(*args, **kwargs)
+
+
 class _CaseInsensitiveString(str):
     """Case insensitive string.
     """
diff --git a/tests/test_deb822.py b/tests/test_deb822.py
index f12dbfd..910a958 100755
--- a/tests/test_deb822.py
+++ b/tests/test_deb822.py
@@ -1196,5 +1196,132 @@ class TestGpgInfo(unittest.TestCase):
         self._validate_gpg_info(gpg_info)
 
 
+def _no_space(s):
+    """Returns s.  Raises ValueError if s contains any whitespace."""
+    if re.search(r'\s', s):
+        raise ValueError('whitespace not allowed')
+    return s
+
+
+class RestrictedWrapperTest(unittest.TestCase):
+    class Wrapper(deb822.RestrictedWrapper):
+        restricted_field = deb822.RestrictedField('Restricted-Field')
+        required_field = deb822.RestrictedField('Required-Field', allow_none=False)
+        space_separated = deb822.RestrictedField(
+                'Space-Separated',
+                from_str=lambda s: tuple((s or '').split()),
+                to_str=lambda seq: ' '.join(_no_space(s) for s in seq) or None)
+
+    def test_unrestricted_get_and_set(self):
+        data = deb822.Deb822()
+        data['Foo'] = 'bar'
+
+        wrapper = self.Wrapper(data)
+        self.assertEqual('bar', wrapper['Foo'])
+        wrapper['foo'] = 'baz'
+        self.assertEqual('baz', wrapper['Foo'])
+        self.assertEqual('baz', wrapper['foo'])
+
+        multiline = 'First line\n Another line'
+        wrapper['X-Foo-Bar'] = multiline
+        self.assertEqual(multiline, wrapper['X-Foo-Bar'])
+        self.assertEqual(multiline, wrapper['x-foo-bar'])
+
+        expected_data = deb822.Deb822()
+        expected_data['Foo'] = 'baz'
+        expected_data['X-Foo-Bar'] = multiline
+        self.assertEqual(expected_data.keys(), data.keys())
+        self.assertEqual(expected_data, data)
+
+    def test_trivially_restricted_get_and_set(self):
+        data = deb822.Deb822()
+        data['Required-Field'] = 'some value'
+
+        wrapper = self.Wrapper(data)
+        self.assertEqual('some value', wrapper.required_field)
+        self.assertEqual('some value', wrapper['Required-Field'])
+        self.assertEqual('some value', wrapper['required-field'])
+        self.assertIsNone(wrapper.restricted_field)
+
+        with self.assertRaises(deb822.RestrictedFieldError):
+            wrapper['Required-Field'] = 'foo'
+        with self.assertRaises(deb822.RestrictedFieldError):
+            wrapper['required-field'] = 'foo'
+        with self.assertRaises(deb822.RestrictedFieldError):
+            wrapper['Restricted-Field'] = 'foo'
+        with self.assertRaises(deb822.RestrictedFieldError):
+            wrapper['Restricted-field'] = 'foo'
+
+        with self.assertRaises(deb822.RestrictedFieldError):
+            del wrapper['Required-Field']
+        with self.assertRaises(deb822.RestrictedFieldError):
+            del wrapper['required-field']
+        with self.assertRaises(deb822.RestrictedFieldError):
+            del wrapper['Restricted-Field']
+        with self.assertRaises(deb822.RestrictedFieldError):
+            del wrapper['restricted-field']
+
+        with self.assertRaises(TypeError):
+            wrapper.required_field = None
+
+        wrapper.restricted_field = 'special value'
+        self.assertEqual('special value', data['Restricted-Field'])
+        wrapper.restricted_field = None
+        self.assertFalse('Restricted-Field' in data)
+        self.assertIsNone(wrapper.restricted_field)
+
+        wrapper.required_field = 'another value'
+        self.assertEqual('another value', data['Required-Field'])
+
+    def test_set_already_none_to_none(self):
+        data = deb822.Deb822()
+        wrapper = self.Wrapper(data)
+        wrapper.restricted_field = 'Foo'
+        wrapper.restricted_field = None
+        self.assertFalse('restricted-field' in data)
+        wrapper.restricted_field = None
+        self.assertFalse('restricted-field' in data)
+
+    def test_processed_get_and_set(self):
+        data = deb822.Deb822()
+        data['Space-Separated'] = 'foo bar baz'
+
+        wrapper = self.Wrapper(data)
+        self.assertEqual(('foo', 'bar', 'baz'), wrapper.space_separated)
+        wrapper.space_separated = ['bar', 'baz', 'quux']
+        self.assertEqual('bar baz quux', data['space-separated'])
+        self.assertEqual('bar baz quux', wrapper['space-separated'])
+        self.assertEqual(('bar', 'baz', 'quux'), wrapper.space_separated)
+
+        with self.assertRaises(ValueError) as cm:
+            wrapper.space_separated = ('foo', 'bar baz')
+        self.assertEqual(('whitespace not allowed',), cm.exception.args)
+
+        wrapper.space_separated = None
+        self.assertEqual((), wrapper.space_separated)
+        self.assertFalse('space-separated' in data)
+        self.assertFalse('Space-Separated' in data)
+
+        wrapper.space_separated = ()
+        self.assertEqual((), wrapper.space_separated)
+        self.assertFalse('space-separated' in data)
+        self.assertFalse('Space-Separated' in data)
+
+    def test_dump(self):
+        data = deb822.Deb822()
+        data['Foo'] = 'bar'
+        data['Baz'] = 'baz'
+        data['Space-Separated'] = 'baz quux'
+        data['Required-Field'] = 'required value'
+        data['Restricted-Field'] = 'restricted value'
+
+        wrapper = self.Wrapper(data)
+        self.assertEqual(data.dump(), wrapper.dump())
+
+        wrapper.restricted_field = 'another value'
+        wrapper.space_separated = ('bar', 'baz', 'quux')
+        self.assertEqual(data.dump(), wrapper.dump())
+
+
 if __name__ == '__main__':
     unittest.main()
-- 
2.1.0




More information about the pkg-python-debian-maint mailing list