11import logging
2+ import re
3+ from datetime import datetime
24from 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
48
59from ... import Logger
610from .base import BaseValidator
1418CONDITION_VALUE = "value"
1519CONDITION_ACTION = "action"
1620FEATURE_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 = ":"
1724
1825
19- class RuleAction (str , Enum ):
26+ class RuleAction (Enum ):
2027 EQUALS = "EQUALS"
2128 NOT_EQUALS = "NOT_EQUALS"
2229 KEY_GREATER_THAN_VALUE = "KEY_GREATER_THAN_VALUE"
@@ -31,6 +38,37 @@ class RuleAction(str, Enum):
3138 KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE"
3239 VALUE_IN_KEY = "VALUE_IN_KEY"
3340 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"
3472
3573
3674class SchemaValidator (BaseValidator ):
@@ -143,7 +181,7 @@ def validate(self) -> None:
143181 if not isinstance (self .schema , dict ):
144182 raise SchemaValidationError (f"Features must be a dictionary, schema={ str (self .schema )} " )
145183
146- features = FeaturesValidator (schema = self .schema )
184+ features = FeaturesValidator (schema = self .schema , logger = self . logger )
147185 features .validate ()
148186
149187
@@ -158,7 +196,7 @@ def validate(self):
158196 for name , feature in self .schema .items ():
159197 self .logger .debug (f"Attempting to validate feature '{ name } '" )
160198 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 )
162200 rules .validate ()
163201
164202 # returns True in case the feature is a regular feature flag with a boolean default value
@@ -196,14 +234,15 @@ def validate(self):
196234 return
197235
198236 if not isinstance (self .rules , dict ):
237+ self .logger .debug (f"Feature rules must be a dictionary, feature={ self .feature_name } " )
199238 raise SchemaValidationError (f"Feature rules must be a dictionary, feature={ self .feature_name } " )
200239
201240 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 } " )
203242 self .validate_rule (
204243 rule = rule , rule_name = rule_name , feature_name = self .feature_name , boolean_feature = self .boolean_feature
205244 )
206- conditions = ConditionsValidator (rule = rule , rule_name = rule_name )
245+ conditions = ConditionsValidator (rule = rule , rule_name = rule_name , logger = self . logger )
207246 conditions .validate ()
208247
209248 @staticmethod
@@ -233,12 +272,14 @@ def __init__(self, rule: Dict[str, Any], rule_name: str, logger: Optional[Union[
233272 self .logger = logger or logging .getLogger (__name__ )
234273
235274 def validate (self ):
275+
236276 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 } " )
237278 raise SchemaValidationError (f"Invalid condition, rule={ self .rule_name } " )
238279
239280 for condition in self .conditions :
240281 # 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 } " )
242283 self .validate_condition (rule_name = self .rule_name , condition = condition )
243284
244285 @staticmethod
@@ -265,8 +306,132 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str):
265306 if not key or not isinstance (key , str ):
266307 raise SchemaValidationError (f"'key' value must be a non empty string, rule={ rule_name } " )
267308
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+
268327 @staticmethod
269328 def validate_condition_value (condition : Dict [str , Any ], rule_name : str ):
270329 value = condition .get (CONDITION_VALUE , "" )
271330 if not value :
272331 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