Coverage for /usr/local/lib/python3.12/site-packages/prefect/server/schemas/schedules.py: 16%
284 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 02:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 02:04 +0000
1"""
2Schedule schemas
3"""
5from __future__ import annotations
7import datetime
8import sys
9from typing import (
10 Annotated,
11 Any,
12 ClassVar,
13 Generator,
14 List,
15 Optional,
16 Tuple,
17 Union,
18)
19from zoneinfo import ZoneInfo
21import dateutil
22import dateutil.rrule
23import pytz
24from pydantic import ConfigDict, Field, field_validator, model_validator
25from typing_extensions import TypeAlias
27from prefect._internal.schemas.validators import (
28 default_timezone,
29 validate_cron_string,
30 validate_rrule_string,
31)
32from prefect._vendor.croniter import croniter
33from prefect.server.utilities.schemas.bases import PrefectBaseModel
34from prefect.types import DateTime, TimeZone
35from prefect.types._datetime import (
36 PositiveInterval,
37 create_datetime_instance,
38 now,
39)
41MAX_ITERATIONS = 1000
43if sys.version_info >= (3, 13): 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 from whenever import DateTimeDelta
46 try:
47 from whenever import ItemizedDelta as _ItemizedDelta
49 _WHENEVER_DELTA_TYPES: tuple[type, ...] = (_ItemizedDelta,)
50 except ImportError:
51 _ItemizedDelta = None # type: ignore[assignment]
52 _WHENEVER_DELTA_TYPES = (DateTimeDelta,)
54 AnchorDate: TypeAlias = datetime.datetime
55else:
56 from pydantic import AfterValidator
58 from prefect._internal.schemas.validators import default_anchor_date
60 AnchorDate: TypeAlias = Annotated[DateTime, AfterValidator(default_anchor_date)]
63def _prepare_scheduling_start_and_end(
64 start: Any, end: Any, timezone: str
65) -> Tuple[DateTime, Optional[DateTime]]:
66 """Uniformly prepares the start and end dates for any Schedule's get_dates call,
67 coercing the arguments into timezone-aware datetimes."""
68 timezone = timezone or "UTC"
70 if start is not None:
71 if sys.version_info >= (3, 13):
72 start = create_datetime_instance(start).astimezone(ZoneInfo(timezone))
73 else:
74 start = create_datetime_instance(start).in_tz(timezone)
76 if end is not None:
77 if sys.version_info >= (3, 13):
78 end = create_datetime_instance(end).astimezone(ZoneInfo(timezone))
79 else:
80 end = create_datetime_instance(end).in_tz(timezone)
82 return start, end
85class IntervalSchedule(PrefectBaseModel):
86 """
87 A schedule formed by adding `interval` increments to an `anchor_date`. If no
88 `anchor_date` is supplied, the current UTC time is used. If a
89 timezone-naive datetime is provided for `anchor_date`, it is assumed to be
90 in the schedule's timezone (or UTC). Even if supplied with an IANA timezone,
91 anchor dates are always stored as UTC offsets, so a `timezone` can be
92 provided to determine localization behaviors like DST boundary handling. If
93 none is provided it will be inferred from the anchor date.
95 NOTE: If the `IntervalSchedule` `anchor_date` or `timezone` is provided in a
96 DST-observing timezone, then the schedule will adjust itself appropriately.
97 Intervals greater than 24 hours will follow DST conventions, while intervals
98 of less than 24 hours will follow UTC intervals. For example, an hourly
99 schedule will fire every UTC hour, even across DST boundaries. When clocks
100 are set back, this will result in two runs that *appear* to both be
101 scheduled for 1am local time, even though they are an hour apart in UTC
102 time. For longer intervals, like a daily schedule, the interval schedule
103 will adjust for DST boundaries so that the clock-hour remains constant. This
104 means that a daily schedule that always fires at 9am will observe DST and
105 continue to fire at 9am in the local time zone.
107 Args:
108 interval (datetime.timedelta): an interval to schedule on.
109 anchor_date (DateTime, optional): an anchor date to schedule increments against;
110 if not provided, the current timestamp will be used.
111 timezone (str, optional): a valid timezone string.
112 """
114 model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
116 interval: PositiveInterval = Field()
117 anchor_date: AnchorDate = Field(
118 default_factory=lambda: now("UTC"),
119 examples=["2020-01-01T00:00:00Z"],
120 )
121 timezone: Optional[str] = Field(default=None, examples=["America/New_York"])
123 @model_validator(mode="after")
124 def validate_timezone(self):
125 self.timezone = default_timezone(self.timezone, self.model_dump())
126 return self
128 async def get_dates(
129 self,
130 n: Optional[int] = None,
131 start: Optional[datetime.datetime] = None,
132 end: Optional[datetime.datetime] = None,
133 ) -> List[DateTime]:
134 """Retrieves dates from the schedule. Up to 1,000 candidate dates are checked
135 following the start date.
137 Args:
138 n (int): The number of dates to generate
139 start (datetime.datetime, optional): The first returned date will be on or
140 after this date. Defaults to None. If a timezone-naive datetime is
141 provided, it is assumed to be in the schedule's timezone.
142 end (datetime.datetime, optional): The maximum scheduled date to return. If
143 a timezone-naive datetime is provided, it is assumed to be in the
144 schedule's timezone.
146 Returns:
147 List[DateTime]: A list of dates
148 """
149 return sorted(self._get_dates_generator(n=n, start=start, end=end))
151 def _get_dates_generator(
152 self,
153 n: Optional[int] = None,
154 start: Optional[datetime.datetime] = None,
155 end: Optional[datetime.datetime] = None,
156 ) -> Generator[DateTime, None, None]:
157 """Retrieves dates from the schedule. Up to 1,000 candidate dates are checked
158 following the start date.
160 Args:
161 n (Optional[int]): The number of dates to generate
162 start (Optional[datetime.datetime]): The first returned date will be on or
163 after this date. Defaults to None. If a timezone-naive datetime is
164 provided, it is assumed to be in the schedule's timezone.
165 end (Optional[datetime.datetime]): The maximum scheduled date to return. If
166 a timezone-naive datetime is provided, it is assumed to be in the
167 schedule's timezone.
169 Returns:
170 List[DateTime]: a list of dates
171 """
172 if n is None:
173 # if an end was supplied, we do our best to supply all matching dates (up to
174 # MAX_ITERATIONS)
175 if end is not None:
176 n = MAX_ITERATIONS
177 else:
178 n = 1
180 if sys.version_info >= (3, 13):
181 # `pendulum` is not supported in Python 3.13, so we use `whenever` instead
182 from whenever import PlainDateTime, ZonedDateTime
184 if start is None:
185 _zdt = ZonedDateTime.now("UTC")
186 start = (
187 _zdt.to_stdlib()
188 if hasattr(_zdt, "to_stdlib")
189 else _zdt.py_datetime()
190 )
192 target_timezone = self.timezone or "UTC"
193 _zdt_from_dt = (
194 ZonedDateTime
195 if hasattr(ZonedDateTime, "to_stdlib")
196 else ZonedDateTime.from_py_datetime
197 )
198 _pdt_from_dt = (
199 PlainDateTime
200 if hasattr(PlainDateTime, "to_stdlib")
201 else PlainDateTime.from_py_datetime
202 )
204 def to_local_zdt(dt: datetime.datetime | None) -> ZonedDateTime | None:
205 if dt is None:
206 return None
207 if dt.tzinfo is None:
208 return _pdt_from_dt(dt).assume_tz(target_timezone)
209 if isinstance(dt.tzinfo, ZoneInfo):
210 return _zdt_from_dt(dt).to_tz(target_timezone)
211 # For offset-based tzinfo instances (e.g. datetime.timezone(+09:00)),
212 # use astimezone to preserve the instant, then convert to ZonedDateTime.
213 return _zdt_from_dt(dt.astimezone(ZoneInfo(target_timezone)))
215 anchor_zdt = to_local_zdt(self.anchor_date)
216 assert anchor_zdt is not None
218 local_start = to_local_zdt(start)
219 assert local_start is not None
221 local_end = to_local_zdt(end)
223 interval = self.interval
224 if isinstance(interval, _WHENEVER_DELTA_TYPES):
225 # whenever delta types distinguish calendar days from exact hours,
226 # so we can use them directly. We still need an approximate
227 # total-seconds value for the initial offset jump.
228 if isinstance(interval, DateTimeDelta):
229 _months, _days, _secs, _nanos = interval.in_months_days_secs_nanos()
230 approx_total_seconds = (
231 _months * 30 * 86400 + _days * 86400 + _secs + _nanos / 1e9
232 )
233 else: # ItemizedDelta (whenever >= 0.10.0)
234 _date, _time = interval.date_and_time_parts()
235 _months = _date.get("months") or 0 if _date else 0
236 _days = _date.get("days") or 0 if _date else 0
237 approx_total_seconds = (
238 _months * 30 * 86400
239 + _days * 86400
240 + (int(_time.total("seconds")) if _time else 0)
241 )
243 def _advance(zdt: ZonedDateTime) -> ZonedDateTime:
244 return zdt + interval
245 else:
246 approx_total_seconds = interval.total_seconds()
247 # break the interval into `days` and `seconds` because
248 # ZonedDateTime.add will handle DST boundaries properly if
249 # days are provided, but not if we add `total seconds`.
250 _interval_days = interval.days
251 _interval_seconds = interval.total_seconds() - (
252 _interval_days * 24 * 60 * 60
253 )
255 def _advance(zdt: ZonedDateTime) -> ZonedDateTime:
256 return zdt.add(days=_interval_days, seconds=_interval_seconds)
258 _diff = local_start - anchor_zdt
259 _diff_secs = (
260 _diff.total("seconds")
261 if hasattr(_diff, "total")
262 else _diff.in_seconds()
263 )
264 offset = _diff_secs / approx_total_seconds
265 next_date = anchor_zdt.add(seconds=approx_total_seconds * int(offset))
267 while next_date < local_start:
268 next_date = _advance(next_date)
270 counter = 0
271 dates: set[ZonedDateTime] = set()
273 while True:
274 # if the end date was exceeded, exit
275 if local_end and next_date > local_end:
276 break
278 # ensure no duplicates; weird things can happen with DST
279 if next_date not in dates:
280 dates.add(next_date)
281 yield (
282 next_date.to_stdlib()
283 if hasattr(next_date, "to_stdlib")
284 else next_date.py_datetime()
285 )
287 # if enough dates have been collected or enough attempts were made, exit
288 if len(dates) >= n or counter > MAX_ITERATIONS:
289 break
291 counter += 1
293 next_date = _advance(next_date)
295 else:
296 if start is None:
297 start = now("UTC")
298 anchor_tz = self.anchor_date.in_tz(self.timezone)
299 start, end = _prepare_scheduling_start_and_end(start, end, self.timezone)
301 # compute the offset between the anchor date and the start date to jump to the
302 # next date
303 offset = (start - anchor_tz).total_seconds() / self.interval.total_seconds()
304 next_date = anchor_tz.add(
305 seconds=self.interval.total_seconds() * int(offset)
306 )
308 # break the interval into `days` and `seconds` because the datetime
309 # library will handle DST boundaries properly if days are provided, but not
310 # if we add `total seconds`. Therefore, `next_date + self.interval`
311 # fails while `next_date.add(days=days, seconds=seconds)` works.
312 interval_days = self.interval.days
313 interval_seconds = self.interval.total_seconds() - (
314 interval_days * 24 * 60 * 60
315 )
317 # daylight saving time boundaries can create a situation where the next date is
318 # before the start date, so we advance it if necessary
319 while next_date < start:
320 next_date = next_date.add(days=interval_days, seconds=interval_seconds)
322 counter = 0
323 dates = set()
325 while True:
326 # if the end date was exceeded, exit
327 if end and next_date > end:
328 break
330 # ensure no duplicates; weird things can happen with DST
331 if next_date not in dates:
332 dates.add(next_date)
333 yield next_date
335 # if enough dates have been collected or enough attempts were made, exit
336 if len(dates) >= n or counter > MAX_ITERATIONS:
337 break
339 counter += 1
341 next_date = next_date.add(days=interval_days, seconds=interval_seconds)
344class CronSchedule(PrefectBaseModel):
345 """
346 Cron schedule
348 NOTE: If the timezone is a DST-observing one, then the schedule will adjust
349 itself appropriately. Cron's rules for DST are based on schedule times, not
350 intervals. This means that an hourly cron schedule will fire on every new
351 schedule hour, not every elapsed hour; for example, when clocks are set back
352 this will result in a two-hour pause as the schedule will fire *the first
353 time* 1am is reached and *the first time* 2am is reached, 120 minutes later.
354 Longer schedules, such as one that fires at 9am every morning, will
355 automatically adjust for DST.
357 Args:
358 cron (str): a valid cron string
359 timezone (str): a valid timezone string in IANA tzdata format (for example,
360 America/New_York).
361 day_or (bool, optional): Control how croniter handles `day` and `day_of_week`
362 entries. Defaults to True, matching cron which connects those values using
363 OR. If the switch is set to False, the values are connected using AND. This
364 behaves like fcron and enables you to e.g. define a job that executes each
365 2nd friday of a month by setting the days of month and the weekday.
366 """
368 model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
370 cron: str = Field(default=..., examples=["0 0 * * *"])
371 timezone: Optional[str] = Field(default=None, examples=["America/New_York"])
372 day_or: bool = Field(
373 default=True,
374 description=(
375 "Control croniter behavior for handling day and day_of_week entries."
376 ),
377 )
379 @model_validator(mode="after")
380 def validate_timezone(self):
381 self.timezone = default_timezone(self.timezone, self.model_dump())
382 return self
384 @field_validator("cron")
385 @classmethod
386 def valid_cron_string(cls, v: str) -> str:
387 return validate_cron_string(v)
389 async def get_dates(
390 self,
391 n: Optional[int] = None,
392 start: Optional[datetime.datetime] = None,
393 end: Optional[datetime.datetime] = None,
394 ) -> List[DateTime]:
395 """Retrieves dates from the schedule. Up to 1,000 candidate dates are checked
396 following the start date.
398 Args:
399 n (int): The number of dates to generate
400 start (datetime.datetime, optional): The first returned date will be on or
401 after this date. Defaults to None. If a timezone-naive datetime is
402 provided, it is assumed to be in the schedule's timezone.
403 end (datetime.datetime, optional): The maximum scheduled date to return. If
404 a timezone-naive datetime is provided, it is assumed to be in the
405 schedule's timezone.
407 Returns:
408 List[DateTime]: A list of dates
409 """
410 return sorted(self._get_dates_generator(n=n, start=start, end=end))
412 def _get_dates_generator(
413 self,
414 n: Optional[int] = None,
415 start: Optional[datetime.datetime] = None,
416 end: Optional[datetime.datetime] = None,
417 ) -> Generator[DateTime, None, None]:
418 """Retrieves dates from the schedule. Up to 1,000 candidate dates are checked
419 following the start date.
421 Args:
422 n (int): The number of dates to generate
423 start (datetime.datetime, optional): The first returned date will be on or
424 after this date. Defaults to the current date. If a timezone-naive
425 datetime is provided, it is assumed to be in the schedule's timezone.
426 end (datetime.datetime, optional): No returned date will exceed this date.
427 If a timezone-naive datetime is provided, it is assumed to be in the
428 schedule's timezone.
430 Returns:
431 List[DateTime]: a list of dates
432 """
433 if start is None:
434 start = now("UTC")
436 start, end = _prepare_scheduling_start_and_end(start, end, self.timezone)
438 if n is None:
439 # if an end was supplied, we do our best to supply all matching dates (up to
440 # MAX_ITERATIONS)
441 if end is not None:
442 n = MAX_ITERATIONS
443 else:
444 n = 1
446 if self.timezone:
447 if sys.version_info >= (3, 13):
448 start = start.astimezone(ZoneInfo(self.timezone or "UTC"))
449 else:
450 start = start.in_tz(self.timezone)
452 # subtract one second from the start date, so that croniter returns it
453 # as an event (if it meets the cron criteria)
454 start = start - datetime.timedelta(seconds=1)
456 # Respect microseconds by rounding up
457 if start.microsecond > 0:
458 start += datetime.timedelta(seconds=1)
460 # croniter's DST logic interferes with all other datetime libraries except pytz
461 if sys.version_info >= (3, 13):
462 start_localized = start.astimezone(ZoneInfo(self.timezone or "UTC"))
463 start_naive_tz = start.replace(tzinfo=None)
464 else:
465 start_localized = pytz.timezone(start.tz.name).localize(
466 datetime.datetime(
467 year=start.year,
468 month=start.month,
469 day=start.day,
470 hour=start.hour,
471 minute=start.minute,
472 second=start.second,
473 microsecond=start.microsecond,
474 )
475 )
476 start_naive_tz = start.naive()
478 cron = croniter(self.cron, start_naive_tz, day_or=self.day_or) # type: ignore
479 dates = set()
480 counter = 0
482 while True:
483 # croniter does not handle DST properly when the start time is
484 # in and around when the actual shift occurs. To work around this,
485 # we use the naive start time to get the next cron date delta, then
486 # add that time to the original scheduling anchor.
487 next_time = cron.get_next(datetime.datetime)
488 delta = next_time - start_naive_tz
489 if sys.version_info >= (3, 13):
490 from whenever import ZonedDateTime
492 # Use `whenever` to handle DST correctly
493 _zdt_from_dt = (
494 ZonedDateTime
495 if hasattr(ZonedDateTime, "to_stdlib")
496 else ZonedDateTime.from_py_datetime
497 )
498 _zdt = _zdt_from_dt(start_localized + delta).to_tz(
499 self.timezone or "UTC"
500 )
501 next_date = (
502 _zdt.to_stdlib()
503 if hasattr(_zdt, "to_stdlib")
504 else _zdt.py_datetime()
505 )
506 else:
507 next_date = create_datetime_instance(start_localized + delta)
509 # if the end date was exceeded, exit
510 if end and next_date > end:
511 break
512 # ensure no duplicates; weird things can happen with DST
513 if next_date not in dates:
514 dates.add(next_date)
515 yield next_date
517 # if enough dates have been collected or enough attempts were made, exit
518 if len(dates) >= n or counter > MAX_ITERATIONS:
519 break
521 counter += 1
524DEFAULT_ANCHOR_DATE = datetime.date(2020, 1, 1)
527class RRuleSchedule(PrefectBaseModel):
528 """
529 RRule schedule, based on the iCalendar standard
530 ([RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545)) as
531 implemented in `dateutils.rrule`.
533 RRules are appropriate for any kind of calendar-date manipulation, including
534 irregular intervals, repetition, exclusions, week day or day-of-month
535 adjustments, and more.
537 Note that as a calendar-oriented standard, `RRuleSchedules` are sensitive to
538 to the initial timezone provided. A 9am daily schedule with a daylight saving
539 time-aware start date will maintain a local 9am time through DST boundaries;
540 a 9am daily schedule with a UTC start date will maintain a 9am UTC time.
542 Args:
543 rrule (str): a valid RRule string
544 timezone (str, optional): a valid timezone string
545 """
547 model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
549 rrule: str
550 timezone: Optional[TimeZone] = "UTC"
552 @field_validator("rrule")
553 @classmethod
554 def validate_rrule_str(cls, v: str) -> str:
555 return validate_rrule_string(v)
557 @classmethod
558 def from_rrule(
559 cls, rrule: dateutil.rrule.rrule | dateutil.rrule.rruleset
560 ) -> "RRuleSchedule":
561 if isinstance(rrule, dateutil.rrule.rrule):
562 if rrule._dtstart.tzinfo is not None:
563 timezone = getattr(rrule._dtstart.tzinfo, "name", None) or getattr(
564 rrule._dtstart.tzinfo, "key", "UTC"
565 )
566 else:
567 timezone = "UTC"
568 return RRuleSchedule(rrule=str(rrule), timezone=timezone)
569 elif isinstance(rrule, dateutil.rrule.rruleset):
570 dtstarts = [rr._dtstart for rr in rrule._rrule if rr._dtstart is not None]
571 unique_dstarts = set(
572 create_datetime_instance(d).astimezone(ZoneInfo("UTC"))
573 for d in dtstarts
574 )
575 unique_timezones = set(d.tzinfo for d in dtstarts if d.tzinfo is not None)
577 if len(unique_timezones) > 1:
578 raise ValueError(
579 f"rruleset has too many dtstart timezones: {unique_timezones}"
580 )
582 if len(unique_dstarts) > 1:
583 raise ValueError(f"rruleset has too many dtstarts: {unique_dstarts}")
585 if unique_dstarts and unique_timezones:
586 tzinfo = dtstarts[0].tzinfo
587 timezone = getattr(tzinfo, "name", None) or getattr(
588 tzinfo, "key", "UTC"
589 )
590 else:
591 timezone = "UTC"
593 rruleset_string = ""
594 if rrule._rrule:
595 rruleset_string += "\n".join(str(r) for r in rrule._rrule)
596 if rrule._exrule:
597 rruleset_string += "\n" if rruleset_string else ""
598 rruleset_string += "\n".join(str(r) for r in rrule._exrule).replace(
599 "RRULE", "EXRULE"
600 )
601 if rrule._rdate:
602 rruleset_string += "\n" if rruleset_string else ""
603 rruleset_string += "RDATE:" + ",".join(
604 rd.strftime("%Y%m%dT%H%M%SZ") for rd in rrule._rdate
605 )
606 if rrule._exdate:
607 rruleset_string += "\n" if rruleset_string else ""
608 rruleset_string += "EXDATE:" + ",".join(
609 exd.strftime("%Y%m%dT%H%M%SZ") for exd in rrule._exdate
610 )
611 return RRuleSchedule(rrule=rruleset_string, timezone=timezone)
612 else:
613 raise ValueError(f"Invalid RRule object: {rrule}")
615 def to_rrule(self) -> dateutil.rrule.rrule:
616 """
617 Since rrule doesn't properly serialize/deserialize timezones, we localize dates
618 here
619 """
620 rrule = dateutil.rrule.rrulestr(
621 self.rrule,
622 dtstart=DEFAULT_ANCHOR_DATE,
623 cache=True,
624 )
625 timezone = dateutil.tz.gettz(self.timezone)
626 if isinstance(rrule, dateutil.rrule.rrule):
627 kwargs = dict(dtstart=rrule._dtstart.replace(tzinfo=timezone))
628 if rrule._until:
629 kwargs.update(
630 until=rrule._until.replace(tzinfo=timezone),
631 )
632 return rrule.replace(**kwargs)
633 elif isinstance(rrule, dateutil.rrule.rruleset):
634 # update rrules
635 localized_rrules = []
636 for rr in rrule._rrule:
637 kwargs = dict(dtstart=rr._dtstart.replace(tzinfo=timezone))
638 if rr._until:
639 kwargs.update(
640 until=rr._until.replace(tzinfo=timezone),
641 )
642 localized_rrules.append(rr.replace(**kwargs))
643 rrule._rrule = localized_rrules
645 # update exrules
646 localized_exrules = []
647 for exr in rrule._exrule:
648 kwargs = dict(dtstart=exr._dtstart.replace(tzinfo=timezone))
649 if exr._until:
650 kwargs.update(
651 until=exr._until.replace(tzinfo=timezone),
652 )
653 localized_exrules.append(exr.replace(**kwargs))
654 rrule._exrule = localized_exrules
656 # update rdates
657 localized_rdates = []
658 for rd in rrule._rdate:
659 localized_rdates.append(rd.replace(tzinfo=timezone))
660 rrule._rdate = localized_rdates
662 # update exdates
663 localized_exdates = []
664 for exd in rrule._exdate:
665 localized_exdates.append(exd.replace(tzinfo=timezone))
666 rrule._exdate = localized_exdates
668 return rrule
670 async def get_dates(
671 self,
672 n: Optional[int] = None,
673 start: datetime.datetime = None,
674 end: datetime.datetime = None,
675 ) -> List[DateTime]:
676 """Retrieves dates from the schedule. Up to 1,000 candidate dates are checked
677 following the start date.
679 Args:
680 n (int): The number of dates to generate
681 start (datetime.datetime, optional): The first returned date will be on or
682 after this date. Defaults to None. If a timezone-naive datetime is
683 provided, it is assumed to be in the schedule's timezone.
684 end (datetime.datetime, optional): The maximum scheduled date to return. If
685 a timezone-naive datetime is provided, it is assumed to be in the
686 schedule's timezone.
688 Returns:
689 List[DateTime]: A list of dates
690 """
691 return sorted(self._get_dates_generator(n=n, start=start, end=end))
693 def _get_dates_generator(
694 self,
695 n: Optional[int] = None,
696 start: Optional[datetime.datetime] = None,
697 end: Optional[datetime.datetime] = None,
698 ) -> Generator[DateTime, None, None]:
699 """Retrieves dates from the schedule. Up to 1,000 candidate dates are checked
700 following the start date.
702 Args:
703 n (int): The number of dates to generate
704 start (datetime.datetime, optional): The first returned date will be on or
705 after this date. Defaults to the current date. If a timezone-naive
706 datetime is provided, it is assumed to be in the schedule's timezone.
707 end (datetime.datetime, optional): No returned date will exceed this date.
708 If a timezone-naive datetime is provided, it is assumed to be in the
709 schedule's timezone.
711 Returns:
712 List[DateTime]: a list of dates
713 """
714 if start is None:
715 start = now("UTC")
717 start, end = _prepare_scheduling_start_and_end(start, end, self.timezone)
719 if n is None:
720 # if an end was supplied, we do our best to supply all matching dates (up
721 # to MAX_ITERATIONS)
722 if end is not None:
723 n = MAX_ITERATIONS
724 else:
725 n = 1
727 dates = set()
728 counter = 0
730 # pass count = None to account for discrepancies with duplicates around DST
731 # boundaries
732 for next_date in self.to_rrule().xafter(start, count=None, inc=True):
733 next_date = create_datetime_instance(next_date).astimezone(
734 ZoneInfo(self.timezone)
735 )
737 # if the end date was exceeded, exit
738 if end and next_date > end:
739 break
741 # ensure no duplicates; weird things can happen with DST
742 if next_date not in dates:
743 dates.add(next_date)
744 yield next_date
746 # if enough dates have been collected or enough attempts were made, exit
747 if len(dates) >= n or counter > MAX_ITERATIONS:
748 break
750 counter += 1
753SCHEDULE_TYPES = Union[IntervalSchedule, CronSchedule, RRuleSchedule]