Completed
Push — develop ( f5a6e3...0e4da0 )
by Kale
01:01
created

auxlib.Field   A

Complexity

Total Complexity 35

Size/Duplication

Total Lines 140
Duplicated Lines 0 %
Metric Value
dl 0
loc 140
rs 9
wmc 35

18 Methods

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