1
1
import logging
2
+ import re
3
+ from datetime import datetime
2
4
from enum import Enum
3
- from typing import Any , Dict , List , Optional , Union
5
+ from typing import Any , Callable , Dict , List , Optional , Union
6
+
7
+ from dateutil import tz
4
8
5
9
from ... import Logger
6
10
from .base import BaseValidator
14
18
CONDITION_VALUE = "value"
15
19
CONDITION_ACTION = "action"
16
20
FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type"
21
+ TIME_RANGE_FORMAT = "%H:%M" # hour:min 24 hours clock
22
+ TIME_RANGE_RE_PATTERN = re .compile (r"2[0-3]:[0-5]\d|[0-1]\d:[0-5]\d" ) # 24 hour clock
23
+ HOUR_MIN_SEPARATOR = ":"
17
24
18
25
19
- class RuleAction (str , Enum ):
26
+ class RuleAction (Enum ):
20
27
EQUALS = "EQUALS"
21
28
NOT_EQUALS = "NOT_EQUALS"
22
29
KEY_GREATER_THAN_VALUE = "KEY_GREATER_THAN_VALUE"
@@ -31,6 +38,37 @@ class RuleAction(str, Enum):
31
38
KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE"
32
39
VALUE_IN_KEY = "VALUE_IN_KEY"
33
40
VALUE_NOT_IN_KEY = "VALUE_NOT_IN_KEY"
41
+ SCHEDULE_BETWEEN_TIME_RANGE = "SCHEDULE_BETWEEN_TIME_RANGE" # hour:min 24 hours clock
42
+ SCHEDULE_BETWEEN_DATETIME_RANGE = "SCHEDULE_BETWEEN_DATETIME_RANGE" # full datetime format, excluding timezone
43
+ SCHEDULE_BETWEEN_DAYS_OF_WEEK = "SCHEDULE_BETWEEN_DAYS_OF_WEEK" # MONDAY, TUESDAY, .... see TimeValues enum
44
+
45
+
46
+ class TimeKeys (Enum ):
47
+ """
48
+ Possible keys when using time rules
49
+ """
50
+
51
+ CURRENT_TIME = "CURRENT_TIME"
52
+ CURRENT_DAY_OF_WEEK = "CURRENT_DAY_OF_WEEK"
53
+ CURRENT_DATETIME = "CURRENT_DATETIME"
54
+
55
+
56
+ class TimeValues (Enum ):
57
+ """
58
+ Possible values when using time rules
59
+ """
60
+
61
+ START = "START"
62
+ END = "END"
63
+ TIMEZONE = "TIMEZONE"
64
+ DAYS = "DAYS"
65
+ SUNDAY = "SUNDAY"
66
+ MONDAY = "MONDAY"
67
+ TUESDAY = "TUESDAY"
68
+ WEDNESDAY = "WEDNESDAY"
69
+ THURSDAY = "THURSDAY"
70
+ FRIDAY = "FRIDAY"
71
+ SATURDAY = "SATURDAY"
34
72
35
73
36
74
class SchemaValidator (BaseValidator ):
@@ -143,7 +181,7 @@ def validate(self) -> None:
143
181
if not isinstance (self .schema , dict ):
144
182
raise SchemaValidationError (f"Features must be a dictionary, schema={ str (self .schema )} " )
145
183
146
- features = FeaturesValidator (schema = self .schema )
184
+ features = FeaturesValidator (schema = self .schema , logger = self . logger )
147
185
features .validate ()
148
186
149
187
@@ -158,7 +196,7 @@ def validate(self):
158
196
for name , feature in self .schema .items ():
159
197
self .logger .debug (f"Attempting to validate feature '{ name } '" )
160
198
boolean_feature : bool = self .validate_feature (name , feature )
161
- rules = RulesValidator (feature = feature , boolean_feature = boolean_feature )
199
+ rules = RulesValidator (feature = feature , boolean_feature = boolean_feature , logger = self . logger )
162
200
rules .validate ()
163
201
164
202
# returns True in case the feature is a regular feature flag with a boolean default value
@@ -196,14 +234,15 @@ def validate(self):
196
234
return
197
235
198
236
if not isinstance (self .rules , dict ):
237
+ self .logger .debug (f"Feature rules must be a dictionary, feature={ self .feature_name } " )
199
238
raise SchemaValidationError (f"Feature rules must be a dictionary, feature={ self .feature_name } " )
200
239
201
240
for rule_name , rule in self .rules .items ():
202
- self .logger .debug (f"Attempting to validate rule ' { rule_name } ' " )
241
+ self .logger .debug (f"Attempting to validate rule= { rule_name } and feature= { self . feature_name } " )
203
242
self .validate_rule (
204
243
rule = rule , rule_name = rule_name , feature_name = self .feature_name , boolean_feature = self .boolean_feature
205
244
)
206
- conditions = ConditionsValidator (rule = rule , rule_name = rule_name )
245
+ conditions = ConditionsValidator (rule = rule , rule_name = rule_name , logger = self . logger )
207
246
conditions .validate ()
208
247
209
248
@staticmethod
@@ -233,12 +272,14 @@ def __init__(self, rule: Dict[str, Any], rule_name: str, logger: Optional[Union[
233
272
self .logger = logger or logging .getLogger (__name__ )
234
273
235
274
def validate (self ):
275
+
236
276
if not self .conditions or not isinstance (self .conditions , list ):
277
+ self .logger .debug (f"Condition is empty or invalid for rule={ self .rule_name } " )
237
278
raise SchemaValidationError (f"Invalid condition, rule={ self .rule_name } " )
238
279
239
280
for condition in self .conditions :
240
281
# Condition can contain PII data; do not log condition value
241
- self .logger .debug (f"Attempting to validate condition for ' { self .rule_name } ' " )
282
+ self .logger .debug (f"Attempting to validate condition for { self .rule_name } " )
242
283
self .validate_condition (rule_name = self .rule_name , condition = condition )
243
284
244
285
@staticmethod
@@ -265,8 +306,132 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str):
265
306
if not key or not isinstance (key , str ):
266
307
raise SchemaValidationError (f"'key' value must be a non empty string, rule={ rule_name } " )
267
308
309
+ # time actions need to have very specific keys
310
+ # SCHEDULE_BETWEEN_TIME_RANGE => CURRENT_TIME
311
+ # SCHEDULE_BETWEEN_DATETIME_RANGE => CURRENT_DATETIME
312
+ # SCHEDULE_BETWEEN_DAYS_OF_WEEK => CURRENT_DAY_OF_WEEK
313
+ action = condition .get (CONDITION_ACTION , "" )
314
+ if action == RuleAction .SCHEDULE_BETWEEN_TIME_RANGE .value and key != TimeKeys .CURRENT_TIME .value :
315
+ raise SchemaValidationError (
316
+ f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME' condition key, rule={ rule_name } " # noqa: E501
317
+ )
318
+ if action == RuleAction .SCHEDULE_BETWEEN_DATETIME_RANGE .value and key != TimeKeys .CURRENT_DATETIME .value :
319
+ raise SchemaValidationError (
320
+ f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME' condition key, rule={ rule_name } " # noqa: E501
321
+ )
322
+ if action == RuleAction .SCHEDULE_BETWEEN_DAYS_OF_WEEK .value and key != TimeKeys .CURRENT_DAY_OF_WEEK .value :
323
+ raise SchemaValidationError (
324
+ f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK' condition key, rule={ rule_name } " # noqa: E501
325
+ )
326
+
268
327
@staticmethod
269
328
def validate_condition_value (condition : Dict [str , Any ], rule_name : str ):
270
329
value = condition .get (CONDITION_VALUE , "" )
271
330
if not value :
272
331
raise SchemaValidationError (f"'value' key must not be empty, rule={ rule_name } " )
332
+ action = condition .get (CONDITION_ACTION , "" )
333
+
334
+ # time actions need to be parsed to make sure date and time format is valid and timezone is recognized
335
+ if action == RuleAction .SCHEDULE_BETWEEN_TIME_RANGE .value :
336
+ ConditionsValidator ._validate_schedule_between_time_and_datetime_ranges (
337
+ value , rule_name , action , ConditionsValidator ._validate_time_value
338
+ )
339
+ elif action == RuleAction .SCHEDULE_BETWEEN_DATETIME_RANGE .value :
340
+ ConditionsValidator ._validate_schedule_between_time_and_datetime_ranges (
341
+ value , rule_name , action , ConditionsValidator ._validate_datetime_value
342
+ )
343
+ elif action == RuleAction .SCHEDULE_BETWEEN_DAYS_OF_WEEK .value :
344
+ ConditionsValidator ._validate_schedule_between_days_of_week (value , rule_name )
345
+
346
+ @staticmethod
347
+ def _validate_datetime_value (datetime_str : str , rule_name : str ):
348
+ date = None
349
+
350
+ # We try to parse first with timezone information in order to return the correct error messages
351
+ # when a timestamp with timezone is used. Otherwise, the user would get the first error "must be a valid
352
+ # ISO8601 time format" which is misleading
353
+
354
+ try :
355
+ # python < 3.11 don't support the Z timezone on datetime.fromisoformat,
356
+ # so we replace any Z with the equivalent "+00:00"
357
+ # datetime.fromisoformat is orders of magnitude faster than datetime.strptime
358
+ date = datetime .fromisoformat (datetime_str .replace ("Z" , "+00:00" ))
359
+ except Exception :
360
+ raise SchemaValidationError (f"'START' and 'END' must be a valid ISO8601 time format, rule={ rule_name } " )
361
+
362
+ # we only allow timezone information to be set via the TIMEZONE field
363
+ # this way we can encode DST into the calculation. For instance, Copenhagen is
364
+ # UTC+2 during winter, and UTC+1 during summer, which would be impossible to define
365
+ # using a single ISO datetime string
366
+ if date .tzinfo is not None :
367
+ raise SchemaValidationError (
368
+ "'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' "
369
+ f"field, rule={ rule_name } "
370
+ )
371
+
372
+ @staticmethod
373
+ def _validate_time_value (time : str , rule_name : str ):
374
+ # Using a regex instead of strptime because it's several orders of magnitude faster
375
+ match = TIME_RANGE_RE_PATTERN .match (time )
376
+
377
+ if not match :
378
+ raise SchemaValidationError (
379
+ f"'START' and 'END' must be a valid time format, time_format={ TIME_RANGE_FORMAT } , rule={ rule_name } "
380
+ )
381
+
382
+ @staticmethod
383
+ def _validate_schedule_between_days_of_week (value : Any , rule_name : str ):
384
+ error_str = f"condition with a CURRENT_DAY_OF_WEEK action must have a condition value dictionary with 'DAYS' and 'TIMEZONE' (optional) keys, rule={ rule_name } " # noqa: E501
385
+ if not isinstance (value , dict ):
386
+ raise SchemaValidationError (error_str )
387
+
388
+ days = value .get (TimeValues .DAYS .value )
389
+ if not isinstance (days , list ) or not value :
390
+ raise SchemaValidationError (error_str )
391
+ for day in days :
392
+ if not isinstance (day , str ) or day not in [
393
+ TimeValues .MONDAY .value ,
394
+ TimeValues .TUESDAY .value ,
395
+ TimeValues .WEDNESDAY .value ,
396
+ TimeValues .THURSDAY .value ,
397
+ TimeValues .FRIDAY .value ,
398
+ TimeValues .SATURDAY .value ,
399
+ TimeValues .SUNDAY .value ,
400
+ ]:
401
+ raise SchemaValidationError (
402
+ f"condition value DAYS must represent a day of the week in 'TimeValues' enum, rule={ rule_name } "
403
+ )
404
+
405
+ timezone = value .get (TimeValues .TIMEZONE .value , "UTC" )
406
+ if not isinstance (timezone , str ):
407
+ raise SchemaValidationError (error_str )
408
+
409
+ # try to see if the timezone string corresponds to any known timezone
410
+ if not tz .gettz (timezone ):
411
+ raise SchemaValidationError (f"'TIMEZONE' value must represent a valid IANA timezone, rule={ rule_name } " )
412
+
413
+ @staticmethod
414
+ def _validate_schedule_between_time_and_datetime_ranges (
415
+ value : Any , rule_name : str , action_name : str , validator : Callable [[str , str ], None ]
416
+ ):
417
+ error_str = f"condition with a '{ action_name } ' action must have a condition value type dictionary with 'START' and 'END' keys, rule={ rule_name } " # noqa: E501
418
+ if not isinstance (value , dict ):
419
+ raise SchemaValidationError (error_str )
420
+
421
+ start_time = value .get (TimeValues .START .value )
422
+ end_time = value .get (TimeValues .END .value )
423
+ if not start_time or not end_time :
424
+ raise SchemaValidationError (error_str )
425
+ if not isinstance (start_time , str ) or not isinstance (end_time , str ):
426
+ raise SchemaValidationError (f"'START' and 'END' must be a non empty string, rule={ rule_name } " )
427
+
428
+ validator (start_time , rule_name )
429
+ validator (end_time , rule_name )
430
+
431
+ timezone = value .get (TimeValues .TIMEZONE .value , "UTC" )
432
+ if not isinstance (timezone , str ):
433
+ raise SchemaValidationError (f"'TIMEZONE' must be a string, rule={ rule_name } " )
434
+
435
+ # try to see if the timezone string corresponds to any known timezone
436
+ if not tz .gettz (timezone ):
437
+ raise SchemaValidationError (f"'TIMEZONE' value must represent a valid IANA timezone, rule={ rule_name } " )
0 commit comments