diff --git a/test_typedcols.py b/test_typedcols.py index c9f845e1c04d067f3b802507d14c960b1a444482..03baef57268fbdf76762a349b250350c8e2d987a 100755 --- a/test_typedcols.py +++ b/test_typedcols.py @@ -4,37 +4,54 @@ # Copyright (c) 2016, CESNET, z. s. p. o. # Use of this source is governed by an ISC license, see LICENSE file. -from typedcols import TypedDict, TypedList, KeyNotAllowed, KeysRequired, Discard, Any +from typedcols import TypedDict, OpenTypedDict, TypedList, KeyNotAllowed, KeysRequired, Discard, Any from sys import version_info import unittest +address_typedef = { + "street": {"type": str}, + "num": {"type": int, "description": "Street number"}, + "city": str, + "state": {"type": str, "required": True} +} class AddressDict(TypedDict): - typedef = { - "street": {"type": str}, - "num": {"type": int, "description": "Street number"}, - "city": str, - "state": {"type": str, "required": True} - } + typedef = address_typedef allow_unknown = True def raise_discard(x=None): raise Discard +person_typedef = { + "name": {"type": str, "default": "_Default_Value_"}, + "age": int, + "address": {"type": AddressDict}, + "tel": {"type": int, "required": True}, + "note": {}, + "discard1": Discard, + "discard2": {"type": Discard, "default": "asdf"}, + "discard3": lambda x: Discard, + "discard4": raise_discard, + "discard_default1": {"type": Any, "default": Discard}, # Same as no default + "discard_default2": {"type": Any, "default": raise_discard}, # Same as no default +} + +person_init_data = { + "age": "34", + "note": None, + "address": { + "street": "Vrchlikova", + "num": 12.3, + "city": "Kocourkov" + }, + "discard1": "junk", + "discard2": "garbage", + "discard3": "rubbish", + "discard4": "scrap" +} + class PersonDict(TypedDict): - typedef = { - "name": {"type": str, "default": "_Default_Value_"}, - "age": int, - "address": {"type": AddressDict}, - "tel": {"type": int, "required": True}, - "note": {}, - "discard1": Discard, - "discard2": {"type": Discard, "default": "asdf"}, - "discard3": lambda x: Discard, - "discard4": raise_discard, - "discard_default1": {"type": Any, "default": Discard}, # Same as no default - "discard_default2": {"type": Any, "default": raise_discard}, # Same as no default - } + typedef = person_typedef allow_unknown = False @@ -46,19 +63,7 @@ if not hasattr(unittest.TestCase, "assertRaisesRegex"): class TestTypedDict(unittest.TestCase): def setUp(self): - self.person = PersonDict({ - "age": "34", - "note": None, - "address": { - "street": "Vrchlikova", - "num": 12.3, - "city": "Kocourkov" - }, - "discard1": "junk", - "discard2": "garbage", - "discard3": "rubbish", - "discard4": "scrap" - }) + self.person = PersonDict(person_init_data) def testTypedefNormalization(self): self.assertEqual(self.person.typedef["age"], {"type": int}) @@ -127,6 +132,20 @@ class TestTypedDict(unittest.TestCase): ("note", None) ]) + +class TestOpenTypedDict(TestTypedDict): + + def setUp(self): + open_address_dict = OpenTypedDict(typedef=address_typedef.copy()) + + open_person_typedef = person_typedef.copy() + open_person_typedef["address"] = open_address_dict + + open_person_dict = OpenTypedDict(typedef=open_person_typedef) + + self.person = open_person_dict(person_init_data) + + class IntList(TypedList): item_type = int diff --git a/typedcols.py b/typedcols.py index 04398f27807934c5b0c60ce9340e79dc78c0d206..e61441977fb6689ddbbd76295a87600c9af87754 100644 --- a/typedcols.py +++ b/typedcols.py @@ -10,7 +10,7 @@ Defines TypedDict and TypedList, which enforce inserted types based on simple type definition. """ -__version__ = '0.1.9' +__version__ = '0.1.10' __author__ = 'Pavel Kácha <pavel.kacha@cesnet.cz>' import collections @@ -42,24 +42,26 @@ def Any(v): return v +def dictify_typedef(typedef): + for key in typedef: + tdef = typedef[key] + if callable(tdef): + typedef[key] = {"type": tdef} + typedef[key].setdefault("type", Any) + + class TypedDictMetaclass(abc.ABCMeta): - """ Metaclass for TypedDict, allowing simplified typedefs - if typedef is not - dict, simple type object is assumed and correct dict is created. + """ Metaclass for TypedDict, allowing simplified typedefs - if typedef is + callable, simple type object is assumed and correct dict is created. Metaclassed to be run just once for the class, not for each instance. """ - def dictifyTypedef(self, typedef): - for key in typedef: - tdef = typedef[key] - if not isinstance(tdef, collections.Mapping): - typedef[key] = {"type": tdef} - def __init__(cls, name, bases, dct): super(TypedDictMetaclass, cls).__init__(name, bases, dct) - cls.dictifyTypedef(cls.typedef) + dictify_typedef(cls.typedef) -class TypedDict(collections.MutableMapping): +class TypedDictBase(collections.MutableMapping): """ Dictionary type abstract class, which supports checking of inserted types, based on simple type definition. @@ -159,7 +161,7 @@ class TypedDict(collections.MutableMapping): .arg will be message from "description" field in type definition """ tdef = self.getTypedef(key) - valuetype = tdef.get("type", Any) + valuetype = tdef["type"] if valuetype is Discard: return try: @@ -211,7 +213,70 @@ class TypedDict(collections.MutableMapping): # Py 2 requires metaclassing by __metaclass__ attribute, whereas Py 3 # needs metaclass argument. What actually happens is the following, # so we will do it explicitly, to be compatible with both versions. -TypedDict = TypedDictMetaclass("TypedDict", (TypedDict,), {}) +TypedDict = TypedDictMetaclass("TypedDict", (TypedDictBase,), {}) + + +class TypedefSetter(object): + """ Setter for OpenTypedDict.typedef value, which forces typedef canonicalisation. + Implemented as setter only class, as it does not intercept and slow down read + access. + """ + + def __set__(self, obj, value): + dictify_typedef(value) + obj.__dict__["typedef"] = value + + +class OpenTypedDict(TypedDictBase): + """ Dictionary type class, which supports checking of inserted types, based on + simple type definition, which must be provided in constructor and is changeable + by assigning instance.typedef variable. + + Note however that changing already populated OpenTypedDict's typedef to + incompatible definition may lead to undefined results and data inconsistent + with definition. + """ + + def __init__(self, init_data=None, typedef=None, allow_unknown=False, dict_class=dict): + """ init_data: initial values + + typedef: dictionary with keys and their type definitions. Type definition + may be simple callable (int, string, check_func, + AnotherTypedDict), or dict with the following members: + "type": + type enforcing callable. If callable returns, raises + or is Discard, key will be silently discarded + "default": + new TypedDict subclass will be initialized with keys + with this value; deleted keys will also revert to it + "required": + bool, checkRequired method will report the key if not present + "description": + string, explaining field type in human readable terms + (will be used in exception explanations) + Type enforcing callable must take one argument, and return value, + coerced to expected type. Coercion may even be conversion, for example + arbitrary date string, converted to DateTime. + + allow_unknown: boolean, specifies whether dictionary allows unknown keys, + that means keys, which are not defined in 'typedef' + + dict_class: class or factory for underlying dict implementation + """ + self.allow_unknown = allow_unknown + self.dict_class = dict_class + self.typedef = typedef or {} + super(OpenTypedDict, self).__init__(init_data) + + typedef = TypedefSetter() + + def __call__(self, data): + """ Instances are made callable so they can be used in nested "type" + definitions. Note however that these classes are mutable, so + assigning new values replaces old ones. + """ + self.update(data) + return self class TypedList(collections.MutableSequence):