Completed
Push — develop ( 0e4da0...38cf72 )
by Kale
55s
created

auxlib.Entity._exists()   A

Complexity

Conditions 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 2
dl 0
loc 6
rs 9.4285
1
# -*- coding: utf-8 -*-
2
"""This module provides facilities for serializable, validatable, and type-enforcing
3
domain objects.
4
5
This module has many of the same motivations as the python Marshmallow package.
6
<http://marshmallow.readthedocs.org/en/latest/why.html>
7
8
Also need to be explicit in explaining what Marshmallow doesn't do, and why this module is needed.
9
  - Provides type safety like an ORM. And like an ORM, all classes subclass Entity.
10
  - Provides BUILT IN serialization and deserialization.  Marhmallow requires a lot of code
11
    duplication.
12
13
This module gives us:
14
  - type safety
15
  - custom field validation
16
  - serialization and deserialization
17
  - rock solid foundational domain objects
18
19
20
Comparison to schematics:
21
  - no get_mock_object method (yet)
22
  - no context-dependent serialization or MultilingualStringType
23
  - name = StringType(serialized_name='person_name')
24
  - name = StringType(serialize_when_none=False)
25
  - more flexible validation error messages
26
27
28
TODO:
29
  - alternate field names
30
  - add dump_if_null field option
31
  - consider adding immutability, maybe ImmutableEntity
32
33
34
Optional Field Properties:
35
  - validation = None
36
  - default = None
37
  - required = True
38
  - in_dump = True
39
  - nullable = False
40
41
Behaviors:
42
  - Nullable is a "hard" setting, in that the value is either always or never allowed to be None.
43
  - What happens then if required=False and nullable=False?
44
      - The object can be init'd without a value (though not with a None value).
45
        getattr throws AttributeError
46
      - Any assignment must be not None.
47
48
49
  - Setting a value to None doesn't "unset" a value.  (That's what del is for.)  And you can't
50
    del a value if required=True, nullable=False, default=None.  That should raise
51
    OperationNotAllowedError.
52
53
  - If a field is not required, del does *not* "unmask" the default value.  Instead, del
54
    removes the value from the object entirely.  To get back the default value, need to recreate
55
    the object.  Entity.from_objects(old_object)
56
57
58
  - Disabling in_dump is a "hard" setting, in that with it disabled the field will never get
59
    dumped.  With it enabled, the field may or may not be dumped depending on its value and other
60
    settings.
61
62
  - Required is a "hard" setting, in that if True, a valid value or default must be provided. None
63
    is only a valid value or default if nullable is True.
64
65
  - In general, nullable means that None is a valid value.
66
    - getattr returns None instead of raising Attribute error
67
    - If in_dump, field is given with null value.
68
    - If default is not None, assigning None clears a previous assignment. Future getattrs return
69
      the default value.
70
    - What does nullable mean with default=None and required=True? Does instantiation raise
71
      an error if assignment not made on init? Can IntField(nullable=True) be init'd?
72
73
  - If required=False and nullable=False, field will only be in dump if field!=None.
74
    Also, getattr raises AttributeError.
75
  - If required=False and nullable=True, field will be in dump if field==None.
76
77
  - What does required and default mean?
78
  - What does required and default and nullable mean?
79
  - If in_dump is True, does default value get dumped:
80
    - if no assignment, default exists
81
    - if nullable, and assigned None
82
  - Can assign None?
83
  - How does optional validation work with nullable and assigning None?
84
  - When does gettattr throw AttributeError, and when does it return None?
85
86
87
88
89
90
Examples:
91
    # ## Chapter 1: Entity and Field Basics ##
92
    >>> class Color(Enum):
93
    ...     blue = 0
94
    ...     black = 1
95
    ...     red = 2
96
    >>> class Car(Entity):
97
    ...     weight = NumberField(required=False)
98
    ...     wheels = IntField(default=4, validation=lambda x: 3 <= x <= 4)
99
    ...     color = EnumField(Color)
100
101
    >>> # create a new car object
102
    >>> car = Car(color=Color.blue, weight=4242.42)
103
    >>> car
104
    Car(weight=4242.42, color=0)
105
106
    >>> # it has 4 wheels, all by default
107
    >>> car.wheels
108
    4
109
110
    >>> # but a car can't have 5 wheels!
111
    >>> car.wheels = 5
112
    Traceback (most recent call last):
113
    ...
114
    auxlib.exceptions.ValidationError: Invalid value 5 for wheels
115
116
    >>> # we can call .dump() on car, and just get back a standard python dict
117
    >>> #   actually, it's ordereddict to preserve attribute declaration order
118
    >>> type(car.dump())
119
    <class 'collections.OrderedDict'>
120
    >>> car.dump()
121
    OrderedDict([('weight', 4242.42), ('wheels', 4), ('color', 0)])
122
123
    >>> # and json too
124
    >>> car.json()
125
    '{"weight": 4242.42, "wheels": 4, "color": 0}'
126
127
    >>> # green cars aren't allowed
128
    >>> car.color = "green"
129
    Traceback (most recent call last):
130
    ...
131
    auxlib.exceptions.ValidationError: 'green' is not a valid Color
132
133
    >>> # but black cars are!
134
    >>> car.color = "black"
135
    >>> car.color
136
    <Color.black: 1>
137
138
    >>> # car.color really is an enum, promise
139
    >>> type(car.color)
140
    <enum 'Color'>
141
142
    >>> # let's do a round-trip marshalling of this thing
143
    >>> same_car = Car.from_json(car.json())  # or equally Car.from_json(json.dumps(car.dump()))
144
    >>> same_car == car
145
    True
146
147
    >>> # actually, they're two different instances
148
    >>> same_car is not car
149
    True
150
151
    >>> # this works too
152
    >>> cloned_car = Car(**car.dump())
153
    >>> cloned_car == car
154
    True
155
156
    # ## Chapter 2: Entity and Field Composition ##
157
    >>> # now let's get fancy
158
    >>> class Fleet(Entity):
159
    ...     boss_car = ComposableField(Car)
160
    ...     cars = ListField(Car)
161
162
    >>> # here's our fleet of company cars
163
    >>> company_fleet = Fleet(boss_car=Car(color='red'), cars=[car, same_car, cloned_car])
164
    >>> company_fleet.pretty_json()  #doctest: +SKIP
165
    {
166
      "boss_car": {
167
        "color": 2,
168
        "wheels": 4
169
      },
170
      "cars": [
171
        {
172
          "color": 1,
173
          "weight": 4242.42,
174
          "wheels": 4
175
        },
176
        {
177
          "color": 1,
178
          "weight": 4242.42,
179
          "wheels": 4
180
        },
181
        {
182
          "color": 1,
183
          "weight": 4242.42,
184
          "wheels": 4
185
        }
186
      ]
187
    }
188
189
    >>> # the boss' car is red of course (and it's still an Enum)
190
    >>> company_fleet.boss_car.color.name
191
    'red'
192
193
    >>> # and there are three cars left for the employees
194
    >>> len(company_fleet.cars)
195
    3
196
197
    >>> # because we can
198
    >>> sum(c.weight for c in company_fleet.cars)
199
    12727.26
200
201
    # ## Chapter 3: The del and null weeds ##
202
    >>> old_date = lambda: dateparse('1982-02-17')
203
    >>> class CarBattery(Entity):
204
    ...     # NOTE: default value can be a callable!
205
    ...     first_charge = DateField(required=False)  # default=None, nullable=False
206
    ...     latest_charge = DateField(default=old_date, nullable=True)  # required=True
207
    ...     expiration = DateField(default=old_date, required=False, nullable=False)
208
209
    # starting point
210
    >>> battery = CarBattery()
211
    >>> battery
212
    CarBattery()
213
    >>> battery.json()
214
    '{"latest_charge": "1982-02-17T00:00:00", "expiration": "1982-02-17T00:00:00"}'
215
216
    # first_charge is not assigned a default value. Once one is assigned, it can be deleted,
217
    #   but it can't be made null.
218
    >>> battery.first_charge = dateparse('2016-03-23')
219
    >>> battery
220
    CarBattery(first_charge=datetime.datetime(2016, 3, 23, 0, 0))
221
    >>> battery.first_charge = None
222
    Traceback (most recent call last):
223
    ...
224
    auxlib.exceptions.ValidationError: Value for first_charge not given or invalid.
225
    >>> del battery.first_charge
226
    >>> battery
227
    CarBattery()
228
229
    # latest_charge can be null, but it can't be deleted. The default value is a callable.
230
    >>> del battery.latest_charge
231
    Traceback (most recent call last):
232
    ...
233
    AttributeError: The latest_charge field is required and cannot be deleted.
234
    >>> battery.latest_charge = None
235
    >>> battery.json()
236
    '{"latest_charge": null, "expiration": "1982-02-17T00:00:00"}'
237
238
    # expiration is assigned by default, can't be made null, but can be deleted.
239
    >>> battery.expiration
240
    datetime.datetime(1982, 2, 17, 0, 0)
241
    >>> battery.expiration = None
242
    Traceback (most recent call last):
243
    ...
244
    auxlib.exceptions.ValidationError: Value for expiration not given or invalid.
245
    >>> del battery.expiration
246
    >>> battery.json()
247
    '{"latest_charge": null}'
248
249
250
"""
251
from __future__ import absolute_import, division, print_function
252
253
from collections import OrderedDict as odict
254
from datetime import datetime
255
from functools import reduce
256
from json import loads as json_loads, dumps as json_dumps
257
from logging import getLogger
258
import collections
259
260
from enum import Enum
261
262
from ._vendor.dateutil.parser import parse as dateparse
263
from ._vendor.five import with_metaclass, items, values
264
from ._vendor.six import integer_types, string_types
265
from .collection import AttrDict
266
from .exceptions import ValidationError, Raise
267
from .type_coercion import maybecall
268
269
log = getLogger(__name__)
270
271
KEY_OVERRIDES_MAP = "__key_overrides__"
272
273
274
class Field(object):
275
    """
276
    Fields are doing something very similar to boxing and unboxing
277
    of c#/java primitives.  __set__ should take a "primitve" or "raw" value and create a "boxed"
278
    or "programatically useable" value of it.  While __get__ should return the boxed value,
279
    dump in turn should unbox the value into a primitive or raw value.
280
281
    Arguments:
282
        types_ (primitive literal or type or sequence of types):
283
        default (any, callable, optional):  If default is callable, it's guaranteed to return a
284
            valid value at the time of Entity creation.
285
        required (boolean, optional):
286
        validation (callable, optional):
287
        dump (boolean, optional):
288
    """
289
290
    # Used to track order of field declarations. Supporting python 2.7, so can't rely
291
    #   on __prepare__.  Strategy lifted from http://stackoverflow.com/a/4460034/2127762
292
    _order_helper = 0
293
294
    def __init__(self, default=None, required=True, validation=None, in_dump=True, nullable=False):
295
        self._default = default if callable(default) else self.box(default)
296
        self._required = required
297
        self._validation = validation
298
        self._in_dump = in_dump
299
        self._nullable = nullable
300
        if default is not None:
301
            self.validate(self.box(maybecall(default)))
302
303
        self._order_helper = Field._order_helper
304
        Field._order_helper += 1
305
306
    @property
307
    def name(self):
308
        try:
309
            return self._name
310
        except AttributeError:
311
            log.error("The name attribute has not been set for this field. "
312
                      "Call set_name at class creation time.")
313
            raise
314
315
    def set_name(self, name):
316
        self._name = name
317
        return self
318
319
    def __get__(self, instance, instance_type):
320
        try:
321
            if instance is None:  # if calling from the class object
322
                val = getattr(instance_type, KEY_OVERRIDES_MAP)[self.name]
323
            else:
324
                val = instance.__dict__[self.name]
325
        except AttributeError:
326
            log.error("The name attribute has not been set for this field.")
327
            raise AttributeError("The name attribute has not been set for this field.")
328
        except KeyError:
329
            if self.default is not None:
330
                val = maybecall(self.default)  # default *can* be a callable
331
            elif self._nullable:
332
                return None
333
            else:
334
                raise AttributeError("A value for {0} has not been set".format(self.name))
335
        if val is None and not self.nullable:
336
            # means the "tricky edge case" was activted in __delete__
337
            raise AttributeError("The {0} field has been deleted.".format(self.name))
338
        return self.unbox(val)
339
340
    def __set__(self, instance, val):
341
        # validate will raise an exception if invalid
342
        # validate will return False if the value should be removed
343
        instance.__dict__[self.name] = self.validate(self.box(val))
344
345
    def __delete__(self, instance):
346
        if self.required:
347
            raise AttributeError("The {0} field is required and cannot be deleted."
348
                                 .format(self.name))
349
        elif not self.nullable:
350
            # tricky edge case
351
            # given a field Field(default='some value', required=False, nullable=False)
352
            # works together with Entity.dump() logic for selecting fields to include in dump
353
            # `if value is not None or field.nullable`
354
            instance.__dict__[self.name] = None
355
        else:
356
            instance.__dict__.pop(self.name, None)
357
358
    def box(self, val):
359
        return val
360
361
    def unbox(self, val):
362
        return val
363
364
    def dump(self, val):
365
        return val
366
367
    def validate(self, val):
368
        """
369
370
        Returns:
371
            True: if val is valid
372
373
        Raises:
374
            ValidationError
375
        """
376
        # note here calling, but not assigning; could lead to unexpected behavior
377
        if isinstance(val, self._type) and (self._validation is None or self._validation(val)):
378
                return val
379
        elif val is None and self.nullable:
380
            return val
381
        raise ValidationError(getattr(self, 'name', 'undefined name'), val)
382
383
    @property
384
    def required(self):
385
        return self.is_required
386
387
    @property
388
    def is_required(self):
389
        return self._required
390
391
    @property
392
    def is_enum(self):
393
        return isinstance(self._type, type) and issubclass(self._type, Enum)
394
395
    @property
396
    def type(self):
397
        return self._type
398
399
    @property
400
    def default(self):
401
        return self._default
402
403
    @property
404
    def in_dump(self):
405
        return self._in_dump
406
407
    @property
408
    def nullable(self):
409
        return self.is_nullable
410
411
    @property
412
    def is_nullable(self):
413
        return self._nullable
414
415
416
class IntField(Field):
417
    _type = integer_types
418
419
420
class StringField(Field):
421
    _type = string_types
422
423
424
class NumberField(Field):
425
    _type = integer_types + (float, complex)
426
427
428
class BooleanField(Field):
429
    _type = bool
430
431
    def box(self, val):
432
        return None if val is None else bool(val)
433
434
435
class DateField(Field):
436
    _type = datetime
437
438
    def box(self, val):
439
        try:
440
            return dateparse(val) if isinstance(val, string_types) else val
441
        except ValueError as e:
442
            raise ValidationError(val, msg=e)
443
444
    def dump(self, val):
445
        return None if val is None else val.isoformat()
446
447
448
class EnumField(Field):
449
450
    def __init__(self, enum_class, default=None, required=True, validation=None,
451
                 in_dump=True, nullable=False):
452
        if not issubclass(enum_class, Enum):
453
            raise ValidationError(None, msg="enum_class must be an instance of Enum")
454
        self._type = enum_class
455
        super(self.__class__, self).__init__(default, required, validation, in_dump, nullable)
456
457
    def box(self, val):
458
        if val is None:
459
            # let the required/nullable logic handle validation for this case
460
            return None
461
        try:
462
            # try to box using val as an Enum name
463
            return val if isinstance(val, self._type) else self._type(val)
464
        except ValueError as e1:
465
            try:
466
                # try to box using val as an Enum value
467
                return self._type[val]
468
            except KeyError:
469
                raise ValidationError(val, msg=e1)
470
471
    def dump(self, val):
472
        return None if val is None else val.value
473
474
475
class ListField(Field):
476
    _type = tuple
477
478
    def __init__(self, element_type, default=None, required=True, validation=None,
479
                 in_dump=True, nullable=False):
480
        self._element_type = element_type
481
        super(self.__class__, self).__init__(default, required, validation, in_dump, nullable)
482
483
    def box(self, val):
484
        if val is None:
485
            return None
486
        elif isinstance(val, string_types):
487
            raise ValidationError("Attempted to assign a string to ListField {0}"
488
                                  "".format(self.name))
489
        elif isinstance(val, collections.Iterable):
490
            et = self._element_type
491
            if isinstance(et, type) and issubclass(et, Entity):
492
                return tuple(v if isinstance(v, et) else et(**v) for v in val)
493
            else:
494
                return tuple(val)
495
        else:
496
            raise ValidationError(val, msg="Cannot assign a non-iterable value to "
497
                                           "{0}".format(self.name))
498
499
    def unbox(self, val):
500
        return tuple() if val is None and not self.nullable else val
501
502
    def dump(self, val):
503
        if isinstance(self._element_type, type) and issubclass(self._element_type, Entity):
504
            return tuple(v.dump() for v in val)
505
        else:
506
            return val
507
508
    def validate(self, val):
509
        if val is None:
510
            if not self.nullable:
511
                raise ValidationError(self.name, val)
512
            return None
513
        else:
514
            val = super(self.__class__, self).validate(val)
515
            et = self._element_type
516
            tuple(Raise(ValidationError(self.name, el, et)) for el in val
517
                  if not isinstance(el, et))
518
            return val
519
520
521
class MapField(Field):
522
    _type = dict
523
    __eq__ = dict.__eq__
524
    __hash__ = dict.__hash__
525
526
527
class ComposableField(Field):
528
529
    def __init__(self, field_class, default=None, required=True, validation=None,
530
                 in_dump=True, nullable=False):
531
        self._type = field_class
532
        super(self.__class__, self).__init__(default, required, validation, in_dump, nullable)
533
534
    def box(self, val):
535
        if val is None:
536
            return None
537
        if isinstance(val, self._type):
538
            return val
539
        else:
540
            # assuming val is a dict now
541
            try:
542
                # if there is a key named 'self', have to rename it
543
                val['slf'] = val.pop('self')
544
            except KeyError:
545
                pass  # no key of 'self', so no worries
546
            return val if isinstance(val, self._type) else self._type(**val)
547
548
    def dump(self, val):
549
        return None if val is None else val.dump()
550
551
552
class EntityType(type):
553
554
    @staticmethod
555
    def __get_entity_subclasses(bases):
556
        try:
557
            return [base for base in bases if issubclass(base, Entity) and base is not Entity]
558
        except NameError:
559
            # NameError: global name 'Entity' is not defined
560
            return []
561
562
    def __new__(mcs, name, bases, dct):
563
        # if we're about to mask a field that's already been created with something that's
564
        #  not a field, then assign it to an alternate variable name
565
        non_field_keys = (key for key, value in items(dct)
566
                          if not isinstance(value, Field) and not key.startswith('__'))
567
        entity_subclasses = EntityType.__get_entity_subclasses(bases)
568
        if entity_subclasses:
569
            keys_to_override = [key for key in non_field_keys
570
                                if any(isinstance(base.__dict__.get(key), Field)
571
                                       for base in entity_subclasses)]
572
            dct[KEY_OVERRIDES_MAP] = {key: dct.pop(key) for key in keys_to_override}
573
        else:
574
            dct[KEY_OVERRIDES_MAP] = dict()
575
576
        return super(EntityType, mcs).__new__(mcs, name, bases, dct)
577
578
    def __init__(cls, name, bases, attr):
579
        super(EntityType, cls).__init__(name, bases, attr)
580
        cls.__fields__ = odict(cls.__fields__) if hasattr(cls, '__fields__') else odict()
581
        cls.__fields__.update(sorted(((name, field.set_name(name))
582
                                      for name, field in cls.__dict__.items()
583
                                      if isinstance(field, Field)),
584
                                     key=lambda item: item[1]._order_helper))
585
        if hasattr(cls, '__register__'):
586
            cls.__register__()
587
588
    @property
589
    def fields(cls):
590
        return cls.__fields__.keys()
591
592
593
@with_metaclass(EntityType)
594
class Entity(object):
595
    __fields__ = odict()
596
597
    def __init__(self, **kwargs):
598
        for key, field in items(self.__fields__):
599
            try:
600
                setattr(self, key, kwargs[key])
601
            except KeyError:
602
                # handle the case of fields inherited from subclass but overrode on class object
603
                if key in getattr(self, KEY_OVERRIDES_MAP):
604
                    setattr(self, key, getattr(self, KEY_OVERRIDES_MAP)[key])
605
                elif field.required and field.default is None:
606
                    raise ValidationError(key, msg="{0} requires a {1} field. Instantiated with "
607
                                                   "{2}".format(self.__class__.__name__,
608
                                                                key, kwargs))
609
            except ValidationError:
610
                if kwargs[key] is not None or field.required:
611
                    raise
612
        self.validate()
613
614
    @classmethod
615
    def create_from_objects(cls, *objects, **override_fields):
616
        init_vars = dict()
617
        search_maps = (AttrDict(override_fields), ) + objects
618
        for key, field in items(cls.__fields__):
619
            value = find_or_none(key, search_maps)
620
            if value is not None or field.required:
621
                init_vars[key] = field.type(value) if field.is_enum else value
622
        return cls(**init_vars)
623
624
    @classmethod
625
    def from_json(cls, json_str):
626
        return cls(**json_loads(json_str))
627
628
    @classmethod
629
    def load(cls, data_dict):
630
        return cls(**data_dict)
631
632
    def validate(self):
633
        # TODO: here, validate should only have to determine if the required keys are set
634
        try:
635
            reduce(lambda x, y: y, (getattr(self, name)
636
                                    for name, field in self.__fields__.items()
637
                                    if field.required))
638
        except AttributeError as e:
639
            raise ValidationError(None, msg=e)
640
        except TypeError as e:
641
            if "no initial value" in str(e):
642
                # TypeError: reduce() of empty sequence with no initial value
643
                pass
644
            else:
645
                raise  # pragma: no cover
646
647
    def __repr__(self):
648
        def _exists(key):
649
            try:
650
                getattr(self, key)
651
                return True
652
            except AttributeError:
653
                return False
654
655
        def _val(key):
656
            val = getattr(self, key)
657
            return repr(val.value) if isinstance(val, Enum) else repr(val)
658
659
        def _sort_helper(key):
660
            field = self.__fields__.get(key)
661
            return field._order_helper if field is not None else -1
662
663
        kwarg_str = ", ".join("{0}={1}".format(key, _val(key))
664
                              for key in sorted(self.__dict__, key=_sort_helper)
665
                              if _exists(key))
666
        return "{0}({1})".format(self.__class__.__name__, kwarg_str)
667
668
    @classmethod
669
    def __register__(cls):
670
        pass
671
672
    def json(self, indent=None, separators=None, **kwargs):
673
        return json_dumps(self.dump(), indent=indent, separators=separators, **kwargs)
674
675
    def pretty_json(self, indent=2, separators=(',', ': '), **kwargs):
676
        return self.json(indent=indent, separators=separators, **kwargs)
677
678
    def dump(self):
679
        return odict((field.name, field.dump(value))
680
                     for field, value in ((field, getattr(self, field.name, None))
681
                                          for field in self.__dump_fields())
682
                     if value is not None or field.nullable)
683
684
    @classmethod
685
    def __dump_fields(cls):
686
        if '__dump_fields_cache' not in cls.__dict__:
687
            cls.__dump_fields_cache = tuple(field for field in values(cls.__fields__)
688
                                            if field.in_dump)
689
        return cls.__dump_fields_cache
690
691
    def __eq__(self, other):
692
        if self.__class__ != other.__class__:
693
            return False
694
        rando_default = 19274656290  # need an arbitrary but definite value if field does not exist
695
        return all(getattr(self, field, rando_default) == getattr(other, field, rando_default)
696
                   for field in self.__fields__)
697
698
    def __hash__(self):
699
        return sum(hash(getattr(self, field, None)) for field in self.__fields__)
700
701
702
def find_or_none(key, search_maps, map_index=0):
703
    """Return the value of the first key found in the list of search_maps,
704
    otherwise return None.
705
706
    Examples:
707
        >>> d1 = AttrDict({'a': 1, 'b': 2, 'c': 3, 'e': None})
708
        >>> d2 = AttrDict({'b': 5, 'e': 6, 'f': 7})
709
        >>> find_or_none('c', (d1, d2))
710
        3
711
        >>> find_or_none('f', (d1, d2))
712
        7
713
        >>> find_or_none('b', (d1, d2))
714
        2
715
        >>> print(find_or_none('g', (d1, d2)))
716
        None
717
        >>> find_or_none('e', (d1, d2))
718
        6
719
720
    """
721
    try:
722
        attr = getattr(search_maps[map_index], key)
723
        return attr if attr is not None else find_or_none(key, search_maps[1:])
724
    except AttributeError:
725
        # not found in first map object, so go to next
726
        return find_or_none(key, search_maps, map_index+1)
727
    except IndexError:
728
        # ran out of map objects to search
729
        return None
730