4
4
from enum import Enum
5
5
from typing import Any , Callable , Dict , List , Optional , Union
6
6
7
+ from dateutil import tz
8
+
7
9
from ... import Logger
8
10
from .base import BaseValidator
9
11
from .exceptions import SchemaValidationError
@@ -42,14 +44,16 @@ class RuleAction(Enum):
42
44
43
45
44
46
class TimeKeys (Enum ):
45
- CURRENT_TIME_UTC = "CURRENT_TIME_UTC "
46
- CURRENT_DAY_OF_WEEK_UTC = "CURRENT_DAY_OF_WEEK_UTC "
47
- CURRENT_DATETIME_UTC = "CURRENT_DATETIME_UTC "
47
+ CURRENT_TIME = "CURRENT_TIME "
48
+ CURRENT_DAY_OF_WEEK = "CURRENT_DAY_OF_WEEK "
49
+ CURRENT_DATETIME = "CURRENT_DATETIME "
48
50
49
51
50
52
class TimeValues (Enum ):
51
53
START = "START"
52
54
END = "END"
55
+ TIMEZONE = "TIMEZONE"
56
+ DAYS = "DAYS"
53
57
SUNDAY = "SUNDAY"
54
58
MONDAY = "MONDAY"
55
59
TUESDAY = "TUESDAY"
@@ -291,17 +295,17 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str):
291
295
if not key or not isinstance (key , str ):
292
296
raise SchemaValidationError (f"'key' value must be a non empty string, rule={ rule_name } " )
293
297
action = condition .get (CONDITION_ACTION , "" )
294
- if action == RuleAction .SCHEDULE_BETWEEN_TIME_RANGE .value and key != TimeKeys .CURRENT_TIME_UTC .value :
298
+ if action == RuleAction .SCHEDULE_BETWEEN_TIME_RANGE .value and key != TimeKeys .CURRENT_TIME .value :
295
299
raise SchemaValidationError (
296
- f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME_UTC ' condition key, rule={ rule_name } " # noqa: E501
300
+ f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME ' condition key, rule={ rule_name } " # noqa: E501
297
301
)
298
- if action == RuleAction .SCHEDULE_BETWEEN_DATETIME_RANGE .value and key != TimeKeys .CURRENT_DATETIME_UTC .value :
302
+ if action == RuleAction .SCHEDULE_BETWEEN_DATETIME_RANGE .value and key != TimeKeys .CURRENT_DATETIME .value :
299
303
raise SchemaValidationError (
300
- f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME_UTC ' condition key, rule={ rule_name } " # noqa: E501
304
+ f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME ' condition key, rule={ rule_name } " # noqa: E501
301
305
)
302
- if action == RuleAction .SCHEDULE_BETWEEN_DAYS_OF_WEEK .value and key != TimeKeys .CURRENT_DAY_OF_WEEK_UTC .value :
306
+ if action == RuleAction .SCHEDULE_BETWEEN_DAYS_OF_WEEK .value and key != TimeKeys .CURRENT_DAY_OF_WEEK .value :
303
307
raise SchemaValidationError (
304
- f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK_UTC ' condition key, rule={ rule_name } " # noqa: E501
308
+ f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK ' condition key, rule={ rule_name } " # noqa: E501
305
309
)
306
310
307
311
@staticmethod
@@ -324,13 +328,25 @@ def validate_condition_value(condition: Dict[str, Any], rule_name: str):
324
328
325
329
@staticmethod
326
330
def _validate_datetime_value (datetime_str : str , rule_name : str ):
331
+ date = None
332
+
327
333
try :
328
334
# python < 3.11 don't support the Z timezone on datetime.fromisoformat,
329
335
# so we replace any Z with the equivalent "+00:00"
330
- datetime .fromisoformat (datetime_str .replace ("Z" , "+00:00" ))
336
+ date = datetime .fromisoformat (datetime_str .replace ("Z" , "+00:00" ))
331
337
except Exception :
332
338
raise SchemaValidationError (f"'START' and 'END' must be a valid ISO8601 time format, rule={ rule_name } " )
333
339
340
+ # we only allow timezone information to be set via the TIMEZONE field
341
+ # this way we can encode DST into the calculation. For instance, Copenhagen is
342
+ # UTC+2 during winter, and UTC+1 during summer, which would be impossible to define
343
+ # using a single ISO datetime string
344
+ if date .tzinfo is not None :
345
+ raise SchemaValidationError (
346
+ "'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' "
347
+ f"field, rule={ rule_name } "
348
+ )
349
+
334
350
@staticmethod
335
351
def _validate_time_value (time : str , rule_name : str ):
336
352
# Using a regex instead of strptime because it's several orders of magnitude faster
@@ -343,11 +359,14 @@ def _validate_time_value(time: str, rule_name: str):
343
359
344
360
@staticmethod
345
361
def _validate_schedule_between_days_of_week (value : Any , rule_name : str ):
346
- if not isinstance (value , list ) or not value :
347
- raise SchemaValidationError (
348
- f"condition with a CURRENT_DAY_OF_WEEK_UTC action must have a non empty condition value type list, rule={ rule_name } " # noqa: E501
349
- )
350
- for day in value :
362
+ 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
363
+ if not isinstance (value , dict ):
364
+ raise SchemaValidationError (error_str )
365
+
366
+ days = value .get (TimeValues .DAYS .value )
367
+ if not isinstance (days , list ) or not value :
368
+ raise SchemaValidationError (error_str )
369
+ for day in days :
351
370
if not isinstance (day , str ) or day not in [
352
371
TimeValues .MONDAY .value ,
353
372
TimeValues .TUESDAY .value ,
@@ -358,9 +377,15 @@ def _validate_schedule_between_days_of_week(value: Any, rule_name: str):
358
377
TimeValues .SUNDAY .value ,
359
378
]:
360
379
raise SchemaValidationError (
361
- f"condition value must represent a day of the week in 'TimeValues' enum, rule={ rule_name } "
380
+ f"condition value DAYS must represent a day of the week in 'TimeValues' enum, rule={ rule_name } "
362
381
)
363
382
383
+ timezone = value .get (TimeValues .TIMEZONE .value , "UTC" )
384
+ if not isinstance (timezone , str ):
385
+ raise SchemaValidationError (error_str )
386
+ if not tz .gettz (timezone ):
387
+ raise SchemaValidationError (f"'TIMEZONE' value must represent a valid IANA timezone, rule={ rule_name } " )
388
+
364
389
@staticmethod
365
390
def _validate_schedule_between_time_and_datetime_ranges (
366
391
value : Any , rule_name : str , action_name : str , validator : Callable [[str , str ], None ]
@@ -376,3 +401,9 @@ def _validate_schedule_between_time_and_datetime_ranges(
376
401
raise SchemaValidationError (f"'START' and 'END' must be a non empty string, rule={ rule_name } " )
377
402
validator (start_time , rule_name )
378
403
validator (end_time , rule_name )
404
+
405
+ timezone = value .get (TimeValues .TIMEZONE .value , "UTC" )
406
+ if not isinstance (timezone , str ):
407
+ raise SchemaValidationError (f"'TIMEZONE' must be a string, rule={ rule_name } " )
408
+ if not tz .gettz (timezone ):
409
+ raise SchemaValidationError (f"'TIMEZONE' value must represent a valid IANA timezone, rule={ rule_name } " )
0 commit comments