/
conditions.py
447 lines (346 loc) · 15.1 KB
/
conditions.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
"""Conditions and sets of conditions."""
from __future__ import unicode_literals
import logging
from django.utils import six
from django.utils.translation import ugettext as _
from djblets.conditions.errors import (ConditionChoiceNotFoundError,
ConditionOperatorNotFoundError,
InvalidConditionModeError,
InvalidConditionValueError)
class Condition(object):
"""A condition used to match state to a choice, operator, and value.
Conditions store a choice, operator, and value (depending on the operator).
Callers can query whether a value fulfills a given condition, making it
easy for users to compose sets of rules safely for controlling behavior
in an application without having to write any code.
Generally, queries will be made against a :py:class:`ConditionSet`, instead
of an individual Condition.
Attributes:
choice (djblets.conditions.choices.BaseConditionChoice):
The choice stored for this condition.
operator (djblets.conditions.operators.BaseConditionOperator):
The operator stored for this condition.
value (object):
The value stored for this condition.
raw_value (object):
The raw (serialized) value for this condition. This is used
internally, and won't usually be needed by a caller.
"""
@classmethod
def deserialize(cls, choices, data, condition_index=None,
choice_kwargs={}):
"""Deserialize a condition from serialized data.
This expects data serialized by :py:meth:`serialize`.
Args:
choices (djblets.conditions.choices.ConditionChoices):
Possible choices for the condition.
data (dict):
Serialized data representing this condition.
condition_index (int, optional):
The index of the condition within the set of conditions.
This is used for exceptions to help identify which condition
failed during deserialization.
Returns:
djblets.conditions.conditions.Condition:
The deserialized condition.
Raises:
djblets.conditions.errors.ConditionChoiceNotFoundError:
The choice ID referenced in the data was missing or did not
match a valid choice.
djblets.conditions.errors.ConditionOperatorNotFoundError:
The operator ID referenced in the data was missing or did not
match a valid operator for the choice.
djblets.conditions.errors.InvalidConditionValueError:
The value was missing from the payload data or was not valid
for the choice and operator.
"""
# Sanity-check that we have the data we expect.
try:
choice_id = data['choice']
except KeyError:
logging.debug('Condition.deserialize: Missing "choice" key for '
'condition %r',
data)
raise ConditionChoiceNotFoundError(
_('A choice is required.'),
condition_index=condition_index)
try:
operator_id = data['op']
except KeyError:
logging.debug('Condition.deserialize: Missing "op" key for '
'condition %r',
data)
raise ConditionOperatorNotFoundError(
_('An operator is required.'),
condition_index=condition_index)
# Load the choice.
try:
choice = choices.get_choice(choice_id, choice_kwargs=choice_kwargs)
except ConditionChoiceNotFoundError as e:
logging.debug('Condition.deserialize: Invalid "choice" value '
'"%s" for condition %r',
choice_id, data)
raise ConditionChoiceNotFoundError(
six.text_type(e),
choice_id=choice_id,
condition_index=condition_index)
# Load the operator.
try:
operator = choice.get_operator(operator_id)
except ConditionOperatorNotFoundError as e:
logging.debug('Condition.deserialize: Invalid "op" value "%s" '
'for condition %r',
operator_id, data)
raise ConditionOperatorNotFoundError(
six.text_type(e),
operator_id=operator_id,
condition_index=condition_index)
# Load the value.
if operator.value_field is not None:
try:
raw_value = data['value']
value = operator.value_field.deserialize_value(raw_value)
except KeyError:
logging.debug('Condition.deserialize: Missing "value" value '
'for condition %r',
data)
raise InvalidConditionValueError(
_('A value is required.'),
condition_index=condition_index)
except InvalidConditionValueError as e:
logging.debug('Condition.deserialize: Invalid "value" value '
'%r for condition %r',
raw_value, data)
e.condition_index = condition_index
raise
else:
raw_value = None
value = None
return cls(choice=choice,
operator=operator,
value=value,
raw_value=raw_value)
def __init__(self, choice, operator, value=None, raw_value=None):
"""Initialize the condition.
Args:
choice (djblets.conditions.choices.BaseConditionChoice):
The choice for this condition.
operator (djblets.conditions.operators.BaseConditionOperator):
The operator for this condition.
value (object, optional):
The value for this condition.
raw_value (object, optional):
The raw (serialized) value for this condition.
"""
self.choice = choice
self.operator = operator
self.value = value
if raw_value is None:
self.raw_value = value
else:
self.raw_value = raw_value
def matches(self, value, value_state_cache=None):
"""Return whether a value matches the condition.
Args:
value (object):
The value to match against.
value_state_cache (dict):
An optional dictionary used to cache common computable data
that might be shared across instances of one or more
conditions.
Returns:
bool:
``True`` if the value fulfills the condition. ``False`` if it
does not.
"""
if value_state_cache is None:
value_state_cache = {}
return self.choice.matches(operator=self.operator,
match_value=value,
condition_value=self.value,
value_state_cache=value_state_cache)
def serialize(self):
"""Serialize the condition to a JSON-serializable dictionary.
Returns:
dict:
A dictionary representing the condition. It can be safely
serialized to JSON.
"""
data = {
'choice': self.choice.choice_id,
'op': self.operator.operator_id,
}
if self.operator.value_field is not None:
if self.value is None:
value = None
else:
value = self.operator.value_field.serialize_value(self.value)
data['value'] = value
return data
# Make this serializable in a DjbletsJSONEncoder.
to_json = serialize
class ConditionSet(object):
"""A set of conditions used to match state and define rules.
Condition sets own multiple conditions, and are given a mode indicating
how to query state against those conditions. They're also responsible
for serializing and deserializing all data around a set of conditions to
a JSON-serializable format.
If using :py:attr:`MODE_ALL`, then all conditions must be satisfied for a
condition set to pass. If using :py:attr:`MODE_ANY`, then only one
condition must be satisfied.
Attributes:
mode (unicode):
The matching mode for the condition set. This is one of
:py:attr:`MODE_ALL` or :py:attr:`MODE_ANY`.
conditions (list of Condition):
The list of conditions that comprise this set.
"""
#: Always match without conditions.
MODE_ALWAYS = 'always'
#: All conditions must match a value to satisfy the condition set.
MODE_ALL = 'all'
#: Any condition may match a value to satisfy the condition set.
MODE_ANY = 'any'
#: A set of all the valid modes.
CONDITIONS = (MODE_ALWAYS, MODE_ALL, MODE_ANY)
#: The default mode.
DEFAULT_MODE = MODE_ALL
@classmethod
def deserialize(cls, choices, data, choice_kwargs={}):
"""Deserialize a set of conditions from serialized data.
This expects data serialized by :py:meth:`deserialize`.
Args:
choices (djblets.conditions.choices.ConditionChoices):
Possible choices for the condition set.
data (dict):
Serialized data representing this condition set.
Returns:
djblets.conditions.conditions.ConditionSet:
The deserialized condition set.
Raises:
djblets.conditions.errors.ConditionChoiceNotFoundError:
The choice ID referenced in the data was missing or did not
match a valid choice in a condition.
djblets.conditions.errors.ConditionOperatorNotFoundError:
The operator ID referenced in the data was missing or did not
match a valid operator for the choice in a condition.
djblets.conditions.errors.InvalidConditionValueError:
The value was missing from the payload data or was not valid
for the choice and operator in a condition.
djblets.conditions.errors.InvalidConditionModeError:
The stored match mode was missing or was not a valid mode.
"""
mode = data.get('mode')
if mode not in cls.CONDITIONS:
logging.debug('ConditionSet.deserialize: Invalid "mode" value '
'"%s" for condition set %r',
mode, data)
raise InvalidConditionModeError(
_('"%s" is not a valid condition mode.')
% mode)
return cls(mode, [
Condition.deserialize(choices, condition_data, i, choice_kwargs)
for i, condition_data in enumerate(data.get('conditions', []))
])
def __init__(self, mode=DEFAULT_MODE, conditions=[]):
"""Initialize the condition set.
Args:
mode (unicode, optional):
The match mode. This defaults to :py:attr:`MODE_ALL`.
conditions (list, optional):
The conditions that make up this set. This defaults to an
empty list.
Raises:
djblets.conditions.errors.InvalidConditionModeError:
The match mode is not a valid mode.
"""
if mode not in self.CONDITIONS:
raise InvalidConditionModeError(
_('"%s" is not a valid condition mode.')
% mode)
self.mode = mode
self.conditions = conditions
def matches(self, **values):
"""Check if a value matches the condition set.
Depending on the mode of the condition set, this will either require
all conditions to match, or only one.
Args:
**values (dict):
Values to match against. By default, condition choices
will match against a single ``value`` keyword argument, but
more specialized uses might take into account one or more
other keyword arguments.
Returns:
bool:
``True`` if the value fulfills the condition set. ``False`` if it
does not.
"""
if self.mode == self.MODE_ALWAYS:
match_conditions = lambda x: True
elif self.mode == self.MODE_ALL:
match_conditions = self._match_all
elif self.mode == self.MODE_ANY:
match_conditions = any
else:
# We shouldn't be here, unless someone set the mode to a bad value
# after creating the condition set.
raise ValueError('Invalid condition mode %r' % self.mode)
return match_conditions(self._get_condition_results(self.conditions,
values))
def serialize(self):
"""Serialize the condition set to a JSON-serializable dictionary.
Returns:
dict:
A dictionary representing the condition set. It can be safely
serialized to JSON.
"""
return {
'mode': self.mode,
'conditions': [
condition.serialize()
for condition in self.conditions
],
}
# Make this serializable in a DjbletsJSONEncoder.
to_json = serialize
def _get_condition_results(self, conditions, values):
"""Yield the results from each condition match.
This will iterate through all the conditions, running a match against
the provided values, yielding each result.
If a condition expects a particular value that's not provided in
``values``, it will evaluate as a false match.
Args:
conditions (list of djblets.conditions.condition.Condition):
The conditions to iterate through.
values (dict):
The dictionary of values to match against.
Yields:
bool:
The result of each condition match.
"""
value_state_cache = {}
for condition in conditions:
value_kwarg = condition.choice.value_kwarg
if value_kwarg in values:
yield condition.matches(values[value_kwarg],
value_state_cache=value_state_cache)
else:
yield False
def _match_all(self, results):
"""Return whether all results are truthy and the list is non-empty.
This works similarly to :py:func:`all`, but will return ``False`` if
the provided list is empty.
Args:
results (generator):
The condition results to iterate through.
Returns:
bool:
``True`` if there are condition results present and they all
evaluate to ``True``. ``False`` otherwise.
"""
found = False
for result in results:
if not result:
return False
found = True
return found