Completed
Push — develop ( 6e8450...b7f3cf )
by Kale
56s
created

auxlib.Entity.dump()   B

Complexity

Conditions 5

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 5
dl 0
loc 5
rs 8.5454
1
"""This module provides facilities for serializable, validatable, and type-enforcing
2
domain objects.
3
4
This module has many of the same motivations as the python Marshmallow package.
5
<http://marshmallow.readthedocs.org/en/latest/why.html>
6
7
Also need to be explicit in explaining what Marshmallow doesn't do, and why this module is needed.
8
  - Provides type safety like an ORM. And like an ORM, all classes subclass Entity.
9
  - Provides BUILT IN serialization and deserialization.  Marhmallow requires a lot of code
10
    duplication.
11
12
This module gives us:
13
  - type safety
14
  - custom field validation
15
  - serialization and deserialization
16
  - rock solid foundational domain objects
17
18
19
20
21
Optional Field Properties:
22
  - validation = None
23
  - default = None
24
  - required = True
25
  - in_dump = True
26
  - nullable = False
27
28
Behaviors:
29
  - Nullable is a "hard" setting, in that the value is either always or never allowed to be None.
30
  - What happens then if required=False and nullable=False?
31
      - The object can be init'd without a value (though not with a None value).
32
        getattr throws AttributeError
33
      - Any assignment must be not None.
34
35
36
  - Setting a value to None doesn't "unset" a value.  (That's what del is for.)  And you can't
37
    del a value if required=True, nullable=False, default=None.  That should raise
38
    OperationNotAllowedError.
39
40
41
  - Disabling in_dump is a "hard" setting, in that with it disabled the field will never get
42
    dumped.  With it enabled, the field may or may not be dumped depending on its value and other
43
    settings.
44
45
  - Required is a "hard" setting, in that if True, a valid value or default must be provided. None
46
    is only a valid value or default if nullable is True.
47
48
  - In general, nullable means that None is a valid value.
49
    - getattr returns None instead of raising Attribute error
50
    - If in_dump, field is given with null value.
51
    - If default is not None, assigning None clears a previous assignment. Future getattrs return
52
      the default value.
53
    - What does nullable mean with default=None and required=True? Does instantiation raise
54
      an error if assignment not made on init? Can IntField(nullable=True) be init'd?
55
56
  - If required=False and nullable=False, field will only be in dump if field!=None.
57
    Also, getattr raises AttributeError.
58
  - If required=False and nullable=True, field will be in dump if field==None.
59
60
  - What does required and default mean?
61
  - What does required and default and nullable mean?
62
  - If in_dump is True, does default value get dumped:
63
    - if no assignment, default exists
64
    - if nullable, and assigned None
65
  - Can assign None?
66
  - How does optional validation work with nullable and assigning None?
67
  - When does gettattr throw AttributeError, and when does it return None?
68
69
70
71
72
73
74
Fields are doing something very similar to boxing and unboxing
75
of java primitives.  __set__ should take a "primitve" or "raw" value and create a "boxed"
76
or "programatically useable" value of it.  While __get__ should return the boxed value,
77
dump in turn should unbox the value into a primitive or raw value.
78
79
The process of boxing a complex object of nested primitive values:
80
81
82
ComposableField uses another Entity as its type.
83
84
85
86
87
88
89
Examples:
90
    >>> class Color(Enum):
91
    ...     blue = 0
92
    ...     black = 1
93
    ...     red = 2
94
    >>> class Truck(Entity):
95
    ...     color = EnumField(Color)
96
    ...     weight = NumberField()
97
    ...     wheels = IntField(4, in_dump=False)
98
99
    >>> truck = Truck(weight=44.4, color=Color.blue, wheels=18)
100
    >>> truck.wheels
101
    18
102
    >>> truck.color
103
    <Color.blue: 0>
104
    >>> sorted(truck.dump().items())
105
    [('color', 0), ('weight', 44.4)]
106
107
"""
108
from __future__ import absolute_import, division, print_function
109
import collections
110
import datetime
111
from functools import reduce
112
import json
113
import logging
114
115
from dateutil.parser import parse as dateparser
116
from enum import Enum
117
118
from ._vendor.five import with_metaclass, items, values
119
from ._vendor.six import integer_types, string_types
120
from .exceptions import ValidationError, Raise
121
from .collection import AttrDict
122
from .type_coercion import maybecall
123
124
log = logging.getLogger(__name__)
125
126
KEY_OVERRIDES_MAP = "__key_overrides__"
127
128
129
class Field(object):
130
    """
131
132
    Arguments:
133
        types_ (primitive literal or type or sequence of types):
134
        default (any, callable, optional):  If default is callable, it's guaranteed to return a
135
            valid value at the time of Entity creation.
136
        required (boolean, optional):
137
        validation (callable, optional):
138
        dump (boolean, optional):
139
    """
140
141
    def __init__(self, default=None, required=True, validation=None, in_dump=True, nullable=False):
142
        self._default = default if callable(default) else self.box(default)
143
        self._required = required
144
        self._validation = validation
145
        self._in_dump = in_dump
146
        self._nullable = nullable
147
        if default is not None:
148
            self.validate(self.box(maybecall(default)))
149
150
    @property
151
    def name(self):
152
        try:
153
            return self._name
154
        except AttributeError:
155
            log.error("The name attribute has not been set for this field. "
156
                      "Call set_name at class creation time.")
157
            raise
158
159
    def set_name(self, name):
160
        self._name = name
161
        return self
162
163
    def __get__(self, instance, instance_type):
164
        try:
165
            if instance is None:  # if calling from the class object
166
                val = getattr(instance_type, KEY_OVERRIDES_MAP)[self.name]
167
            else:
168
                val = instance.__dict__[self.name]
169
        except AttributeError:
170
            log.error("The name attribute has not been set for this field.")
171
            raise
172
        except KeyError:
173
            if self.default is not None:
174
                val = maybecall(self.default)  # default *can* be a callable
175
            elif self._nullable:
176
                return None
177
            else:
178
                raise AttributeError("A value for {} has not been set".format(self.name))
179
        return self.unbox(val)
180
181
    def __set__(self, instance, val):
182
        # validate will raise an exception if invalid
183
        # validate will return False if the value should be removed
184
        instance.__dict__[self.name] = self.validate(self.box(val))
185
186
    def __delete__(self, instance):
187
        instance.__dict__.pop(self.name, None)
188
189
    def box(self, val):
190
        return val
191
192
    def unbox(self, val):
193
        return val
194
195
    def dump(self, val):
196
        return val
197
198
    def validate(self, val):
199
        """
200
201
        Returns:
202
            True: if val is valid
203
204
        Raises:
205
            ValidationError
206
        """
207
        # note here calling, but not assigning; could lead to unexpected behavior
208
        if isinstance(val, self._type):
209
            if self._validation is None or self._validation(val):
210
                return val
211
        elif val is None and self.nullable:
212
            return val
213
        raise ValidationError(getattr(self, 'name', 'undefined name'), val)
214
215
    @property
216
    def required(self):
217
        return self.is_required
218
219
    @property
220
    def is_required(self):
221
        return self._required
222
223
    @property
224
    def is_enum(self):
225
        return isinstance(self._type, type) and issubclass(self._type, Enum)
226
227
    @property
228
    def type(self):
229
        return self._type
230
231
    @property
232
    def default(self):
233
        return self._default
234
235
    @property
236
    def in_dump(self):
237
        return self._in_dump
238
239
    @property
240
    def nullable(self):
241
        return self.is_nullable
242
243
    @property
244
    def is_nullable(self):
245
        return self._nullable
246
247
248
class IntField(Field):
249
    _type = integer_types
250
251
252
class StringField(Field):
253
    _type = string_types
254
255
256
class NumberField(Field):
257
    _type = integer_types + (float, complex)
258
259
260
class BooleanField(Field):
261
    _type = bool
262
263
    def box(self, val):
264
        return None if val is None else bool(val)
265
266
267
class DateField(Field):
268
    _type = datetime.datetime
269
270
    def box(self, val):
271
        try:
272
            return dateparser(val) if isinstance(val, string_types) else val
273
        except ValueError as e:
274
            raise ValidationError(val, msg=e)
275
276
    def dump(self, val):
277
        return None if val is None else val.isoformat()
278
279
280
class EnumField(Field):
281
282
    def __init__(self, enum_class, default=None, required=True, validation=None,
283
                 in_dump=True, nullable=False):
284
        self._type = enum_class
285
        super(self.__class__, self).__init__(default, required, validation, in_dump, nullable)
286
287
    def box(self, val):
288
        if val is None:
289
            return None
290
        try:
291
            return val if isinstance(val, self._type) else self._type(val)
292
        except ValueError as e:
293
            raise ValidationError(val, msg=e)
294
295
    def dump(self, val):
296
        return None if val is None else val.value
297
298
299
class ListField(Field):
300
    _type = tuple
301
302
    def __init__(self, element_type, default=None, required=True, validation=None,
303
                 in_dump=True, nullable=False):
304
        self._element_type = element_type
305
        super(self.__class__, self).__init__(default, required, validation, in_dump, nullable)
306
307
    def box(self, val):
308
        if val is None:
309
            return None
310
        elif isinstance(val, collections.Iterable):
311
            et = self._element_type
312
            if isinstance(et, type) and issubclass(et, Entity):
313
                return tuple(v if isinstance(v, et) else et(**v) for v in val)
314
            else:
315
                return tuple(val)
316
        else:
317
            raise ValidationError(val, msg="Cannot assign a non-iterable value to "
318
                                           "{}".format(self.name))
319
320
    def unbox(self, val):
321
        return tuple() if val is None else val
322
323
    def dump(self, val):
324
        if issubclass(self._element_type, Entity):
325
            return tuple(v.dump() for v in val)
326
        else:
327
            return val
328
329
    def validate(self, val):
330
        if val is None:
331
            if not self.nullable:
332
                raise ValidationError(self.name, val)
333
            return None
334
        else:
335
            val = super(self.__class__, self).validate(val)
336
            et = self._element_type
337
            tuple(Raise(ValidationError(self.name, el, et)) for el in val
338
                  if not isinstance(el, et))
339
            return val
340
341
342
class MapField(Field):
343
    _type = dict
344
345
346
class ComposableField(Field):
347
348
    def __init__(self, field_class, default=None, required=True, validation=None,
349
                 in_dump=True, nullable=False):
350
        self._type = field_class
351
        super(self.__class__, self).__init__(default, required, validation, in_dump, nullable)
352
353
    def box(self, val):
354
        if val is None:
355
            return None
356
        if isinstance(val, self._type):
357
            return val
358
        else:
359
            # assuming val is a dict now
360
            try:
361
                # if there is a key named 'self', have to rename it
362
                val['slf'] = val.pop('self')
363
            except KeyError:
364
                pass  # no key of 'self', so no worries
365
            return val if isinstance(val, self._type) else self._type(**val)
366
367
    def dump(self, val):
368
        return None if val is None else val.dump()
369
370
371
class EntityType(type):
372
373
    @staticmethod
374
    def __get_entity_subclasses(bases):
375
        try:
376
            return [base for base in bases if issubclass(base, Entity) and base is not Entity]
377
        except NameError:
378
            # NameError: global name 'Entity' is not defined
379
            return []
380
381
    def __new__(mcs, name, bases, dct):
382
        # if we're about to mask a field that's already been created with something that's
383
        #  not a field, then assign it to an alternate variable name
384
        non_field_keys = (key for key, value in items(dct)
385
                          if not isinstance(value, Field) and not key.startswith('__'))
386
        entity_subclasses = EntityType.__get_entity_subclasses(bases)
387
        if entity_subclasses:
388
            keys_to_override = [key for key in non_field_keys
389
                                if any(isinstance(base.__dict__.get(key, None), Field)
390
                                       for base in entity_subclasses)]
391
            dct[KEY_OVERRIDES_MAP] = {key: dct.pop(key) for key in keys_to_override}
392
393
        return super(EntityType, mcs).__new__(mcs, name, bases, dct)
394
395
    def __init__(cls, name, bases, attr):
396
        super(EntityType, cls).__init__(name, bases, attr)
397
        cls.__fields__ = dict(cls.__fields__) if hasattr(cls, '__fields__') else dict()
398
        cls.__fields__.update({name: field.set_name(name)
399
                               for name, field in cls.__dict__.items()
400
                               if isinstance(field, Field)})
401
        if hasattr(cls, '__register__'):
402
            cls.__register__()
403
404
    @property
405
    def fields(cls):
406
        return cls.__fields__.keys()
407
408
409
@with_metaclass(EntityType)
410
class Entity(object):
411
    __fields__ = dict()
412
    # TODO: add arg order to fields like in enum34
413
414
    def __init__(self, **kwargs):
415
        for key, field in items(self.__fields__):
416
            try:
417
                setattr(self, key, kwargs[key])
418
            except KeyError:
419
                # handle the case of fields inherited from subclass but overrode on class object
420
                if key in getattr(self, KEY_OVERRIDES_MAP, []):
421
                    setattr(self, key, getattr(self, KEY_OVERRIDES_MAP)[key])
422
                elif not field.required or field.default is not None:
423
                    pass
424
                else:
425
                    raise ValidationError(key, msg="{} requires a {} field. Instantiated with {}"
426
                                                   "".format(self.__class__.__name__, key, kwargs))
427
            except ValidationError:
428
                if kwargs[key] is None and not field.required:
429
                    pass
430
                else:
431
                    raise
432
        self.validate()
433
434
    @classmethod
435
    def create_from_objects(cls, *objects, **override_fields):
436
        init_vars = dict()
437
        search_maps = (AttrDict(override_fields), ) + objects
438
        for key, field in items(cls.__fields__):
439
            value = find_or_none(key, search_maps)
440
            if value is not None or field.required:
441
                init_vars[key] = field.type(value) if field.is_enum else value
442
        return cls(**init_vars)
443
444
    @classmethod
445
    def from_json(cls, json_str):
446
        return cls(**json.loads(json_str))
447
448
    @classmethod
449
    def load(cls, data_dict):
450
        return cls(**data_dict)
451
452
    def validate(self):
453
        # TODO: here, validate should only have to determine if the required keys are set
454
        try:
455
            reduce(lambda x, y: y, (getattr(self, name)
456
                                    for name, field in self.__fields__.items()
457
                                    if field.required))
458
        except AttributeError as e:
459
            raise ValidationError(None, msg=e)
460
        except TypeError as e:
461
            if "no initial value" in str(e):
462
                # TypeError: reduce() of empty sequence with no initial value
463
                pass
464
            else:
465
                raise  # pragma: no cover
466
467
    def __repr__(self):
468
        def _repr(val):
469
            return repr(val.value) if isinstance(val, Enum) else repr(val)
470
        return "{}({})".format(
471
            self.__class__.__name__,
472
            ", ".join("{}={}".format(key, _repr(value)) for key, value in self.__dict__.items()))
473
474
    @classmethod
475
    def __register__(cls):
476
        pass
477
478
    def dump(self):
479
        return {field.name: field.dump(value)
480
                for field, value in ((field, getattr(self, field.name, None))
481
                                     for field in self.__dump_fields())
482
                if value is not None or field.nullable}
483
484
    @classmethod
485
    def __dump_fields(cls):
486
        if '__dump_fields_cache' not in cls.__dict__:
487
            cls.__dump_fields_cache = set([field for field in values(cls.__fields__)
488
                                           if field.in_dump])
489
        return cls.__dump_fields_cache
490
491
    def __eq__(self, other):
492
        if self.__class__ != other.__class__:
493
            return False
494
        rando_default = 19274656290
495
        return all(getattr(self, field, rando_default) == getattr(other, field, rando_default)
496
                   for field in self.__fields__)
497
498
    def __hash__(self):
499
        return sum(hash(getattr(self, field, None)) for field in self.__fields__)
500
501
502
def find_or_none(key, search_maps, map_index=0):
503
    """Return the value of the first key found in the list of search_maps,
504
    otherwise return None.
505
506
    Examples:
507
        >>> d1 = AttrDict({'a': 1, 'b': 2, 'c': 3, 'e': None})
508
        >>> d2 = AttrDict({'b': 5, 'e': 6, 'f': 7})
509
        >>> find_or_none('c', (d1, d2))
510
        3
511
        >>> find_or_none('f', (d1, d2))
512
        7
513
        >>> find_or_none('b', (d1, d2))
514
        2
515
        >>> print(find_or_none('g', (d1, d2)))
516
        None
517
        >>> find_or_none('e', (d1, d2))
518
        6
519
520
    """
521
    try:
522
        attr = getattr(search_maps[map_index], key)
523
        return attr if attr is not None else find_or_none(key, search_maps[1:])
524
    except AttributeError:
525
        # not found in first map object, so go to next
526
        return find_or_none(key, search_maps, map_index+1)
527
    except IndexError:
528
        # ran out of map objects to search
529
        return None
530