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

1""" 

2Schedule schemas 

3""" 

4 

5from __future__ import annotations 

6 

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 

20 

21import dateutil 

22import dateutil.rrule 

23import pytz 

24from pydantic import ConfigDict, Field, field_validator, model_validator 

25from typing_extensions import TypeAlias 

26 

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) 

40 

41MAX_ITERATIONS = 1000 

42 

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 

45 

46 try: 

47 from whenever import ItemizedDelta as _ItemizedDelta 

48 

49 _WHENEVER_DELTA_TYPES: tuple[type, ...] = (_ItemizedDelta,) 

50 except ImportError: 

51 _ItemizedDelta = None # type: ignore[assignment] 

52 _WHENEVER_DELTA_TYPES = (DateTimeDelta,) 

53 

54 AnchorDate: TypeAlias = datetime.datetime 

55else: 

56 from pydantic import AfterValidator 

57 

58 from prefect._internal.schemas.validators import default_anchor_date 

59 

60 AnchorDate: TypeAlias = Annotated[DateTime, AfterValidator(default_anchor_date)] 

61 

62 

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" 

69 

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) 

75 

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) 

81 

82 return start, end 

83 

84 

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. 

94 

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. 

106 

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 """ 

113 

114 model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") 

115 

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"]) 

122 

123 @model_validator(mode="after") 

124 def validate_timezone(self): 

125 self.timezone = default_timezone(self.timezone, self.model_dump()) 

126 return self 

127 

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. 

136 

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. 

145 

146 Returns: 

147 List[DateTime]: A list of dates 

148 """ 

149 return sorted(self._get_dates_generator(n=n, start=start, end=end)) 

150 

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. 

159 

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. 

168 

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 

179 

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 

183 

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 ) 

191 

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 ) 

203 

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))) 

214 

215 anchor_zdt = to_local_zdt(self.anchor_date) 

216 assert anchor_zdt is not None 

217 

218 local_start = to_local_zdt(start) 

219 assert local_start is not None 

220 

221 local_end = to_local_zdt(end) 

222 

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 ) 

242 

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 ) 

254 

255 def _advance(zdt: ZonedDateTime) -> ZonedDateTime: 

256 return zdt.add(days=_interval_days, seconds=_interval_seconds) 

257 

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)) 

266 

267 while next_date < local_start: 

268 next_date = _advance(next_date) 

269 

270 counter = 0 

271 dates: set[ZonedDateTime] = set() 

272 

273 while True: 

274 # if the end date was exceeded, exit 

275 if local_end and next_date > local_end: 

276 break 

277 

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 ) 

286 

287 # if enough dates have been collected or enough attempts were made, exit 

288 if len(dates) >= n or counter > MAX_ITERATIONS: 

289 break 

290 

291 counter += 1 

292 

293 next_date = _advance(next_date) 

294 

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) 

300 

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 ) 

307 

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 ) 

316 

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) 

321 

322 counter = 0 

323 dates = set() 

324 

325 while True: 

326 # if the end date was exceeded, exit 

327 if end and next_date > end: 

328 break 

329 

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 

334 

335 # if enough dates have been collected or enough attempts were made, exit 

336 if len(dates) >= n or counter > MAX_ITERATIONS: 

337 break 

338 

339 counter += 1 

340 

341 next_date = next_date.add(days=interval_days, seconds=interval_seconds) 

342 

343 

344class CronSchedule(PrefectBaseModel): 

345 """ 

346 Cron schedule 

347 

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. 

356 

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 """ 

367 

368 model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") 

369 

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 ) 

378 

379 @model_validator(mode="after") 

380 def validate_timezone(self): 

381 self.timezone = default_timezone(self.timezone, self.model_dump()) 

382 return self 

383 

384 @field_validator("cron") 

385 @classmethod 

386 def valid_cron_string(cls, v: str) -> str: 

387 return validate_cron_string(v) 

388 

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. 

397 

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. 

406 

407 Returns: 

408 List[DateTime]: A list of dates 

409 """ 

410 return sorted(self._get_dates_generator(n=n, start=start, end=end)) 

411 

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. 

420 

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. 

429 

430 Returns: 

431 List[DateTime]: a list of dates 

432 """ 

433 if start is None: 

434 start = now("UTC") 

435 

436 start, end = _prepare_scheduling_start_and_end(start, end, self.timezone) 

437 

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 

445 

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) 

451 

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) 

455 

456 # Respect microseconds by rounding up 

457 if start.microsecond > 0: 

458 start += datetime.timedelta(seconds=1) 

459 

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() 

477 

478 cron = croniter(self.cron, start_naive_tz, day_or=self.day_or) # type: ignore 

479 dates = set() 

480 counter = 0 

481 

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 

491 

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) 

508 

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 

516 

517 # if enough dates have been collected or enough attempts were made, exit 

518 if len(dates) >= n or counter > MAX_ITERATIONS: 

519 break 

520 

521 counter += 1 

522 

523 

524DEFAULT_ANCHOR_DATE = datetime.date(2020, 1, 1) 

525 

526 

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`. 

532 

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. 

536 

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. 

541 

542 Args: 

543 rrule (str): a valid RRule string 

544 timezone (str, optional): a valid timezone string 

545 """ 

546 

547 model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") 

548 

549 rrule: str 

550 timezone: Optional[TimeZone] = "UTC" 

551 

552 @field_validator("rrule") 

553 @classmethod 

554 def validate_rrule_str(cls, v: str) -> str: 

555 return validate_rrule_string(v) 

556 

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) 

576 

577 if len(unique_timezones) > 1: 

578 raise ValueError( 

579 f"rruleset has too many dtstart timezones: {unique_timezones}" 

580 ) 

581 

582 if len(unique_dstarts) > 1: 

583 raise ValueError(f"rruleset has too many dtstarts: {unique_dstarts}") 

584 

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" 

592 

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}") 

614 

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 

644 

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 

655 

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 

661 

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 

667 

668 return rrule 

669 

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. 

678 

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. 

687 

688 Returns: 

689 List[DateTime]: A list of dates 

690 """ 

691 return sorted(self._get_dates_generator(n=n, start=start, end=end)) 

692 

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. 

701 

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. 

710 

711 Returns: 

712 List[DateTime]: a list of dates 

713 """ 

714 if start is None: 

715 start = now("UTC") 

716 

717 start, end = _prepare_scheduling_start_and_end(start, end, self.timezone) 

718 

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 

726 

727 dates = set() 

728 counter = 0 

729 

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 ) 

736 

737 # if the end date was exceeded, exit 

738 if end and next_date > end: 

739 break 

740 

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 

745 

746 # if enough dates have been collected or enough attempts were made, exit 

747 if len(dates) >= n or counter > MAX_ITERATIONS: 

748 break 

749 

750 counter += 1 

751 

752 

753SCHEDULE_TYPES = Union[IntervalSchedule, CronSchedule, RRuleSchedule]