9
9
10
10
UTC = timezone .utc
11
11
12
+ # systemd seems to stop iteration when it reaches year 2200. We do the same.
12
13
MAX_YEAR = 2200
13
14
RANGES = [
14
15
range (0 , 7 ),
42
43
"quarterly" : "*-01,04,07,10-01 00:00:00" ,
43
44
"semiannually" : "*-01,07-01 00:00:00" ,
44
45
}
46
+ # timedelta initialization is not cheap so we prepare a few constants
47
+ # that we will need often:
45
48
SECOND = td (seconds = 1 )
46
49
MINUTE = td (minutes = 1 )
47
50
HOUR = td (hours = 1 )
@@ -66,13 +69,26 @@ def msg(self) -> str:
66
69
def _int (self , value : str ) -> int :
67
70
if value == "" :
68
71
raise OnCalendarError (self .msg ())
72
+ # Make sure the value contains digits and nothing else
73
+ # (for example, we reject integer literals with underscores)
69
74
for ch in value :
70
75
if ch not in "0123456789" :
71
76
raise OnCalendarError (self .msg ())
72
77
73
78
return int (value )
74
79
75
80
def int (self , s : str ) -> int :
81
+ """Convert the supplied sting to an integer.
82
+
83
+ This function handles a few special cases:
84
+ * It converts weekdays "Mon", "Tue", ..., "Sun" to 0, 1, ..., 6
85
+ * It converts years 70 .. 99 to 1970 - 1999
86
+ * It converts years 0 .. 69 to 2000 - 2069
87
+
88
+ It also checks if the resulting integer is within the range
89
+ of valid values for the given field, and raises `OnCalendarError`
90
+ if it is not.
91
+ """
76
92
if self == Field .DOW :
77
93
s = s .upper ()
78
94
if s in SYMBOLIC_DAYS :
@@ -97,6 +113,11 @@ def int(self, s: str) -> int:
97
113
return v
98
114
99
115
def parse (self , s : str , reverse : bool = False ) -> set [__builtins__ .int ]:
116
+ """Parse a single component of an expression into a set of integers.
117
+
118
+ To handle lists, intervals, and intervals with a step, this function
119
+ recursively calls itself.
120
+ """
100
121
if self == Field .DAY and s .startswith ("~" ):
101
122
# Chop leading "~" and set the reverse flag
102
123
return self .parse (s [1 :], reverse = True )
@@ -168,11 +189,42 @@ def parse(self, s: str, reverse: bool = False) -> set[__builtins__.int]:
168
189
169
190
170
191
def is_imaginary (dt : datetime ) -> bool :
192
+ """Return True if dt gets skipped over during DST transition."""
171
193
return dt != dt .astimezone (UTC ).astimezone (dt .tzinfo )
172
194
173
195
174
196
class BaseIterator (object ):
197
+ """OnCalendar expression parser and iterator.
198
+
199
+ This iterator supports most syntax features supported by systemd. It however
200
+ does *not* support:
201
+
202
+ * Timezone specified within the expression (use `TzIterator` instead).
203
+ * Seconds fields with decimal values.
204
+
205
+ This iterator works with both naive and timezone-aware datetimes. In case
206
+ of timezone-aware datetimes, it mimics systemd behaviour during DST transitions:
207
+
208
+ * It skips over datetimes that fall in the skipped hour during the spring DST
209
+ transition.
210
+ * It repeats the datetimes that fall in the repeated hour during the fall DST
211
+ transition. It returns a datetime with the pre-transition timezone,
212
+ then the same datetime but with the post-transition timezone.
213
+ """
214
+
175
215
def __init__ (self , expression : str , start : datetime ):
216
+ """Initialize the iterator with an OnCalendar expression and the start time.
217
+
218
+ `expression` should contain a single OnCalendar expression without a timezone,
219
+ for example: `Mon 01-01 12:00:00`.
220
+
221
+ `start` is the datetime to start iteration from. The first result
222
+ returned by the iterator will be greater than `start`. The supplied
223
+ datetime can be either naive or timezone-aware. If `start` is naive,
224
+ the iterator will also return naive datetimes. If `start` is timezone-aware,
225
+ the iterator will return timezone-aware datetimes using the same timezone
226
+ as `start`.
227
+ """
176
228
self .dt = start .replace (microsecond = 0 )
177
229
178
230
if expression .lower () in SPECIALS :
@@ -359,6 +411,13 @@ def advance_month(self) -> bool:
359
411
return True
360
412
361
413
def advance_year (self ) -> None :
414
+ """Roll forward the year component until it satisfies the constraints.
415
+
416
+ Return False if the year meets contraints without modification.
417
+ Return True if self.dt was rolled forward.
418
+
419
+ """
420
+
362
421
if self .dt .year in self .years :
363
422
return
364
423
@@ -407,6 +466,7 @@ def __next__(self) -> datetime:
407
466
408
467
409
468
def parse_tz (value : str ) -> ZoneInfo | None :
469
+ """Return ZoneInfo object or None if value fails to parse."""
410
470
# Optimization: there are no timezones that start with a digit or star
411
471
if value [0 ] in "0123456789*" :
412
472
return None
@@ -418,7 +478,22 @@ def parse_tz(value: str) -> ZoneInfo | None:
418
478
419
479
420
480
class TzIterator (object ):
481
+ """OnCalendar expression parser and iterator (with timezone support).
482
+
483
+ This iterator wraps `BaseIterator` and adds support for timezones within
484
+ the expression. This iterator requires the starting datetime to be
485
+ timezone-aware.
486
+ """
487
+
421
488
def __init__ (self , expression : str , start : datetime ):
489
+ """Initialize the iterator with an OnCalendar expression and the start time.
490
+
491
+ `expression` should contain a single OnCalendar expression with or without a
492
+ timezone, for example: `Mon 01-01 12:00:00 Europe/Riga`.
493
+
494
+ `start` is the timezone-aware datetime to start iteration from. The iterator
495
+ will return datetimes using the same timezone as `start`.
496
+ """
422
497
if not start .tzinfo :
423
498
raise OnCalendarError ("Argument 'dt' must be timezone-aware" )
424
499
@@ -436,7 +511,22 @@ def __next__(self) -> datetime:
436
511
437
512
438
513
class OnCalendar (object ):
514
+ """OnCalendar expression parser and iterator (with multiple expression support).
515
+
516
+ This iterator wraps `TzIterator` and adds support for iterating over multiple
517
+ expressions (separated by newlines) at once.
518
+ """
519
+
439
520
def __init__ (self , expressions : str , start : datetime ):
521
+ """Initialize the iterator with OnCalendar expression(s) and the start time.
522
+
523
+ `expressions` should contain one or more OnCalendar expressions with or without
524
+ a timezone, separated with newlines. Example:
525
+ `00:00 Europe/Riga\n 00:00 UTC`.
526
+
527
+ `start` is the timezone-aware datetime to start iteration from. The iterator
528
+ will return datetimes using the same timezone as `start`.
529
+ """
440
530
if not start .tzinfo :
441
531
raise OnCalendarError ("Argument 'dt' must be timezone-aware" )
442
532
0 commit comments