oncalendar 1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oncalendar/__init__.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from calendar import monthrange
|
|
4
|
+
from datetime import date, datetime, time
|
|
5
|
+
from datetime import timedelta as td
|
|
6
|
+
from datetime import timezone
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
9
|
+
|
|
10
|
+
UTC = timezone.utc
|
|
11
|
+
|
|
12
|
+
# systemd seems to stop iteration when it reaches year 2200. We do the same.
|
|
13
|
+
MAX_YEAR = 2200
|
|
14
|
+
RANGES = [
|
|
15
|
+
range(0, 7),
|
|
16
|
+
range(1970, MAX_YEAR),
|
|
17
|
+
range(1, 13),
|
|
18
|
+
range(1, 32),
|
|
19
|
+
range(0, 24),
|
|
20
|
+
range(0, 60),
|
|
21
|
+
range(0, 60),
|
|
22
|
+
]
|
|
23
|
+
SYMBOLIC_DAYS = "MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY".split()
|
|
24
|
+
SYMBOLIC_DAYS_SHORT = [s[:3] for s in SYMBOLIC_DAYS]
|
|
25
|
+
DAYS_IN_MONTH = [-1, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
26
|
+
FIELD_NAMES = [
|
|
27
|
+
"day-of-week",
|
|
28
|
+
"year",
|
|
29
|
+
"month",
|
|
30
|
+
"day-of-month",
|
|
31
|
+
"hour",
|
|
32
|
+
"minute",
|
|
33
|
+
"second",
|
|
34
|
+
]
|
|
35
|
+
SPECIALS = {
|
|
36
|
+
"minutely": "*-*-* *:*:00",
|
|
37
|
+
"hourly": "*-*-* *:00:00",
|
|
38
|
+
"daily": "*-*-* 00:00:00",
|
|
39
|
+
"monthly": "*-*-01 00:00:00",
|
|
40
|
+
"weekly": "Mon *-*-* 00:00:00",
|
|
41
|
+
"yearly": "*-01-01 00:00:00",
|
|
42
|
+
"annually": "*-01-01 00:00:00",
|
|
43
|
+
"quarterly": "*-01,04,07,10-01 00:00:00",
|
|
44
|
+
"semiannually": "*-01,07-01 00:00:00",
|
|
45
|
+
}
|
|
46
|
+
# timedelta initialization is not cheap so we prepare a few constants
|
|
47
|
+
# that we will need often:
|
|
48
|
+
SECOND = td(seconds=1)
|
|
49
|
+
MINUTE = td(minutes=1)
|
|
50
|
+
HOUR = td(hours=1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OnCalendarError(Exception):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Field(IntEnum):
|
|
58
|
+
DOW = 0
|
|
59
|
+
YEAR = 1
|
|
60
|
+
MONTH = 2
|
|
61
|
+
DAY = 3
|
|
62
|
+
HOUR = 4
|
|
63
|
+
MINUTE = 5
|
|
64
|
+
SECOND = 6
|
|
65
|
+
|
|
66
|
+
def msg(self) -> str:
|
|
67
|
+
return "Bad %s" % FIELD_NAMES[self]
|
|
68
|
+
|
|
69
|
+
def _int(self, value: str) -> int:
|
|
70
|
+
if value == "":
|
|
71
|
+
raise OnCalendarError(self.msg())
|
|
72
|
+
# Make sure the value contains digits and nothing else
|
|
73
|
+
# (for example, we reject integer literals with underscores)
|
|
74
|
+
for ch in value:
|
|
75
|
+
if ch not in "0123456789":
|
|
76
|
+
raise OnCalendarError(self.msg())
|
|
77
|
+
|
|
78
|
+
return int(value)
|
|
79
|
+
|
|
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
|
+
"""
|
|
92
|
+
if self == Field.DOW:
|
|
93
|
+
s = s.upper()
|
|
94
|
+
if s in SYMBOLIC_DAYS:
|
|
95
|
+
# Monday -> 0, ..., Sunday -> 6
|
|
96
|
+
return SYMBOLIC_DAYS.index(s)
|
|
97
|
+
if s in SYMBOLIC_DAYS_SHORT:
|
|
98
|
+
# Mon -> 0, ..., Sun -> 6
|
|
99
|
+
return SYMBOLIC_DAYS_SHORT.index(s)
|
|
100
|
+
raise OnCalendarError(self.msg())
|
|
101
|
+
|
|
102
|
+
v = self._int(s)
|
|
103
|
+
if self == Field.YEAR and v < 70:
|
|
104
|
+
# Interpret 0-69 as 2000-2069
|
|
105
|
+
v += 2000
|
|
106
|
+
if self == Field.YEAR and v < 100:
|
|
107
|
+
# Interpret 70-99 as 1970-1999
|
|
108
|
+
v += 1900
|
|
109
|
+
|
|
110
|
+
if v not in RANGES[self]:
|
|
111
|
+
raise OnCalendarError(self.msg())
|
|
112
|
+
|
|
113
|
+
return v
|
|
114
|
+
|
|
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
|
+
"""
|
|
121
|
+
if self == Field.DAY and s.startswith("~"):
|
|
122
|
+
# Chop leading "~" and set the reverse flag
|
|
123
|
+
return self.parse(s[1:], reverse=True)
|
|
124
|
+
|
|
125
|
+
if self != Field.DOW and s == "*":
|
|
126
|
+
return set(RANGES[self])
|
|
127
|
+
|
|
128
|
+
if "*" in s:
|
|
129
|
+
# systemd's OnCalendar syntax does not allow '*' to appear in
|
|
130
|
+
# comma-delimited lists, in intervals, or in intervals with step.
|
|
131
|
+
raise OnCalendarError(self.msg())
|
|
132
|
+
|
|
133
|
+
if self == Field.DOW and s.endswith(","):
|
|
134
|
+
# systemd allows both "Mon 1-1" and "Mon, 1-1". forms, and normalizes
|
|
135
|
+
# to the form without trailing comma. We do the same.
|
|
136
|
+
return self.parse(s[:-1])
|
|
137
|
+
|
|
138
|
+
if self == Field.DOW and "-" in s:
|
|
139
|
+
# For the weekday component, systemd allows both the "Mon..Fri" and
|
|
140
|
+
# the "Mon-Fri" forms. It normalizes "-" to ".." and we do the same.
|
|
141
|
+
return self.parse(s.replace("-", ".."))
|
|
142
|
+
|
|
143
|
+
if "," in s:
|
|
144
|
+
result = set()
|
|
145
|
+
for term in s.split(","):
|
|
146
|
+
result.update(self.parse(term, reverse=reverse))
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
if "/" in s and self != Field.DOW:
|
|
150
|
+
term, step_str = s.split("/", maxsplit=1)
|
|
151
|
+
step = self._int(step_str)
|
|
152
|
+
if step == 0:
|
|
153
|
+
raise OnCalendarError(self.msg())
|
|
154
|
+
|
|
155
|
+
items = self.parse(term, reverse=reverse)
|
|
156
|
+
|
|
157
|
+
if len(items) == 1:
|
|
158
|
+
start = items.pop()
|
|
159
|
+
assert isinstance(start, int)
|
|
160
|
+
end = 0 if reverse else max(RANGES[self])
|
|
161
|
+
tail = range(start, end + 1)
|
|
162
|
+
return set(tail[::step])
|
|
163
|
+
|
|
164
|
+
# items is an unordered set, so sort it before taking
|
|
165
|
+
# every step-th item. Then convert it back to set.
|
|
166
|
+
return set(sorted(items)[::step])
|
|
167
|
+
|
|
168
|
+
if ".." in s:
|
|
169
|
+
start_str, end_str = s.split("..", maxsplit=1)
|
|
170
|
+
start = self.int(start_str)
|
|
171
|
+
end = self.int(end_str)
|
|
172
|
+
|
|
173
|
+
if end < start:
|
|
174
|
+
raise OnCalendarError(self.msg())
|
|
175
|
+
|
|
176
|
+
if reverse:
|
|
177
|
+
start, end = -end, -start
|
|
178
|
+
|
|
179
|
+
return set(range(start, end + 1))
|
|
180
|
+
|
|
181
|
+
v = self.int(s)
|
|
182
|
+
if reverse:
|
|
183
|
+
# When using <month>~<day> syntax, systemd rejects day values above 28
|
|
184
|
+
if v > 28:
|
|
185
|
+
raise OnCalendarError(self.msg())
|
|
186
|
+
v = -v
|
|
187
|
+
|
|
188
|
+
return {v}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_imaginary(dt: datetime) -> bool:
|
|
192
|
+
"""Return True if dt gets skipped over during DST transition."""
|
|
193
|
+
return dt != dt.astimezone(UTC).astimezone(dt.tzinfo)
|
|
194
|
+
|
|
195
|
+
|
|
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
|
+
|
|
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
|
+
"""
|
|
228
|
+
self.dt = start.replace(microsecond=0)
|
|
229
|
+
|
|
230
|
+
if expression.lower() in SPECIALS:
|
|
231
|
+
expression = SPECIALS[expression.lower()]
|
|
232
|
+
|
|
233
|
+
parts = expression.replace("~", "-~").split()
|
|
234
|
+
if not parts:
|
|
235
|
+
raise OnCalendarError("Wrong number of fields")
|
|
236
|
+
|
|
237
|
+
if ":" in parts[-1]:
|
|
238
|
+
time_parts = parts.pop().split(":")
|
|
239
|
+
if len(time_parts) not in (2, 3):
|
|
240
|
+
raise OnCalendarError("Bad time")
|
|
241
|
+
if len(time_parts) == 2:
|
|
242
|
+
# If seconds is missing, use default
|
|
243
|
+
time_parts.append("0")
|
|
244
|
+
self.hours = Field.HOUR.parse(time_parts[0])
|
|
245
|
+
self.minutes = Field.MINUTE.parse(time_parts[1])
|
|
246
|
+
self.seconds = Field.SECOND.parse(time_parts[2])
|
|
247
|
+
else:
|
|
248
|
+
# Default: 00:00:00
|
|
249
|
+
self.hours, self.minutes, self.seconds = {0}, {0}, {0}
|
|
250
|
+
|
|
251
|
+
if parts and "-" in parts[-1] and parts[-1][0] in "0123456789*":
|
|
252
|
+
date_parts = parts.pop().split("-")
|
|
253
|
+
if len(date_parts) not in (2, 3):
|
|
254
|
+
raise OnCalendarError("Bad date")
|
|
255
|
+
if len(date_parts) == 2:
|
|
256
|
+
# If year is missing, use default
|
|
257
|
+
date_parts.insert(0, "*")
|
|
258
|
+
self.years = Field.YEAR.parse(date_parts[0])
|
|
259
|
+
self.months = Field.MONTH.parse(date_parts[1])
|
|
260
|
+
self.days = Field.DAY.parse(date_parts[2])
|
|
261
|
+
else:
|
|
262
|
+
# Default: *-*-*
|
|
263
|
+
self.years = set(RANGES[Field.YEAR])
|
|
264
|
+
self.months = set(RANGES[Field.MONTH])
|
|
265
|
+
self.days = set(RANGES[Field.DAY])
|
|
266
|
+
|
|
267
|
+
if parts:
|
|
268
|
+
self.weekdays = Field.DOW.parse(parts.pop(0))
|
|
269
|
+
else:
|
|
270
|
+
# Default: Mon..Sun
|
|
271
|
+
self.weekdays = set(RANGES[Field.DOW])
|
|
272
|
+
|
|
273
|
+
# There should be no parts left over
|
|
274
|
+
if parts:
|
|
275
|
+
raise OnCalendarError("Wrong number of fields")
|
|
276
|
+
|
|
277
|
+
self.fixup_tz = None
|
|
278
|
+
if self.dt.tzinfo in (None, UTC):
|
|
279
|
+
# No special DST handling for UTC
|
|
280
|
+
pass
|
|
281
|
+
else:
|
|
282
|
+
self.fixup_tz = self.dt.tzinfo
|
|
283
|
+
self.dt = self.dt.replace(tzinfo=None)
|
|
284
|
+
|
|
285
|
+
self.any_reverse_day = any(d < 0 for d in self.days)
|
|
286
|
+
|
|
287
|
+
self.never = False
|
|
288
|
+
if min(self.days) > 29:
|
|
289
|
+
# Optimization for the special case where no months have enough
|
|
290
|
+
# days for the expression to ever match. Set the self.never flag
|
|
291
|
+
# which tells the iterator to exit early.
|
|
292
|
+
if min(self.days) > max(DAYS_IN_MONTH[month] for month in self.months):
|
|
293
|
+
self.never = True
|
|
294
|
+
|
|
295
|
+
def advance_second(self) -> bool:
|
|
296
|
+
"""Roll forward the second component until it satisfies the constraints.
|
|
297
|
+
|
|
298
|
+
Return False if the second meets contraints without modification.
|
|
299
|
+
Return True if self.dt was rolled forward.
|
|
300
|
+
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
if self.dt.second in self.seconds:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
if len(self.seconds) == 1:
|
|
307
|
+
# An optimization for the special case where self.seconds has exactly
|
|
308
|
+
# one element. Instead of advancing one second per iteration,
|
|
309
|
+
# make a jump from the current second to the target second.
|
|
310
|
+
delta = (next(iter(self.seconds)) - self.dt.second) % 60
|
|
311
|
+
self.dt += td(seconds=delta)
|
|
312
|
+
|
|
313
|
+
while self.dt.second not in self.seconds:
|
|
314
|
+
self.dt += SECOND
|
|
315
|
+
if self.dt.second == 0:
|
|
316
|
+
# Break out to re-check year, month, day, hour, and minute
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def advance_minute(self) -> bool:
|
|
322
|
+
"""Roll forward the minute component until it satisfies the constraints.
|
|
323
|
+
|
|
324
|
+
Return False if the minute meets contraints without modification.
|
|
325
|
+
Return True if self.dt was rolled forward.
|
|
326
|
+
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
if self.dt.minute in self.minutes:
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
self.dt = self.dt.replace(second=0)
|
|
333
|
+
while self.dt.minute not in self.minutes:
|
|
334
|
+
self.dt += MINUTE
|
|
335
|
+
if self.dt.minute == 0:
|
|
336
|
+
# Break out to re-check year, month, day and hour
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
def advance_hour(self) -> bool:
|
|
342
|
+
"""Roll forward the hour component until it satisfies the constraints.
|
|
343
|
+
|
|
344
|
+
Return False if the hour meets contraints without modification.
|
|
345
|
+
Return True if self.dt was rolled forward.
|
|
346
|
+
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
if self.dt.hour in self.hours:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
self.dt = self.dt.replace(minute=0, second=0)
|
|
353
|
+
while self.dt.hour not in self.hours:
|
|
354
|
+
self.dt += HOUR
|
|
355
|
+
if self.dt.hour == 0:
|
|
356
|
+
# break out to re-check year, month and day
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
def match_dom(self, d: date) -> bool:
|
|
362
|
+
"""Return True is day-of-month matches."""
|
|
363
|
+
if d.day in self.days:
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
if self.any_reverse_day:
|
|
367
|
+
_, last = monthrange(d.year, d.month)
|
|
368
|
+
if d.day - last - 1 in self.days:
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
def match_dow(self, d: date) -> bool:
|
|
374
|
+
"""Return True is day-of-week matches."""
|
|
375
|
+
|
|
376
|
+
return d.weekday() in self.weekdays
|
|
377
|
+
|
|
378
|
+
def advance_day(self) -> bool:
|
|
379
|
+
"""Roll forward the day component until it satisfies the constraints.
|
|
380
|
+
|
|
381
|
+
This method advances the date until it matches the
|
|
382
|
+
day-of-week and the day-of-month constraints.
|
|
383
|
+
|
|
384
|
+
Return False if the day meets contraints without modification.
|
|
385
|
+
Return True if self.dt was rolled forward.
|
|
386
|
+
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
needle = self.dt.date()
|
|
390
|
+
if self.match_dow(needle) and self.match_dom(needle):
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
while not self.match_dow(needle) or not self.match_dom(needle):
|
|
394
|
+
needle += td(days=1)
|
|
395
|
+
if needle.day == 1:
|
|
396
|
+
# We're in a different month now, break out to re-check year and month
|
|
397
|
+
# This significantly speeds up the "0 0 * 2 MON#5" case
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
self.dt = datetime.combine(needle, time(), tzinfo=self.dt.tzinfo)
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
def advance_month(self) -> bool:
|
|
404
|
+
"""Roll forward the month component until it satisfies the constraints.
|
|
405
|
+
|
|
406
|
+
Return False if the month meets contraints without modification.
|
|
407
|
+
Return True if self.dt was rolled forward.
|
|
408
|
+
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
if self.dt.month in self.months:
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
needle = self.dt.date()
|
|
415
|
+
while needle.month not in self.months:
|
|
416
|
+
needle = (needle.replace(day=1) + td(days=32)).replace(day=1)
|
|
417
|
+
|
|
418
|
+
self.dt = datetime.combine(needle, time(), tzinfo=self.dt.tzinfo)
|
|
419
|
+
return True
|
|
420
|
+
|
|
421
|
+
def advance_year(self) -> None:
|
|
422
|
+
"""Roll forward the year component until it satisfies the constraints.
|
|
423
|
+
|
|
424
|
+
Return False if the year meets contraints without modification.
|
|
425
|
+
Return True if self.dt was rolled forward.
|
|
426
|
+
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
if self.dt.year in self.years:
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
needle = self.dt.date()
|
|
433
|
+
while needle.year not in self.years and needle.year < MAX_YEAR:
|
|
434
|
+
needle = needle.replace(year=needle.year + 1, month=1, day=1)
|
|
435
|
+
|
|
436
|
+
self.dt = datetime.combine(needle, time(), tzinfo=self.dt.tzinfo)
|
|
437
|
+
|
|
438
|
+
def __iter__(self) -> "BaseIterator":
|
|
439
|
+
return self
|
|
440
|
+
|
|
441
|
+
def __next__(self) -> datetime:
|
|
442
|
+
if self.never:
|
|
443
|
+
raise StopIteration
|
|
444
|
+
|
|
445
|
+
self.dt += SECOND
|
|
446
|
+
while True:
|
|
447
|
+
self.advance_year()
|
|
448
|
+
|
|
449
|
+
# systemd seems to generate dates up to 2200, so we do the same
|
|
450
|
+
if self.dt.year >= MAX_YEAR:
|
|
451
|
+
raise StopIteration
|
|
452
|
+
|
|
453
|
+
if self.advance_month():
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
if self.advance_day():
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
if self.advance_hour():
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
if self.advance_minute():
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
if self.advance_second():
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
if self.fixup_tz:
|
|
469
|
+
result = self.dt.replace(tzinfo=self.fixup_tz, fold=0)
|
|
470
|
+
if is_imaginary(result):
|
|
471
|
+
# If we hit an imaginary datetime then look for the next
|
|
472
|
+
# occurence
|
|
473
|
+
self.dt += SECOND
|
|
474
|
+
continue
|
|
475
|
+
return result
|
|
476
|
+
|
|
477
|
+
return self.dt
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def parse_tz(value: str) -> ZoneInfo | None:
|
|
481
|
+
"""Return ZoneInfo object or None if value fails to parse."""
|
|
482
|
+
# Optimization: there are no timezones that start with a digit or star
|
|
483
|
+
if value[0] in "0123456789*":
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
return ZoneInfo(value)
|
|
488
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class TzIterator(object):
|
|
493
|
+
"""OnCalendar expression parser and iterator (with timezone support).
|
|
494
|
+
|
|
495
|
+
This iterator wraps `BaseIterator` and adds support for timezones within
|
|
496
|
+
the expression. This iterator requires the starting datetime to be
|
|
497
|
+
timezone-aware.
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def __init__(self, expression: str, start: datetime):
|
|
501
|
+
"""Initialize the iterator with an OnCalendar expression and the start time.
|
|
502
|
+
|
|
503
|
+
`expression` should contain a single OnCalendar expression with or without a
|
|
504
|
+
timezone, for example: `Mon 01-01 12:00:00 Europe/Riga`.
|
|
505
|
+
|
|
506
|
+
`start` is the timezone-aware datetime to start iteration from. The iterator
|
|
507
|
+
will return datetimes using the same timezone as `start`.
|
|
508
|
+
"""
|
|
509
|
+
if not start.tzinfo:
|
|
510
|
+
raise OnCalendarError("Argument 'dt' must be timezone-aware")
|
|
511
|
+
|
|
512
|
+
self.local_tz = start.tzinfo
|
|
513
|
+
expression = expression.strip()
|
|
514
|
+
if " " in expression:
|
|
515
|
+
head, maybe_tz = expression.rsplit(maxsplit=1)
|
|
516
|
+
if tz := parse_tz(maybe_tz):
|
|
517
|
+
expression, start = head, start.astimezone(tz)
|
|
518
|
+
|
|
519
|
+
self.iterator = BaseIterator(expression, start)
|
|
520
|
+
|
|
521
|
+
def __iter__(self) -> "TzIterator":
|
|
522
|
+
return self
|
|
523
|
+
|
|
524
|
+
def __next__(self) -> datetime:
|
|
525
|
+
return next(self.iterator).astimezone(self.local_tz)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class OnCalendar(object):
|
|
529
|
+
"""OnCalendar expression parser and iterator (with multiple expression support).
|
|
530
|
+
|
|
531
|
+
This iterator wraps `TzIterator` and adds support for iterating over multiple
|
|
532
|
+
expressions (separated by newlines) at once.
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
def __init__(self, expressions: str, start: datetime):
|
|
536
|
+
"""Initialize the iterator with OnCalendar expression(s) and the start time.
|
|
537
|
+
|
|
538
|
+
`expressions` should contain one or more OnCalendar expressions with or without
|
|
539
|
+
a timezone, separated with newlines. Example:
|
|
540
|
+
`00:00 Europe/Riga\n00:00 UTC`.
|
|
541
|
+
|
|
542
|
+
`start` is the timezone-aware datetime to start iteration from. The iterator
|
|
543
|
+
will return datetimes using the same timezone as `start`.
|
|
544
|
+
"""
|
|
545
|
+
if not start.tzinfo:
|
|
546
|
+
raise OnCalendarError("Argument 'dt' must be timezone-aware")
|
|
547
|
+
|
|
548
|
+
self.dt = start
|
|
549
|
+
self.iterators = {}
|
|
550
|
+
for expr in expressions.strip().split("\n"):
|
|
551
|
+
self.iterators[TzIterator(expr, start.replace())] = start
|
|
552
|
+
|
|
553
|
+
def __iter__(self) -> "OnCalendar":
|
|
554
|
+
return self
|
|
555
|
+
|
|
556
|
+
def __next__(self) -> datetime:
|
|
557
|
+
for it in list(self.iterators.keys()):
|
|
558
|
+
if self.iterators[it] > self.dt:
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
self.iterators[it] = next(it)
|
|
563
|
+
except StopIteration:
|
|
564
|
+
del self.iterators[it]
|
|
565
|
+
|
|
566
|
+
if not self.iterators:
|
|
567
|
+
raise StopIteration
|
|
568
|
+
|
|
569
|
+
self.dt = min(self.iterators.values())
|
|
570
|
+
return self.dt
|
oncalendar/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oncalendar
|
|
3
|
+
Version: 1.2
|
|
4
|
+
Summary: Systemd OnCalendar expression parser and evaluator
|
|
5
|
+
Author-email: Pēteris Caune <cuu508@monkeyseemonkeydo.lv>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Repository, https://github.com/cuu508/oncalendar.git
|
|
8
|
+
Project-URL: Issues, https://github.com/cuu508/oncalendar/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/cuu508/oncalendar/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: systemd,oncalendar,calendar,schedule
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# oncalendar
|
|
24
|
+
|
|
25
|
+
[](https://github.com/cuu508/oncalendar/actions/workflows/pytest.yml)
|
|
26
|
+
|
|
27
|
+
A systemd [OnCalendar expression](https://www.man7.org/linux/man-pages/man7/systemd.time.7.html#CALENDAR_EVENTS)
|
|
28
|
+
parser and evaluator. Requires Python 3.11+.
|
|
29
|
+
|
|
30
|
+
oncalendar is written for and being used in [Healthchecks](https://github.com/healthchecks/healthchecks/)
|
|
31
|
+
(a scheduled task monitoring service).
|
|
32
|
+
|
|
33
|
+
This package provides three iterators:
|
|
34
|
+
|
|
35
|
+
* **oncalendar.BaseIterator(expression: str, start: datime)**: supports expressions
|
|
36
|
+
without timezone (example: "Mon, 12:34"). Accepts both naive and timezone-aware
|
|
37
|
+
datetimes as the start time.
|
|
38
|
+
* **oncalendar.TzIterator(expression: str, start: datetime)**: supports expressions
|
|
39
|
+
with and without timezone. (example: "Mon, 12:34 Europe/Riga"). Requires the start
|
|
40
|
+
datetime to be timezone-aware.
|
|
41
|
+
* **oncalendar.OnCalendar(expressions:str, start: datetime)**: supports multiple
|
|
42
|
+
expressions with or without timezones, separated by newlines. Requires the start
|
|
43
|
+
datetime to be timezone-aware. Example:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
2020-01-01
|
|
47
|
+
12:34 Europe/Riga
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pip install oncalendar
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from datetime import datetime
|
|
60
|
+
from oncalendar import BaseIterator
|
|
61
|
+
|
|
62
|
+
it = BaseIterator("Mon, 12:34", datetime.now())
|
|
63
|
+
for x in range(0, 10):
|
|
64
|
+
print(next(it))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Produces:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
2023-12-11 12:34:00
|
|
71
|
+
2023-12-18 12:34:00
|
|
72
|
+
2023-12-25 12:34:00
|
|
73
|
+
2024-01-01 12:34:00
|
|
74
|
+
2024-01-08 12:34:00
|
|
75
|
+
2024-01-15 12:34:00
|
|
76
|
+
2024-01-22 12:34:00
|
|
77
|
+
2024-01-29 12:34:00
|
|
78
|
+
2024-02-05 12:34:00
|
|
79
|
+
2024-02-12 12:34:00
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If oncalendar receives an invalid expression, it raises `oncalendar.OnCalendarError`
|
|
83
|
+
exception:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from datetime import datetime
|
|
87
|
+
from oncalendar import BaseIterator
|
|
88
|
+
|
|
89
|
+
BaseIterator("Mon, 123:456", datetime.now())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Produces:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
oncalendar.OnCalendarError: Bad hour
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If oncalendar hits year 2200 while iterating, it stops iteration by raising
|
|
99
|
+
`StopIteration`:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from datetime import datetime
|
|
103
|
+
from oncalendar import BaseIterator
|
|
104
|
+
|
|
105
|
+
# 2199 is not leap year, and we stop at 2200
|
|
106
|
+
print(next(BaseIterator("2199-2-29", datetime.now())))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Produces:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
StopIteration
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Known Limitations
|
|
116
|
+
|
|
117
|
+
* Does not support decimals in the second field.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
oncalendar/__init__.py,sha256=Vw-hpLaPi7MilGX2KMFj39uIt1gZqNtRxZN08igsxeg,18998
|
|
2
|
+
oncalendar/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
oncalendar-1.2.dist-info/licenses/LICENSE,sha256=DG1MwCN-e9Dx5ttVB9--Rr5wm_Bo9X_SGuIc5rDdAnQ,1484
|
|
4
|
+
oncalendar-1.2.dist-info/METADATA,sha256=4eRsZotgKy1ysWynHP3htkROXvwgjdlBaUu-rIQW5m0,3195
|
|
5
|
+
oncalendar-1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
oncalendar-1.2.dist-info/top_level.txt,sha256=4fcWAu7WYIdUNPYgIc23ejfVED_tI8fOo4KtZ0aFFX4,11
|
|
7
|
+
oncalendar-1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2023, Pēteris Caune
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
5
|
+
|
|
6
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
7
|
+
|
|
8
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
9
|
+
|
|
10
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
11
|
+
|
|
12
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oncalendar
|