-
Notifications
You must be signed in to change notification settings - Fork 70
/
Copy pathbase.py
632 lines (480 loc) · 24.6 KB
/
base.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
import json
import logging
import re
import typing
from abc import ABCMeta
from collections import OrderedDict
from inspect import Signature, Parameter
from toggl import utils, exceptions
from toggl.api import fields as model_fields
logger = logging.getLogger('toggl.api.base')
Entity = typing.TypeVar('Entity', bound='TogglEntity')
def evaluate_conditions(conditions, entity, contain=False): # type: (typing.Dict, Entity, bool) -> bool
"""
Will compare conditions dict and entity.
Condition's keys and values must match the entries attributes, but not the other way around.
:param contain: If True, then string fields won't be tested on equality but on partial match.
:param entity: TogglEntity
:param conditions: dict
:return:
"""
logger.debug(f'EvaluatingConditions: Filtering based on conditions: {conditions}')
for key, value in conditions.items():
try:
field = entity.__fields__[key]
except KeyError:
try:
field = entity.__mapped_fields__[key]
except KeyError:
logger.debug(f'EvaluatingConditions: Field {key} not found in entity {entity}')
return False
if isinstance(field, model_fields.MappingField):
if isinstance(value, TogglEntity):
value = value.id
if value is None:
raise RuntimeError('Condition\'s entity was not yet saved! We can\'t compere unsaved instances!')
mapped_entity_id = entity.__dict__.get(field.mapped_field)
# When both are None than it is desired ==> both not set
if value is None and mapped_entity_id is None:
continue
if value != mapped_entity_id:
logger.debug(f'EvaluatingConditions: Mapped entity\'s ID does not match')
return False
continue
entity_value = getattr(entity, key, None)
if isinstance(field, model_fields.SetField):
if value is None and entity_value is None:
continue
if value is None or entity_value is None:
return False
if not isinstance(value, set) and not isinstance(value, model_fields.SetContainer):
return False
if isinstance(value, set) and not entity_value._inner_set.issuperset(value):
return False
if isinstance(value, model_fields.SetContainer) \
and not entity_value._inner_set.issuperset(value._inner_set):
return False
continue
if not entity_value:
return False
if isinstance(field, model_fields.StringField) and contain:
if str(value) not in str(entity_value):
return False
continue
if str(entity_value) != str(value):
logger.debug(f'EvaluatingConditions: String values do not match: {entity_value} != {value}')
return False
return True
# TODO: Caching
class TogglSet(object):
"""
Class that is mainly responsible for fetching objects from the API.
It is always binded to an entity class that represents entries which will be fetched from the API. The binding is
done either passing the Entity's class to constructor or later on calling method bind_to_class. Without
binded Entity the class can not perform any action.
"""
def __init__(self, entity_cls=None, url=None, can_get_detail=None, can_get_list=None): # type: (Entity, typing.Optional[str], typing.Optional[bool], typing.Optional[bool]) -> None
self.entity_cls = entity_cls
self._url = url
self._can_get_detail = can_get_detail
self._can_get_list = can_get_list
def bind_to_class(self, cls): # type: (Entity) -> None
"""
Binds an Entity to the instance.
:raises exceptions.TogglException: When instance is already bound TogglException is raised.
"""
if self.entity_cls is not None:
raise exceptions.TogglException('The instance is already bound to a class {}!'.format(self.entity_cls))
self.entity_cls = cls
@property
def entity_endpoints_name(self): # type: (TogglSet) -> str
"""
Returns base URL which will be used for building listing or detail URLs.
"""
if self._url:
return self._url
if self.entity_cls is None:
raise exceptions.TogglException('The TogglSet instance is not binded to any TogglEntity!')
return self.entity_cls.get_endpoints_name()
def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str
"""
Build the listing URL.
:param caller: Defines which method called this method, it can be either 'filter' or 'all'.
:param config: Config
:param conditions: If caller == 'filter' then contain conditions for filtering. Passed as reference,
therefore any modifications will result modifications
"""
return '/me/{}'.format(self.entity_endpoints_name)
def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str
"""
Build the detail URL.
:param eid: ID of the entity to fetch.
:param config: Config
:param conditions: If caller == 'filter' then contain conditions for filtering. Passed as reference,
therefore any modifications will result modifications
"""
return '/me/{}/{}'.format(self.entity_endpoints_name, eid)
@property
def can_get_detail(self): # type: (TogglSet) -> bool
"""
Property which defines if TogglSet can fetch detail of the binded Entity.
"""
if self._can_get_detail is not None:
return self._can_get_detail
if self.entity_cls and self.entity_cls._can_get_detail is not None:
return self.entity_cls._can_get_detail
return True
@property
def can_get_list(self): # type: (TogglSet) -> bool
"""
Property which defines if TogglSet can fetch list of all objects of the binded Entity.
"""
if self._can_get_list is not None:
return self._can_get_list
if self.entity_cls and self.entity_cls._can_get_list is not None:
return self.entity_cls._can_get_list
return True
def get(self, id=None, config=None, **conditions): # type: (typing.Any, utils.Config, **typing.Any) -> typing.Optional[Entity]
"""
Method for fetching detail object of the entity. it fetches the object based on specified conditions.
If ID is used then detail URL is used to fetch object.
If other conditions are used to specify the object, then TogglSet will fetch all objects using listing URL and
filter out objects based on passed conditions.
In any case result must be only one object or no object at all. Returned is the fetched object or None.
:raises exceptions.TogglMultipleResultsException: When multiple results is returned base on the specified conditions.
"""
if self.entity_cls is None:
raise exceptions.TogglException('The TogglSet instance is not binded to any TogglEntity!')
config = config or utils.Config.factory()
if id is not None:
if self.can_get_detail:
try:
fetched_entity = utils.toggl(self.build_detail_url(id, config, conditions), 'get', config=config)
if fetched_entity is None:
return None
return self.entity_cls.deserialize(config=config, **fetched_entity)
except exceptions.TogglNotFoundException:
return None
else:
# TODO: [Q/Design] Is this desired fallback?
# Most probably it is desired for Toggl usecase, because some Entities does not have detail view (eq. Users) and need
# to do query for whole list and then filter out the entity based on ID.
conditions['id'] = id
entries = self.filter(config=config, **conditions)
if len(entries) > 1:
raise exceptions.TogglMultipleResultsException()
if not entries:
return None
return entries[0]
def _fetch_all(self, url, order, config): # type: (str, str, utils.Config) -> typing.List[Entity]
"""
Helper method that fetches all objects from given URL and deserialize them.
"""
fetched_entities = utils.toggl(url, 'get', config=config)
if isinstance(fetched_entities, dict):
fetched_entities = fetched_entities.get('data')
if fetched_entities is None:
return []
output = [self.entity_cls.deserialize(config=config, **entry) for entry in fetched_entities]
if order == 'desc':
return output[::-1]
return output
def filter(self, order='asc', config=None, contain=False, **conditions): # type: (str, utils.Config, bool, **typing.Any) -> typing.List[Entity]
"""
Method that fetches all entries and filter them out based on specified conditions.
:param order: Strings 'asc' or 'desc' which specifies how the results will be sorted (
:param config: Config instance
:param contain: Specify how evaluation of conditions is performed. If True condition is evaluated using 'in' operator, otherwise hard equality (==) is enforced.
:param conditions: Dict of conditions to filter the results. It has structure 'name of property' => 'value'
"""
config = config or utils.Config.factory()
if self.entity_cls is None:
raise exceptions.TogglException('The TogglSet instance is not binded to any TogglEntity!')
if not self.can_get_list:
raise exceptions.TogglNotAllowedException('Entity {} is not allowed to fetch list from the API!'
.format(self.entity_cls))
url = self.build_list_url('filter', config, conditions)
fetched_entities = self._fetch_all(url, order, config)
if fetched_entities is None:
return []
logger.debug(f'Filter: Fetched {fetched_entities} entities')
# There are no specified conditions ==> return all
if not conditions:
return fetched_entities
return [entity for entity in fetched_entities if evaluate_conditions(conditions, entity, contain)]
def all(self, order='asc', config=None, **kwargs): # type: (str, utils.Config, **typing.Any) -> typing.List[Entity]
"""
Method that fetches all entries and deserialize them into instances of the binded entity.
:param order: Strings 'asc' or 'desc' which specifies how the results will be sorted.
:param config: Config instance
:raises exceptions.TogglNotAllowedException: When retrieving a list of objects is not allowed.
"""
if self.entity_cls is None:
raise exceptions.TogglException('The TogglSet instance is not binded to any TogglEntity!')
if not self.can_get_list:
raise exceptions.TogglNotAllowedException('Entity {} is not allowed to fetch list from the API!'
.format(self.entity_cls))
config = config or utils.Config.factory()
url = self.build_list_url('all', config, kwargs)
return self._fetch_all(url, order, config)
def __str__(self):
return 'TogglSet<{}>'.format(self.entity_cls.__name__)
class WorkspacedTogglSet(TogglSet):
"""
Specialized TogglSet for Workspaced entries.
"""
@classmethod
def _get_workspace_id(cls, config, conditions): # type: (utils.Config, typing.Dict) -> int
if conditions.get('workspace') is not None:
return conditions['workspace'].id
elif conditions.get('workspace_id') is not None:
return conditions['workspace_id']
else:
return conditions.get('wid') or config.default_workspace.id
def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str
wid = self._get_workspace_id(config, conditions)
return f'/workspaces/{wid}/{self.entity_endpoints_name}'
def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str
"""
Build the detail URL.
:param eid: ID of the entity to fetch.
:param config: Config
"""
wid = self._get_workspace_id(config, conditions)
return f"/workspaces/{wid}/{self.entity_endpoints_name}/{eid}"
class TogglEntityMeta(ABCMeta):
"""
Toggl Entity's Meta, which collects all Fields of a Entity and build related properties ('__fields__', '__mapped_fields__', '__signature__')
Also if not defined it creates TogglSet instance binded to the Entity under 'objects' property.
"""
@staticmethod
def _make_signature(fields): # type: (typing.Dict[str, model_fields.TogglField]) -> Signature
"""
Creates Signature object for validation of passed args and kwargs. Currently not used for validation.
"""
non_default_parameters = [Parameter(field.name, Parameter.POSITIONAL_OR_KEYWORD) for field in fields.values()
if field.name != 'id' and field.required]
default_parameters = [Parameter(field.name, Parameter.POSITIONAL_OR_KEYWORD, default=field.default) for field in
fields.values()
if field.name != 'id' and not field.required]
return Signature(non_default_parameters + default_parameters)
@staticmethod
def _make_fields(attrs, parents): # type: (typing.Dict, typing.List[typing.Type[Entity]]) -> typing.Dict[str, model_fields.Field]
"""
Builds dict where keys are name of the fields and values are the TogglField's instances.
"""
fields = OrderedDict()
for parent in parents:
fields.update(parent.__fields__)
for key, field in attrs.items():
if isinstance(field, model_fields.TogglField):
if key in fields:
logger.warning(f'Field \'{key}\' is being overridden')
field.name = key
fields[key] = field
return fields
@staticmethod
def _make_mapped_fields(fields): # type: (typing.Dict[str, model_fields.TogglField]) -> typing.Dict[str, model_fields.MappingField]
"""
Similar to _make_fields(), except it takes in consideration MappedFields.
The keys of the result dict are 'mapped_field's (see MappedField implementation)
"""
out = {}
for field in fields.values():
if isinstance(field, model_fields.MappingField):
if field.mapped_field in out:
raise TypeError('MappingField conflict! There is already other field who is mapped to \'{}\''.format(field.mapped_field))
out[field.mapped_field] = field
return out
@classmethod
def __prepare__(mcs, name, bases):
return OrderedDict()
def __new__(mcs, name, bases, attrs, **kwargs):
new_class = super().__new__(mcs, name, bases, attrs, **kwargs)
fields = mcs._make_fields(attrs, bases)
setattr(new_class, '__fields__', fields)
setattr(new_class, '__mapped_fields__', mcs._make_mapped_fields(fields))
setattr(new_class, '__signature__', mcs._make_signature(fields))
# Add objects only if they are not defined to allow custom TogglSet implementations
if 'objects' not in new_class.__dict__:
setattr(new_class, 'objects', WorkspacedTogglSet(new_class))
else:
try:
new_class.objects.bind_to_class(new_class)
except (exceptions.TogglException, AttributeError):
pass
return new_class
class TogglEntity(metaclass=TogglEntityMeta):
"""
Base class for all Toggl Entities.
Simplest Entities consists only of fields declaration (eq. TogglField and its subclasses), but it is also possible
to implement custom class or instance methods for specific tasks.
This class handles serialization, saving new instances, updating the existing one, deletion etc.
Support for these operation can be customized using _can_* attributes, by default everything is enabled.
"""
__signature__ = Signature()
__fields__ = OrderedDict()
_endpoints_name = None
_validate_workspace = True
_can_create = True
_can_update = True
_can_delete = True
_can_get_detail = True
_can_get_list = True
id = model_fields.IntegerField(required=False, default=None)
objects = None # type: TogglSet
def __init__(self, config=None, **kwargs):
self._config = config or utils.Config.factory()
self.__change_dict__ = {}
for field in self.__fields__.values():
if field.name in {'id'}:
continue
if isinstance(field, model_fields.MappingField):
# User supplied most probably the whole mapped object
if field.name in kwargs:
field.init(self, kwargs.get(field.name))
continue
# Most probably converting API call with direct ID of the object
if field.mapped_field in kwargs:
field.init(self, kwargs.get(field.mapped_field))
continue
if field.default is model_fields.NOTSET and field.required:
raise TypeError('We need \'{}\' attribute!'.format(field.mapped_field))
continue
if field.name not in kwargs:
if field.default is model_fields.NOTSET and field.required:
raise TypeError('We need \'{}\' attribute!'.format(field.name))
else: # Set the attribute only when there is some value to set, so default values could work properly
field.init(self, kwargs[field.name])
def save(self, config=None): # type: (utils.Config) -> None
"""
Main method for saving the entity.
If it is a new entity (eq. entity.id is not set), then calling this method will result in creation of new object using POST call.
If this is already existing entity, then calling this method will result in updating of the object using PUT call.
For updating the entity, only changed fields are sent (this is tracked using self.__change_dict__).
Before the API call validations are performed on the instance and only after successful validation, the call is made.
:raises exceptions.TogglNotAllowedException: When action (create/update) is not allowed.
"""
if not self._can_update and self.id is not None:
raise exceptions.TogglNotAllowedException('Updating this entity is not allowed!')
if not self._can_create and self.id is None:
raise exceptions.TogglNotAllowedException('Creating this entity is not allowed!')
config = config or self._config
self.validate()
if self.id is not None: # Update
utils.toggl('/{}/{}'.format(self.get_url(), self.id), 'put', self.json(update=True), config=config)
self.__change_dict__ = {} # Reset tracking changes
else: # Create
data = utils.toggl('/{}'.format(self.get_url()), 'post', self.json(), config=config)
self.id = data['id'] # Store the returned ID
def delete(self, config=None): # type: (utils.Config) -> None
"""
Method for deletion of the entity through API using DELETE call.
This will not delete the instance's object in Python, therefore calling save() method after deletion will
result in new object created using POST call.
:raises exceptions.TogglNotAllowedException: When action is not allowed.
"""
if not self._can_delete:
raise exceptions.TogglNotAllowedException('Deleting this entity is not allowed!')
if not self.id:
raise exceptions.TogglException('This instance has not been saved yet!')
utils.toggl('/{}/{}'.format(self.get_url(), self.id), 'delete', config=config or self._config)
self.id = None # Invalidate the object, so when save() is called after delete a new object is created
def json(self, update=False): # type: (bool) -> str
"""
Serialize the entity into JSON string.
:param update: Specifies if the resulted JSON should contain only changed fields (for PUT call) or whole entity.
"""
return json.dumps(self.to_dict(serialized=True, changes_only=update))
def validate(self): # type: () -> None
"""
Performs validation across all Entity's fields.
If overloading then don't forget to call super().validate()!
"""
for field in self.__fields__.values():
try:
value = field._get_value(self)
except AttributeError:
value = None
field.validate(value, self)
def to_dict(self, serialized=False, changes_only=False): # type: (bool, bool) -> typing.Dict
"""
Method that returns dict representing the instance.
:param serialized: If True, the returned dict contains only Python primitive types and no objects (eq. so JSON serialization could happen)
:param changes_only: If True, the returned dict contains only changes to the instance since last call of save() method.
"""
from .models import WorkspacedEntity
workspace = self.workspace if isinstance(self, WorkspacedEntity) else self
allow_premium = getattr(workspace, "premium", False)
source_dict = self.__change_dict__ if changes_only else self.__fields__
entity_dict = {}
for field_name in source_dict.keys():
try:
field = self.__fields__[field_name]
except KeyError:
field = self.__mapped_fields__[field_name]
if field.premium and not allow_premium:
continue
try:
value = field._get_value(self)
except AttributeError:
value = None
if serialized:
try:
entity_dict[field.mapped_field] = field.serialize(value)
except AttributeError:
entity_dict[field.name] = field.serialize(value)
else:
entity_dict[field.name] = value
return entity_dict
def __eq__(self, other): # type: (typing.Generic[Entity]) -> bool
if not isinstance(other, self.__class__):
return False
if self.id is None or other.id is None:
raise RuntimeError('One of the instances was not yet saved! We can\'t compere unsaved instances!')
return self.id == other.id
# TODO: [Q/Design] Problem with unique field's. Copy ==> making invalid option ==> Some validation?
def __copy__(self): # type: () -> typing.Generic[Entity]
cls = self.__class__
new_instance = cls.__new__(cls)
new_instance.__dict__.update(self.__dict__)
new_instance.id = None # New instance was never saved ==> no ID for it yet
return new_instance
def __str__(self): # type: () -> str
return '{} (#{})'.format(getattr(self, 'name', None) or self.__class__.__name__, self.id)
@classmethod
def get_name(cls, verbose=False): # type: (bool) -> str
name = cls.__name__
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
if verbose:
return name.replace('_', ' ').capitalize()
return name
@classmethod
def get_endpoints_name(self): # type: () -> str
assert self._endpoints_name is not None
return self._endpoints_name
def get_url(self): # type: () -> str
return self.get_endpoints_name()
@classmethod
def deserialize(cls, config=None, **kwargs): # type: (utils.Config, **typing.Any) -> typing.Generic[Entity]
"""
Method which takes kwargs as dict representing the Entity's data and return actuall instance of the Entity.
"""
try:
kwargs.pop('at')
except KeyError:
pass
instance = cls.__new__(cls)
instance._config = config
instance.__change_dict__ = {}
for key, field in instance.__fields__.items():
try:
value = kwargs[key]
except KeyError:
try:
value = kwargs[field.mapped_field]
except (KeyError, AttributeError):
continue
field.init(instance, value)
return instance