[PATCH 3/4] Add a deb822.RestrictedWrapper class.
John Wright
jsw at debian.org
Fri Aug 29 23:55:17 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 | 170 +++++++++++++++++++++++++++++++++++++++++++++++++++
tests/test_deb822.py | 127 ++++++++++++++++++++++++++++++++++++++
3 files changed, 300 insertions(+)
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..4f4837c 100644
--- a/lib/debian/deb822.py
+++ b/lib/debian/deb822.py
@@ -66,6 +66,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 +1365,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