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
+ [![Tests](https://github.com/cuu508/oncalendar/actions/workflows/pytest.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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