Completed
Pull Request — develop (#13)
by Kale
01:08
created

DictSafeMixin.__setitem__()   A

Complexity

Conditions 1

Size

Total Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
dl 0
loc 2
rs 10
c 1
b 0
f 0
1
# -*- coding: utf-8 -*-
2
"""
3
This module provides serializable, validatable, type-enforcing domain objects and data
4
transfer objects. It has many of the same motivations as the python
5
`Marshmallow <http://marshmallow.readthedocs.org/en/latest/why.html>`_ package. It is most
6
similar to `Schematics <http://schematics.readthedocs.io/>`_.
7
8
========
9
Tutorial
10
========
11
12
Chapter 1: Entity and Field Basics
13
----------------------------------
14
15
    >>> class Color(Enum):
16
    ...     blue = 0
17
    ...     black = 1
18
    ...     red = 2
19
    >>> class Car(Entity):
20
    ...     weight = NumberField(required=False)
21
    ...     wheels = IntField(default=4, validation=lambda x: 3 <= x <= 4)
22
    ...     color = EnumField(Color)
23
24
    >>> # create a new car object
25
    >>> car = Car(color=Color.blue, weight=4242.46)
26
    >>> car
27
    Car(weight=4242.46, color=0)
28
29
    >>> # it has 4 wheels, all by default
30
    >>> car.wheels
31
    4
32
33
    >>> # but a car can't have 5 wheels!
34
    >>> #  the `validation=` field is a simple callable that returns a
35
    >>> #  boolean based on validity
36
    >>> car.wheels = 5
37
    Traceback (most recent call last):
38
    ValidationError: Invalid value 5 for wheels
39
40
    >>> # we can call .dump() on car, and just get back a standard
41
    >>> #  python dict actually, it's an ordereddict to match attribute
42
    >>> #  declaration order
43
    >>> type(car.dump())
44
    <class '...OrderedDict'>
45
    >>> car.dump()
46
    OrderedDict([('weight', 4242.46), ('wheels', 4), ('color', 0)])
47
48
    >>> # and json too (note the order!)
49
    >>> car.json()
50
    '{"weight": 4242.46, "wheels": 4, "color": 0}'
51
52
    >>> # green cars aren't allowed
53
    >>> car.color = "green"
54
    Traceback (most recent call last):
55
    ValidationError: 'green' is not a valid Color
56
57
    >>> # but black cars are!
58
    >>> car.color = "black"
59
    >>> car.color
60
    <Color.black: 1>
61
62
    >>> # car.color really is an enum, promise
63
    >>> type(car.color)
64
    <enum 'Color'>
65
66
    >>> # enum assignment can be with any of (and preferentially)
67
    >>> #   (1) an enum literal,
68
    >>> #   (2) a valid enum value, or
69
    >>> #   (3) a valid enum name
70
    >>> car.color = Color.blue; car.color.value
71
    0
72
    >>> car.color = 1; car.color.name
73
    'black'
74
75
    >>> # let's do a round-trip marshalling of this thing
76
    >>> same_car = Car.from_json(car.json())  # or equally Car.from_json(json.dumps(car.dump()))
77
    >>> same_car == car
78
    True
79
80
    >>> # actually, they're two different instances
81
    >>> same_car is not car
82
    True
83
84
    >>> # this works too
85
    >>> cloned_car = Car(**car.dump())
86
    >>> cloned_car == car
87
    True
88
89
    >>> # while we're at it, these are all equivalent too
90
    >>> car == Car.from_objects(car)
91
    True
92
    >>> car == Car.from_objects({"weight": 4242.46, "wheels": 4, "color": 1})
93
    True
94
    >>> car == Car.from_json('{"weight": 4242.46, "color": 1}')
95
    True
96
97
    >>> # .from_objects() even lets you stack and combine objects
98
    >>> class DumbClass:
99
    ...     color = 0
100
    ...     wheels = 3
101
    >>> Car.from_objects(DumbClass(), dict(weight=2222, color=1))
102
    Car(weight=2222, wheels=3, color=0)
103
    >>> # and also pass kwargs that override properties pulled
104
    >>> #  off any objects
105
    >>> Car.from_objects(DumbClass(), {'weight': 2222, 'color': 1}, color=2, weight=33)
106
    Car(weight=33, wheels=3, color=2)
107
108
109
Chapter 2: Entity and Field Composition
110
---------------------------------------
111
112
    >>> # now let's get fancy
113
    >>> # a ComposableField "nests" another valid Entity
114
    >>> # a ListField's first argument is a "generic" type,
115
    >>> #   which can be a valid Entity, any python primitive
116
    >>> #   type, or a list of Entities/types
117
    >>> class Fleet(Entity):
118
    ...     boss_car = ComposableField(Car)
119
    ...     cars = ListField(Car)
120
121
    >>> # here's our fleet of company cars
122
    >>> company_fleet = Fleet(boss_car=Car(color='red'), cars=[car, same_car, cloned_car])
123
    >>> company_fleet.pretty_json()  #doctest: +SKIP
124
    {
125
      "boss_car": {
126
        "wheels": 4
127
        "color": 2,
128
      },
129
      "cars": [
130
        {
131
          "weight": 4242.46,
132
          "wheels": 4
133
          "color": 1,
134
        },
135
        {
136
          "weight": 4242.46,
137
          "wheels": 4
138
          "color": 1,
139
        },
140
        {
141
          "weight": 4242.46,
142
          "wheels": 4
143
          "color": 1,
144
        }
145
      ]
146
    }
147
148
    >>> # the boss' car is red of course (and it's still an Enum)
149
    >>> company_fleet.boss_car.color.name
150
    'red'
151
152
    >>> # and there are three cars left for the employees
153
    >>> len(company_fleet.cars)
154
    3
155
156
157
Chapter 3: Immutability
158
-----------------------
159
160
    >>> class ImmutableCar(ImmutableEntity):
161
    ...     wheels = IntField(default=4, validation=lambda x: 3 <= x <= 4)
162
    ...     color = EnumField(Color)
163
    >>> icar = ImmutableCar.from_objects({'wheels': 3, 'color': 'blue'})
164
    >>> icar
165
    ImmutableCar(wheels=3, color=0)
166
167
    >>> icar.wheels = 4
168
    Traceback (most recent call last):
169
    AttributeError: Assignment not allowed. ImmutableCar is immutable.
170
171
    >>> class FixedWheelCar(Entity):
172
    ...     wheels = IntField(default=4, immutable=True)
173
    ...     color = EnumField(Color)
174
    >>> fwcar = FixedWheelCar.from_objects(icar)
175
    >>> fwcar.json()
176
    '{"wheels": 3, "color": 0}'
177
178
    >>> # repainting the car is easy
179
    >>> fwcar.color = Color.red
180
    >>> fwcar.color.name
181
    'red'
182
183
    >>> # can't really change the number of wheels though
184
    >>> fwcar.wheels = 18
185
    Traceback (most recent call last):
186
    AttributeError: The wheels field is immutable.
187
188
189
Chapter X: The del and null Weeds
190
---------------------------------
191
192
    >>> old_date = lambda: isoparse('1982-02-17')
193
    >>> class CarBattery(Entity):
194
    ...     # NOTE: default value can be a callable!
195
    ...     first_charge = DateField(required=False)  # default=None, nullable=False
196
    ...     latest_charge = DateField(default=old_date, nullable=True)  # required=True
197
    ...     expiration = DateField(default=old_date, required=False, nullable=False)
198
199
    >>> # starting point
200
    >>> battery = CarBattery()
201
    >>> battery
202
    CarBattery()
203
    >>> battery.json()
204
    '{"latest_charge": "1982-02-17T00:00:00", "expiration": "1982-02-17T00:00:00"}'
205
206
    >>> # first_charge is not assigned a default value. Once one is assigned, it can be deleted,
207
    >>> #   but it can't be made null.
208
    >>> battery.first_charge = isoparse('2016-03-23')
209
    >>> battery
210
    CarBattery(first_charge=datetime.datetime(2016, 3, 23, 0, 0))
211
    >>> battery.first_charge = None
212
    Traceback (most recent call last):
213
    ValidationError: Value for first_charge not given or invalid.
214
    >>> del battery.first_charge
215
    >>> battery
216
    CarBattery()
217
218
    >>> # latest_charge can be null, but it can't be deleted. The default value is a callable.
219
    >>> del battery.latest_charge
220
    Traceback (most recent call last):
221
    AttributeError: The latest_charge field is required and cannot be deleted.
222
    >>> battery.latest_charge = None
223
    >>> battery.json()
224
    '{"latest_charge": null, "expiration": "1982-02-17T00:00:00"}'
225
226
    >>> # expiration is assigned by default, can't be made null, but can be deleted.
227
    >>> battery.expiration
228
    datetime.datetime(1982, 2, 17, 0, 0)
229
    >>> battery.expiration = None
230
    Traceback (most recent call last):
231
    ValidationError: Value for expiration not given or invalid.
232
    >>> del battery.expiration
233
    >>> battery.json()
234
    '{"latest_charge": null}'
235
236
237
"""
238
from __future__ import absolute_import, division, print_function
239
240
from collections import Iterable, Sequence, Mapping
241
from copy import deepcopy
242
from datetime import datetime
243
from enum import Enum
244
from functools import reduce
245
from json import JSONEncoder, dumps as json_dumps, loads as json_loads
246
from logging import getLogger
247
248
from ._vendor.boltons.timeutils import isoparse
249
from .collection import AttrDict, frozendict, make_immutable
250
from .compat import (integer_types, iteritems, itervalues, odict, string_types, text_type,
251
                     with_metaclass, isiterable)
252
from .exceptions import Raise, ValidationError
253
from .ish import find_or_none
254
from .logz import DumpEncoder
255
from .type_coercion import maybecall
256
257
log = getLogger(__name__)
258
259
__all__ = [
260
    "Entity", "ImmutableEntity", "Field",
261
    "BooleanField", "BoolField", "IntegerField", "IntField",
262
    "NumberField", "StringField", "DateField",
263
    "EnumField", "ListField", "MapField", "ComposableField",
264
]
265
266
KEY_OVERRIDES_MAP = "__key_overrides__"
267
268
269
NOTES = """
270
271
Current deficiencies to schematics:
272
  - no get_mock_object method
273
  - no context-dependent serialization or MultilingualStringType
274
  - name = StringType(serialized_name='person_name', alternate_names=['human_name'])
275
  - name = StringType(serialize_when_none=False)
276
  - more flexible validation error messages
277
  - field validation can depend on other fields
278
  - 'roles' containing blacklists for .dump() and .json()
279
    __roles__ = {
280
        EntityRole.registered_name: Blacklist('field1', 'field2'),
281
        EntityRole.another_registered_name: Whitelist('field3', 'field4'),
282
    }
283
284
285
TODO:
286
  - alternate field names
287
  - add dump_if_null field option
288
  - add help/description parameter to Field
289
  - consider leveraging slots
290
  - collect all validation errors before raising
291
  - Allow returning string error message for validation instead of False
292
  - profile and optimize
293
  - use boltons instead of dateutil
294
  - correctly implement copy and deepcopy on fields and Entity, DictSafeMixin
295
    http://stackoverflow.com/questions/1500718/what-is-the-right-way-to-override-the-copy-deepcopy-operations-on-an-object-in-p
296
297
298
Optional Field Properties:
299
  - validation = None
300
  - default = None
301
  - required = True
302
  - in_dump = True
303
  - nullable = False
304
305
Behaviors:
306
  - Nullable is a "hard" setting, in that the value is either always or never allowed to be None.
307
  - What happens then if required=False and nullable=False?
308
      - The object can be init'd without a value (though not with a None value).
309
        getattr throws AttributeError
310
      - Any assignment must be not None.
311
312
313
  - Setting a value to None doesn't "unset" a value.  (That's what del is for.)  And you can't
314
    del a value if required=True, nullable=False, default=None.
315
316
  - If a field is not required, del does *not* "unmask" the default value.  Instead, del
317
    removes the value from the object entirely.  To get back the default value, need to recreate
318
    the object.  Entity.from_objects(old_object)
319
320
321
  - Disabling in_dump is a "hard" setting, in that with it disabled the field will never get
322
    dumped.  With it enabled, the field may or may not be dumped depending on its value and other
323
    settings.
324
325
  - Required is a "hard" setting, in that if True, a valid value or default must be provided. None
326
    is only a valid value or default if nullable is True.
327
328
  - In general, nullable means that None is a valid value.
329
    - getattr returns None instead of raising Attribute error
330
    - If in_dump, field is given with null value.
331
    - If default is not None, assigning None clears a previous assignment. Future getattrs return
332
      the default value.
333
    - What does nullable mean with default=None and required=True? Does instantiation raise
334
      an error if assignment not made on init? Can IntField(nullable=True) be init'd?
335
336
  - If required=False and nullable=False, field will only be in dump if field!=None.
337
    Also, getattr raises AttributeError.
338
  - If required=False and nullable=True, field will be in dump if field==None.
339
340
  - If in_dump is True, does default value get dumped:
341
    - if no assignment, default exists
342
    - if nullable, and assigned None
343
  - How does optional validation work with nullable and assigning None?
344
  - When does gettattr throw AttributeError, and when does it return None?
345
346
347
348
"""
349
350
351
class Field(object):
352
    """
353
    Fields are doing something very similar to boxing and unboxing
354
    of c#/java primitives.  __set__ should take a "primitive" or "raw" value and create a "boxed"
355
    or "programatically useable" value of it.  While __get__ should return the boxed value,
356
    dump in turn should unbox the value into a primitive or raw value.
357
358
    Arguments:
359
        types_ (primitive literal or type or sequence of types):
360
        default (any, callable, optional):  If default is callable, it's guaranteed to return a
361
            valid value at the time of Entity creation.
362
        required (boolean, optional):
363
        validation (callable, optional):
364
        dump (boolean, optional):
365
    """
366
367
    # Used to track order of field declarations. Supporting python 2.7, so can't rely
368
    #   on __prepare__.  Strategy lifted from http://stackoverflow.com/a/4460034/2127762
369
    _order_helper = 0
370
371
    def __init__(self, default=None, required=True, validation=None,
372
                 in_dump=True, nullable=False, immutable=False):
373
        self._required = required
374
        self._validation = validation
375
        self._in_dump = in_dump
376
        self._nullable = nullable
377
        self._immutable = immutable
378
        self._default = default if callable(default) else self.box(None, default)
379
        if default is not None:
380
            self.validate(None, self.box(None, maybecall(default)))
381
382
        self._order_helper = Field._order_helper
383
        Field._order_helper += 1
384
385
    @property
386
    def name(self):
387
        try:
388
            return self._name
389
        except AttributeError:
390
            log.error("The name attribute has not been set for this field. "
391
                      "Call set_name at class creation time.")
392
            raise
393
394
    def set_name(self, name):
395
        self._name = name
396
        return self
397
398
    def __get__(self, instance, instance_type):
399
        try:
400
            if instance is None:  # if calling from the class object
401
                val = getattr(instance_type, KEY_OVERRIDES_MAP)[self.name]
402
            else:
403
                val = instance.__dict__[self.name]
404
        except AttributeError:
405
            log.error("The name attribute has not been set for this field.")
406
            raise AttributeError("The name attribute has not been set for this field.")
407
        except KeyError:
408
            if self.default is not None:
409
                val = maybecall(self.default)  # default *can* be a callable
410
            elif self._nullable:
411
                return None
412
            else:
413
                raise AttributeError("A value for {0} has not been set".format(self.name))
414
        if val is None and not self.nullable:
415
            # means the "tricky edge case" was activated in __delete__
416
            raise AttributeError("The {0} field has been deleted.".format(self.name))
417
        return self.unbox(instance, instance_type, val)
418
419
    def __set__(self, instance, val):
420
        if self.immutable and instance._initd:
421
            raise AttributeError("The {0} field is immutable.".format(self.name))
422
        # validate will raise an exception if invalid
423
        # validate will return False if the value should be removed
424
        instance.__dict__[self.name] = self.validate(instance, self.box(instance, val))
425
426
    def __delete__(self, instance):
427
        if self.immutable and instance._initd:
428
            raise AttributeError("The {0} field is immutable.".format(self.name))
429
        elif self.required:
430
            raise AttributeError("The {0} field is required and cannot be deleted."
431
                                 .format(self.name))
432
        elif not self.nullable:
433
            # tricky edge case
434
            # given a field Field(default='some value', required=False, nullable=False)
435
            # works together with Entity.dump() logic for selecting fields to include in dump
436
            # `if value is not None or field.nullable`
437
            instance.__dict__[self.name] = None
438
        else:
439
            instance.__dict__.pop(self.name, None)
440
441
    def box(self, instance, val):
442
        return val
443
444
    def unbox(self, instance, instance_type, val):
445
        return val
446
447
    def dump(self, val):
448
        return val
449
450
    def validate(self, instance, val):
451
        """
452
453
        Returns:
454
            True: if val is valid
455
456
        Raises:
457
            ValidationError
458
        """
459
        # note here calling, but not assigning; could lead to unexpected behavior
460
        if isinstance(val, self._type) and (self._validation is None or self._validation(val)):
461
            return val
462
        elif val is None and self.nullable:
463
            return val
464
        else:
465
            raise ValidationError(getattr(self, 'name', 'undefined name'), val)
466
467
    @property
468
    def required(self):
469
        return self._required
470
471
    @property
472
    def type(self):
473
        return self._type
474
475
    @property
476
    def default(self):
477
        return self._default
478
479
    @property
480
    def in_dump(self):
481
        return self._in_dump
482
483
    @property
484
    def nullable(self):
485
        return self.is_nullable
486
487
    @property
488
    def is_nullable(self):
489
        return self._nullable
490
491
    @property
492
    def immutable(self):
493
        return self._immutable
494
495
496
class BooleanField(Field):
497
    _type = bool
498
499
    def box(self, instance, val):
500
        return None if val is None else bool(val)
501
502
BoolField = BooleanField
503
504
505
class IntegerField(Field):
506
    _type = integer_types
507
508
IntField = IntegerField
509
510
511
class NumberField(Field):
512
    _type = integer_types + (float, complex)
513
514
515
class StringField(Field):
516
    _type = string_types
517
518
    def box(self, instance, val):
519
        return text_type(val) if isinstance(val, NumberField._type) else val
520
521
522
class DateField(Field):
523
    _type = datetime
524
525
    def box(self, instance, val):
526
        try:
527
            return isoparse(val) if isinstance(val, string_types) else val
528
        except ValueError as e:
529
            raise ValidationError(val, msg=e)
530
531
    def dump(self, val):
532
        return None if val is None else val.isoformat()
533
534
535
class EnumField(Field):
536
537
    def __init__(self, enum_class, default=None, required=True, validation=None,
538
                 in_dump=True, nullable=False, immutable=False):
539
        if not issubclass(enum_class, Enum):
540
            raise ValidationError(None, msg="enum_class must be an instance of Enum")
541
        self._type = enum_class
542
        super(EnumField, self).__init__(default, required, validation,
543
                                        in_dump, nullable, immutable)
544
545
    def box(self, instance, val):
546
        if val is None:
547
            # let the required/nullable logic handle validation for this case
548
            return None
549
        try:
550
            # try to box using val as an Enum name
551
            return val if isinstance(val, self._type) else self._type(val)
552
        except ValueError as e1:
553
            try:
554
                # try to box using val as an Enum value
555
                return self._type[val]
556
            except KeyError:
557
                raise ValidationError(val, msg=e1)
558
559
    def dump(self, val):
560
        return None if val is None else val.value
561
562
563
class ListField(Field):
564
    _type = tuple
565
566
    def __init__(self, element_type, default=None, required=True, validation=None,
567
                 in_dump=True, nullable=False, immutable=False):
568
        self._element_type = element_type
569
        super(ListField, self).__init__(default, required, validation,
570
                                        in_dump, nullable, immutable)
571
572
    def box(self, instance, val):
573
        if val is None:
574
            return None
575
        elif isinstance(val, string_types):
576
            raise ValidationError("Attempted to assign a string to ListField {0}"
577
                                  "".format(self.name))
578
        elif isiterable(val):
579
            et = self._element_type
580
            if isinstance(et, type) and issubclass(et, Entity):
581
                return self._type(v if isinstance(v, et) else et(**v) for v in val)
582
            else:
583
                return make_immutable(val) if self.immutable else self._type(val)
584
        else:
585
            raise ValidationError(val, msg="Cannot assign a non-iterable value to "
586
                                           "{0}".format(self.name))
587
588
    def unbox(self, instance, instance_type, val):
589
        return self._type() if val is None and not self.nullable else val
590
591
    def dump(self, val):
592
        if isinstance(self._element_type, type) and issubclass(self._element_type, Entity):
593
            return self._type(v.dump() for v in val)
594
        else:
595
            return val
596
597
    def validate(self, instance, val):
598
        if val is None:
599
            if not self.nullable:
600
                raise ValidationError(self.name, val)
601
            return None
602
        else:
603
            val = super(ListField, self).validate(instance, val)
604
            et = self._element_type
605
            self._type(Raise(ValidationError(self.name, el, et)) for el in val
606
                       if not isinstance(el, et))
607
            return val
608
609
610
class MutableListField(ListField):
611
    _type = list
612
613
614
class MapField(Field):
615
    _type = frozendict
616
617
    def __init__(self, default=None, required=True, validation=None,
618
                 in_dump=True, nullable=False):
619
        super(MapField, self).__init__(default, required, validation, in_dump, nullable, True)
620
621
    def box(self, instance, val):
622
        # TODO: really need to make this recursive to make any lists or maps immutable
623
        if val is None:
624
            return self._type()
625
        elif isiterable(val):
626
            val = make_immutable(val)
627
            if not isinstance(val, Mapping):
628
                raise ValidationError(val, msg="Cannot assign a non-iterable value to "
629
                                               "{0}".format(self.name))
630
            return val
631
        else:
632
            raise ValidationError(val, msg="Cannot assign a non-iterable value to "
633
                                           "{0}".format(self.name))
634
635
636
637
class ComposableField(Field):
638
639
    def __init__(self, field_class, default=None, required=True, validation=None,
640
                 in_dump=True, nullable=False, immutable=False):
641
        self._type = field_class
642
        super(ComposableField, self).__init__(default, required, validation,
643
                                              in_dump, nullable, immutable)
644
645
    def box(self, instance, val):
646
        if val is None:
647
            return None
648
        if isinstance(val, self._type):
649
            return val
650
        else:
651
            # assuming val is a dict now
652
            try:
653
                # if there is a key named 'self', have to rename it
654
                if hasattr(val, 'pop'):
655
                    val['slf'] = val.pop('self')
656
            except KeyError:
657
                pass  # no key of 'self', so no worries
658
            return val if isinstance(val, self._type) else self._type(**val)
659
660
    def dump(self, val):
661
        return None if val is None else val.dump()
662
663
664
class EntityType(type):
665
666
    @staticmethod
667
    def __get_entity_subclasses(bases):
668
        try:
669
            return [base for base in bases if issubclass(base, Entity) and base is not Entity]
670
        except NameError:
671
            # NameError: global name 'Entity' is not defined
672
            return ()
673
674
    def __new__(mcs, name, bases, dct):
675
        # if we're about to mask a field that's already been created with something that's
676
        #  not a field, then assign it to an alternate variable name
677
        non_field_keys = (key for key, value in iteritems(dct)
678
                          if not isinstance(value, Field) and not key.startswith('__'))
679
        entity_subclasses = EntityType.__get_entity_subclasses(bases)
680
        if entity_subclasses:
681
            keys_to_override = [key for key in non_field_keys
682
                                if any(isinstance(base.__dict__.get(key), Field)
683
                                       for base in entity_subclasses)]
684
            dct[KEY_OVERRIDES_MAP] = dict((key, dct.pop(key)) for key in keys_to_override)
685
        else:
686
            dct[KEY_OVERRIDES_MAP] = dict()
687
688
        return super(EntityType, mcs).__new__(mcs, name, bases, dct)
689
690
    def __init__(cls, name, bases, attr):
691
        super(EntityType, cls).__init__(name, bases, attr)
692
        fields = odict(cls.__fields__) if hasattr(cls, '__fields__') else odict()
693
        fields.update(sorted(((name, field.set_name(name))
694
                                      for name, field in iteritems(cls.__dict__)
695
                                      if isinstance(field, Field)),
696
                                     key=lambda item: item[1]._order_helper))
697
        cls.__fields__ = frozendict(fields)
698
        if hasattr(cls, '__register__'):
699
            cls.__register__()
700
701
    def __call__(cls, *args, **kwargs):
702
        instance = super(EntityType, cls).__call__(*args, **kwargs)
703
        setattr(instance, '_{0}__initd'.format(cls.__name__), True)
704
        return instance
705
706
    @property
707
    def fields(cls):
708
        return cls.__fields__.keys()
709
710
711
@with_metaclass(EntityType)
712
class Entity(object):
713
    __fields__ = odict()
714
    _lazy_validate = False
715
716
    def __init__(self, **kwargs):
717
        for key, field in iteritems(self.__fields__):
718
            try:
719
                setattr(self, key, kwargs[key])
720
            except KeyError:
721
                # handle the case of fields inherited from subclass but overrode on class object
722
                if key in getattr(self, KEY_OVERRIDES_MAP):
723
                    setattr(self, key, getattr(self, KEY_OVERRIDES_MAP)[key])
724
                elif field.required and field.default is None:
725
                    raise ValidationError(key, msg="{0} requires a {1} field. Instantiated with "
726
                                                   "{2}".format(self.__class__.__name__,
727
                                                                key, kwargs))
728
            except ValidationError:
729
                if kwargs[key] is not None or field.required:
730
                    raise
731
        if not self._lazy_validate:
732
            self.validate()
733
734
    @classmethod
735
    def from_objects(cls, *objects, **override_fields):
736
        init_vars = dict()
737
        search_maps = tuple(AttrDict(o) if isinstance(o, dict) else o
738
                            for o in ((override_fields,) + objects))
739
        for key in cls.__fields__:
740
            init_vars[key] = find_or_none(key, search_maps)
741
        return cls(**init_vars)
742
743
    @classmethod
744
    def from_json(cls, json_str):
745
        return cls(**json_loads(json_str))
746
747
    @classmethod
748
    def load(cls, data_dict):
749
        return cls(**data_dict)
750
751
    def validate(self):
752
        # TODO: here, validate should only have to determine if the required keys are set
753
        try:
754
            reduce(lambda _, name: getattr(self, name),
755
                   (name for name, field in iteritems(self.__fields__) if field.required)
756
                   )
757
        except TypeError as e:
758
            if str(e) == "reduce() of empty sequence with no initial value":
759
                pass
760
        except AttributeError as e:
761
            raise ValidationError(None, msg=e)
762
763
    def __repr__(self):
764
        def _valid(key):
765
            # TODO: re-enable once aliases are implemented
766
            # if key.startswith('_'):
767
            #     return False
768
            if '__' in key:
769
                return False
770
            try:
771
                getattr(self, key)
772
                return True
773
            except AttributeError:
774
                return False
775
776
        def _val(key):
777
            val = getattr(self, key)
778
            return repr(val.value) if isinstance(val, Enum) else repr(val)
779
780
        def _sort_helper(key):
781
            field = self.__fields__.get(key)
782
            return field._order_helper if field is not None else -1
783
784
        kwarg_str = ", ".join("{0}={1}".format(key, _val(key))
785
                              for key in sorted(self.__dict__, key=_sort_helper)
786
                              if _valid(key))
787
        return "{0}({1})".format(self.__class__.__name__, kwarg_str)
788
789
    @classmethod
790
    def __register__(cls):
791
        pass
792
793
    def json(self, indent=None, separators=None, **kwargs):
794
        return json_dumps(self, indent=indent, separators=separators, cls=DumpEncoder, **kwargs)
795
796
    def pretty_json(self, indent=2, separators=(',', ': '), **kwargs):
797
        return self.json(indent=indent, separators=separators, **kwargs)
798
799
    def dump(self):
800
        return odict((field.name, field.dump(value))
801
                     for field, value in ((field, getattr(self, field.name, None))
802
                                          for field in self.__dump_fields())
803
                     if value is not None or field.nullable)
804
805
    @classmethod
806
    def __dump_fields(cls):
807
        if '__dump_fields_cache' not in cls.__dict__:
808
            cls.__dump_fields_cache = tuple(field for field in itervalues(cls.__fields__)
809
                                            if field.in_dump)
810
        return cls.__dump_fields_cache
811
812
    def __eq__(self, other):
813
        if self.__class__ != other.__class__:
814
            return False
815
        rando_default = 19274656290  # need an arbitrary but definite value if field does not exist
816
        return all(getattr(self, field, rando_default) == getattr(other, field, rando_default)
817
                   for field in self.__fields__)
818
819
    def __hash__(self):
820
        return sum(hash(getattr(self, field, None)) for field in self.__fields__)
821
822
    @property
823
    def _initd(self):
824
        return getattr(self, '_{0}__initd'.format(self.__class__.__name__), None)
825
826
827
class ImmutableEntity(Entity):
828
829
    def __setattr__(self, attribute, value):
830
        if self._initd:
831
            raise AttributeError("Assignment not allowed. {0} is immutable."
832
                                 .format(self.__class__.__name__))
833
        super(ImmutableEntity, self).__setattr__(attribute, value)
834
835
    def __delattr__(self, item):
836
        if self._initd:
837
            raise AttributeError("Deletion not allowed. {0} is immutable."
838
                                 .format(self.__class__.__name__))
839
        super(ImmutableEntity, self).__delattr__(item)
840
841
842
class DictSafeMixin(object):
843
844
    def __getitem__(self, item):
845
        return getattr(self, item)
846
847
    def __setitem__(self, key, value):
848
        setattr(self, key, value)
849
850
    def __delitem__(self, key):
851
        delattr(self, key)
852
853
    def get(self, item, default=None):
854
        return getattr(self, item, default)
855
856
    def __contains__(self, item):
857
        value = getattr(self, item, None)
858
        if value is None:
859
            return False
860
        field = self.__fields__[item]
861
        if isinstance(field, (MapField, ListField)):
862
            return len(value) > 0
863
        return True
864
865
    def __iter__(self):
866
        for key in self.__fields__:
867
            if key in self:
868
                yield key
869
870
    def iteritems(self):
871
        for key in self.__fields__:
872
            if key in self:
873
                yield key, getattr(self, key)
874
875
    def items(self):
876
        return self.iteritems()
877
878
    def copy(self):
879
        return self.__class__(**self.dump())
880
881
    def setdefault(self, key, default_value):
882
        if key not in self:
883
            setattr(self, key, default_value)
884
885
    def update(self, E=None, **F):
886
        # D.update([E, ]**F) -> None.  Update D from dict/iterable E and F.
887
        # If E present and has a .keys() method, does:     for k in E: D[k] = E[k]
888
        # If E present and lacks .keys() method, does:     for (k, v) in E: D[k] = v
889
        # In either case, this is followed by: for k in F: D[k] = F[k]
890
        if E is not None:
891
            if hasattr(E, 'keys'):
892
                for k in E:
893
                    self[k] = E[k]
894
            else:
895
                for k, v in iteritems(E):
896
                    self[k] = v
897
        for k in F:
898
            self[k] = F[k]
899
900
901
class EntityEncoder(JSONEncoder):
902
    # json.dumps(obj, cls=SetEncoder)
903
    def default(self, obj):
904
        if hasattr(obj, 'dump'):
905
            return obj.dump()
906
        elif hasattr(obj, '__json__'):
907
            return obj.__json__()
908
        elif hasattr(obj, 'to_json'):
909
            return obj.to_json()
910
        elif hasattr(obj, 'as_json'):
911
            return obj.as_json()
912
        elif isinstance(obj, Enum):
913
            return obj.value
914
        return JSONEncoder.default(self, obj)
915