Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/croniter/croniter.py: 82%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

838 statements  

1#!/usr/bin/env python 

2import binascii 

3import calendar 

4import copy 

5import datetime 

6import math 

7import platform 

8import random 

9import re 

10import struct 

11import sys 

12import traceback as _traceback 

13from time import time 

14from typing import Any, Literal, Optional, Union 

15 

16from dateutil.relativedelta import relativedelta 

17from dateutil.tz import datetime_exists, tzutc 

18 

19ExpandedExpression = list[Union[int, Literal["*", "l"]]] 

20 

21 

22def is_32bit() -> bool: 

23 """ 

24 Detect if Python is running in 32-bit mode. 

25 Returns True if running on 32-bit Python, False for 64-bit. 

26 """ 

27 # Method 1: Check pointer size 

28 bits = struct.calcsize("P") * 8 

29 

30 # Method 2: Check platform architecture string 

31 try: 

32 architecture = platform.architecture()[0] 

33 except RuntimeError: 

34 architecture = None 

35 

36 # Method 3: Check maxsize 

37 is_small_maxsize = sys.maxsize <= 2**32 

38 

39 # Evaluate all available methods 

40 is_32 = False 

41 

42 if bits == 32: 

43 is_32 = True 

44 elif architecture and "32" in architecture: 

45 is_32 = True 

46 elif is_small_maxsize: 

47 is_32 = True 

48 

49 return is_32 

50 

51 

52try: 

53 # https://github.com/python/cpython/issues/101069 detection 

54 if is_32bit(): 

55 datetime.datetime.fromtimestamp(3999999999) 

56 OVERFLOW32B_MODE = False 

57except OverflowError: 

58 OVERFLOW32B_MODE = True 

59 

60 

61UTC_DT = datetime.timezone.utc 

62EPOCH = datetime.datetime.fromtimestamp(0, UTC_DT) 

63 

64M_ALPHAS: dict[str, Union[int, str]] = { 

65 "jan": 1, 

66 "feb": 2, 

67 "mar": 3, 

68 "apr": 4, 

69 "may": 5, 

70 "jun": 6, 

71 "jul": 7, 

72 "aug": 8, 

73 "sep": 9, 

74 "oct": 10, 

75 "nov": 11, 

76 "dec": 12, 

77} 

78DOW_ALPHAS: dict[str, Union[int, str]] = { 

79 "sun": 0, 

80 "mon": 1, 

81 "tue": 2, 

82 "wed": 3, 

83 "thu": 4, 

84 "fri": 5, 

85 "sat": 6, 

86} 

87 

88MINUTE_FIELD = 0 

89HOUR_FIELD = 1 

90DAY_FIELD = 2 

91MONTH_FIELD = 3 

92DOW_FIELD = 4 

93SECOND_FIELD = 5 

94YEAR_FIELD = 6 

95 

96UNIX_FIELDS = (MINUTE_FIELD, HOUR_FIELD, DAY_FIELD, MONTH_FIELD, DOW_FIELD) 

97SECOND_FIELDS = (MINUTE_FIELD, HOUR_FIELD, DAY_FIELD, MONTH_FIELD, DOW_FIELD, SECOND_FIELD) 

98YEAR_FIELDS = ( 

99 MINUTE_FIELD, 

100 HOUR_FIELD, 

101 DAY_FIELD, 

102 MONTH_FIELD, 

103 DOW_FIELD, 

104 SECOND_FIELD, 

105 YEAR_FIELD, 

106) 

107 

108step_search_re = re.compile(r"^([^-]+)-([^-/]+)(/(\d+))?$") 

109only_int_re = re.compile(r"^\d+$") 

110 

111DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) 

112WEEKDAYS = "|".join(DOW_ALPHAS.keys()) 

113MONTHS = "|".join(M_ALPHAS.keys()) 

114star_or_int_re = re.compile(r"^(\d+|\*)$") 

115special_dow_re = re.compile( 

116 rf"^(?P<pre>((?P<he>(({WEEKDAYS})(-({WEEKDAYS}))?)" 

117 rf"|(({MONTHS})(-({MONTHS}))?)|\w+)#)|l)(?P<last>\d+)$" 

118) 

119nearest_weekday_re = re.compile(r"^(?:(\d+)w|w(\d+))$") 

120re_star = re.compile("[*]") 

121hash_expression_re = re.compile( 

122 r"^(?P<hash_type>h|r)(\((?P<range_begin>\d+)-(?P<range_end>\d+)\))?(\/(?P<divisor>\d+))?$" 

123) 

124 

125CRON_FIELDS = { 

126 "unix": UNIX_FIELDS, 

127 "second": SECOND_FIELDS, 

128 "year": YEAR_FIELDS, 

129 len(UNIX_FIELDS): UNIX_FIELDS, 

130 len(SECOND_FIELDS): SECOND_FIELDS, 

131 len(YEAR_FIELDS): YEAR_FIELDS, 

132} 

133UNIX_CRON_LEN = len(UNIX_FIELDS) 

134SECOND_CRON_LEN = len(SECOND_FIELDS) 

135YEAR_CRON_LEN = len(YEAR_FIELDS) 

136# retrocompat 

137VALID_LEN_EXPRESSION = {a for a in CRON_FIELDS if isinstance(a, int)} 

138 

139MARKER = object() 

140 

141 

142def datetime_to_timestamp(d): 

143 if d.tzinfo is not None: 

144 d = d.replace(tzinfo=None) - d.utcoffset() 

145 

146 return (d - datetime.datetime(1970, 1, 1)).total_seconds() 

147 

148 

149def _is_leap(year: int) -> bool: 

150 return year % 400 == 0 or (year % 4 == 0 and year % 100 != 0) 

151 

152 

153def _last_day_of_month(year: int, month: int) -> int: 

154 """Calculate the last day of the given month (honor leap years).""" 

155 last_day = DAYS[month - 1] 

156 if month == 2 and _is_leap(year): 

157 last_day += 1 

158 return last_day 

159 

160 

161def _is_successor( 

162 date: datetime.datetime, previous_date: datetime.datetime, is_prev: bool 

163) -> bool: 

164 """Check if the given date is a successor (after/before) of the previous date.""" 

165 if is_prev: 

166 return date.astimezone(UTC_DT) < previous_date.astimezone(UTC_DT) 

167 return date.astimezone(UTC_DT) > previous_date.astimezone(UTC_DT) 

168 

169 

170def _timezone_delta(date1: datetime.datetime, date2: datetime.datetime) -> datetime.timedelta: 

171 """Calculate the timezone difference of the given dates.""" 

172 offset1 = date1.utcoffset() 

173 offset2 = date2.utcoffset() 

174 assert offset1 is not None 

175 assert offset2 is not None 

176 return offset2 - offset1 

177 

178 

179def _add_tzinfo( 

180 date: datetime.datetime, previous_date: datetime.datetime, is_prev: bool 

181) -> tuple[datetime.datetime, bool]: 

182 """Add the tzinfo from the previous date to the given date. 

183 

184 In case the new date is ambiguous, determine the correct date 

185 based on it being closer to the previous date but still a successor 

186 (after/before based on `is_prev`). 

187 

188 In case the date does not exist, jump forward to the next existing date. 

189 """ 

190 localize = getattr(previous_date.tzinfo, "localize", None) 

191 if localize is not None: 

192 # pylint: disable-next=import-outside-toplevel 

193 import pytz 

194 

195 try: 

196 result = localize(date, is_dst=None) 

197 except pytz.NonExistentTimeError: 

198 while True: 

199 date += datetime.timedelta(minutes=1) 

200 try: 

201 result = localize(date, is_dst=None) 

202 except pytz.NonExistentTimeError: 

203 continue 

204 break 

205 return result, False 

206 except pytz.AmbiguousTimeError: 

207 closer = localize(date, is_dst=not is_prev) 

208 farther = localize(date, is_dst=is_prev) 

209 # TODO: Check negative DST 

210 assert (closer.astimezone(UTC_DT) > farther.astimezone(UTC_DT)) == is_prev 

211 if _is_successor(closer, previous_date, is_prev): 

212 result = closer 

213 else: 

214 assert _is_successor(farther, previous_date, is_prev) 

215 result = farther 

216 return result, True 

217 

218 result = date.replace(fold=1 if is_prev else 0, tzinfo=previous_date.tzinfo) 

219 if not datetime_exists(result): 

220 while not datetime_exists(result): 

221 result += datetime.timedelta(minutes=1) 

222 return result, False 

223 

224 # result is closer to the previous date 

225 farther = date.replace(fold=0 if is_prev else 1, tzinfo=previous_date.tzinfo) 

226 # Comparing the UTC offsets in the check for the date being ambiguous. 

227 if result.utcoffset() != farther.utcoffset(): 

228 # TODO: Check negative DST 

229 assert (result.astimezone(UTC_DT) > farther.astimezone(UTC_DT)) == is_prev 

230 if not _is_successor(result, previous_date, is_prev): 

231 assert _is_successor(farther, previous_date, is_prev) 

232 result = farther 

233 return result, True 

234 

235 

236class CroniterError(ValueError): 

237 """General top-level Croniter base exception""" 

238 

239 

240class CroniterBadTypeRangeError(TypeError): 

241 """.""" 

242 

243 

244class CroniterBadCronError(CroniterError): 

245 """Syntax, unknown value, or range error within a cron expression""" 

246 

247 

248class CroniterUnsupportedSyntaxError(CroniterBadCronError): 

249 """Valid cron syntax, but likely to produce inaccurate results""" 

250 

251 # Extending CroniterBadCronError, which may be contridatory, but this allows 

252 # catching both errors with a single exception. From a user perspective 

253 # these will likely be handled the same way. 

254 

255 

256class CroniterBadDateError(CroniterError): 

257 """Unable to find next/prev timestamp match""" 

258 

259 

260class CroniterNotAlphaError(CroniterBadCronError): 

261 """Cron syntax contains an invalid day or month abbreviation""" 

262 

263 

264class croniter: 

265 MONTHS_IN_YEAR = 12 

266 

267 # This helps with expanding `*` fields into `lower-upper` ranges. Each item 

268 # in this tuple maps to the corresponding field index 

269 RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 6), (0, 59), (1970, 2099)) 

270 

271 ALPHACONV: tuple[dict[str, Union[int, str]], ...] = ( 

272 {}, # 0: min 

273 {}, # 1: hour 

274 {"l": "l"}, # 2: dom 

275 # 3: mon 

276 copy.deepcopy(M_ALPHAS), 

277 # 4: dow 

278 copy.deepcopy(DOW_ALPHAS), 

279 # 5: second 

280 {}, 

281 # 6: year 

282 {}, 

283 ) 

284 

285 LOWMAP: tuple[dict[int, int], ...] = ({}, {}, {0: 1}, {0: 1}, {7: 0}, {}, {}) 

286 

287 LEN_MEANS_ALL = (60, 24, 31, 12, 7, 60, 130) 

288 

289 def __init__( 

290 self, 

291 expr_format: str, 

292 start_time: Optional[Union[datetime.datetime, float]] = None, 

293 ret_type: type = float, 

294 day_or: bool = True, 

295 max_years_between_matches: Optional[int] = None, 

296 is_prev: bool = False, 

297 hash_id: Optional[Union[bytes, str]] = None, 

298 implement_cron_bug: bool = False, 

299 second_at_beginning: bool = False, 

300 expand_from_start_time: bool = False, 

301 ) -> None: 

302 self._ret_type = ret_type 

303 self._day_or = day_or 

304 self._implement_cron_bug = implement_cron_bug 

305 self.second_at_beginning = bool(second_at_beginning) 

306 self._expand_from_start_time = expand_from_start_time 

307 

308 if hash_id is not None: 

309 if not isinstance(hash_id, (bytes, str)): 

310 raise TypeError("hash_id must be bytes or UTF-8 string") 

311 if not isinstance(hash_id, bytes): 

312 hash_id = hash_id.encode("UTF-8") 

313 

314 self._max_years_btw_matches_explicitly_set = max_years_between_matches is not None 

315 if max_years_between_matches is None: 

316 max_years_between_matches = 50 

317 self._max_years_between_matches = max(int(max_years_between_matches), 1) 

318 

319 if start_time is None: 

320 start_time = time() 

321 

322 self.tzinfo: Optional[datetime.tzinfo] = None 

323 

324 self.start_time = 0.0 

325 self.dst_start_time = 0.0 

326 self.cur = 0.0 

327 self.set_current(start_time, force=True) 

328 

329 self.expanded, self.nth_weekday_of_month, self.expressions, self.nearest_weekday = self._expand( 

330 expr_format, 

331 hash_id=hash_id, 

332 from_timestamp=self.dst_start_time if self._expand_from_start_time else None, 

333 from_timestamp_tz=self.tzinfo if self._expand_from_start_time else None, 

334 second_at_beginning=second_at_beginning, 

335 ) 

336 self.fields = CRON_FIELDS[len(self.expanded)] 

337 self._is_prev = is_prev 

338 

339 @classmethod 

340 def _alphaconv(cls, index, key, expressions): 

341 try: 

342 return cls.ALPHACONV[index][key] 

343 except KeyError: 

344 raise CroniterNotAlphaError(f"[{' '.join(expressions)}] is not acceptable") 

345 

346 def get_next(self, ret_type=None, start_time=None, update_current=True): 

347 if start_time and self._expand_from_start_time: 

348 raise ValueError( 

349 "start_time is not supported when using expand_from_start_time = True." 

350 ) 

351 return self._get_next( 

352 ret_type=ret_type, start_time=start_time, is_prev=False, update_current=update_current 

353 ) 

354 

355 def get_prev(self, ret_type=None, start_time=None, update_current=True): 

356 return self._get_next( 

357 ret_type=ret_type, start_time=start_time, is_prev=True, update_current=update_current 

358 ) 

359 

360 def get_current(self, ret_type=None): 

361 ret_type = ret_type or self._ret_type 

362 if issubclass(ret_type, datetime.datetime): 

363 return self.timestamp_to_datetime(self.cur) 

364 return self.cur 

365 

366 def set_current( 

367 self, start_time: Optional[Union[datetime.datetime, float]], force: bool = True 

368 ) -> float: 

369 if (force or (self.cur is None)) and start_time is not None: 

370 if isinstance(start_time, datetime.datetime): 

371 self.tzinfo = start_time.tzinfo 

372 start_time = self.datetime_to_timestamp(start_time) 

373 

374 self.start_time = start_time 

375 self.dst_start_time = start_time 

376 self.cur = start_time 

377 return self.cur 

378 

379 @staticmethod 

380 def datetime_to_timestamp(d: datetime.datetime) -> float: 

381 """ 

382 Converts a `datetime` object `d` into a UNIX timestamp. 

383 """ 

384 return datetime_to_timestamp(d) 

385 

386 _datetime_to_timestamp = datetime_to_timestamp # retrocompat 

387 

388 def timestamp_to_datetime(self, timestamp: float, tzinfo: Any = MARKER) -> datetime.datetime: 

389 """ 

390 Converts a UNIX `timestamp` into a `datetime` object. 

391 """ 

392 if tzinfo is MARKER: # allow to give tzinfo=None even if self.tzinfo is set 

393 tzinfo = self.tzinfo 

394 if OVERFLOW32B_MODE: 

395 # degraded mode to workaround Y2038 

396 # see https://github.com/python/cpython/issues/101069 

397 result = EPOCH.replace(tzinfo=None) + datetime.timedelta(seconds=timestamp) 

398 else: 

399 result = datetime.datetime.fromtimestamp(timestamp, tz=tzutc()).replace(tzinfo=None) 

400 if tzinfo: 

401 result = result.replace(tzinfo=UTC_DT).astimezone(tzinfo) 

402 return result 

403 

404 _timestamp_to_datetime = timestamp_to_datetime # retrocompat 

405 

406 def _get_next(self, ret_type=None, start_time=None, is_prev=None, update_current=None): 

407 if update_current is None: 

408 update_current = True 

409 self.set_current(start_time, force=True) 

410 if is_prev is None: 

411 is_prev = self._is_prev 

412 self._is_prev = is_prev 

413 

414 ret_type = ret_type or self._ret_type 

415 

416 if not issubclass(ret_type, (float, datetime.datetime)): 

417 raise TypeError("Invalid ret_type, only 'float' or 'datetime' is acceptable.") 

418 

419 result = self._calc_next(is_prev) 

420 timestamp = self.datetime_to_timestamp(result) 

421 if update_current: 

422 self.cur = timestamp 

423 if issubclass(ret_type, datetime.datetime): 

424 return result 

425 return timestamp 

426 

427 # iterator protocol, to enable direct use of croniter 

428 # objects in a loop, like "for dt in croniter("5 0 * * *'): ..." 

429 # or for combining multiple croniters into single 

430 # dates feed using 'itertools' module 

431 def all_next(self, ret_type=None, start_time=None, update_current=None): 

432 """ 

433 Returns a generator yielding consecutive dates. 

434 

435 May be used instead of an implicit call to __iter__ whenever a 

436 non-default `ret_type` needs to be specified. 

437 """ 

438 # In a Python 3.7+ world: contextlib.suppress and contextlib.nullcontext could 

439 # be used instead 

440 try: 

441 while True: 

442 self._is_prev = False 

443 yield self._get_next( 

444 ret_type=ret_type, start_time=start_time, update_current=update_current 

445 ) 

446 start_time = None 

447 except CroniterBadDateError: 

448 if self._max_years_btw_matches_explicitly_set: 

449 return 

450 raise 

451 

452 def all_prev(self, ret_type=None, start_time=None, update_current=None): 

453 """ 

454 Returns a generator yielding previous dates. 

455 """ 

456 try: 

457 while True: 

458 self._is_prev = True 

459 yield self._get_next( 

460 ret_type=ret_type, start_time=start_time, update_current=update_current 

461 ) 

462 start_time = None 

463 except CroniterBadDateError: 

464 if self._max_years_btw_matches_explicitly_set: 

465 return 

466 raise 

467 

468 def iter(self, *args, **kwargs): 

469 return self.all_prev if self._is_prev else self.all_next 

470 

471 def __iter__(self): 

472 return self 

473 

474 __next__ = next = _get_next 

475 

476 def _calc_next(self, is_prev: bool) -> datetime.datetime: 

477 current = self.timestamp_to_datetime(self.cur) 

478 expanded = self.expanded[:] 

479 nth_weekday_of_month = self.nth_weekday_of_month.copy() 

480 

481 # exception to support day of month and day of week as defined in cron 

482 if (expanded[DAY_FIELD][0] != "*" and expanded[DOW_FIELD][0] != "*") and self._day_or: 

483 # If requested, handle a bug in vixie cron/ISC cron where day_of_month and 

484 # day_of_week form an intersection (AND) instead of a union (OR) if either 

485 # field is an asterisk or starts with an asterisk (https://crontab.guru/cron-bug.html) 

486 if self._implement_cron_bug and ( 

487 re_star.match(self.expressions[DAY_FIELD]) 

488 or re_star.match(self.expressions[DOW_FIELD]) 

489 ): 

490 # To produce a schedule identical to the cron bug, we'll bypass the code 

491 # that makes a union of DOM and DOW, and instead skip to the code that 

492 # does an intersect instead 

493 pass 

494 else: 

495 # Under OR semantics an unsatisfiable side -- the 31st in a month 

496 # that has no 31st, say -- contributes no dates rather than ruling 

497 # out the expression, so the other side must still be able to 

498 # match. Only both sides failing means there is no such date. 

499 # 

500 # That only holds while each side is a clean operand. '#' and 'W' 

501 # are not carried by the DAY/DOW fields but by nth_weekday_of_month 

502 # and self.nearest_weekday, so blanking the fields above does not 

503 # remove them and each side stays an intersection (the semantics 

504 # test_issue_k33 pins down). Swallowing a failure there would drop 

505 # the other field from the schedule instead, so let it propagate. 

506 clean_split = not nth_weekday_of_month and not self.nearest_weekday 

507 

508 bak = expanded[DOW_FIELD] 

509 expanded[DOW_FIELD] = ["*"] 

510 try: 

511 t1 = self._calc(current, expanded, nth_weekday_of_month, is_prev) 

512 except CroniterBadDateError: 

513 if not clean_split: 

514 raise 

515 t1 = None 

516 expanded[DOW_FIELD] = bak 

517 expanded[DAY_FIELD] = ["*"] 

518 

519 try: 

520 t2 = self._calc(current, expanded, nth_weekday_of_month, is_prev) 

521 except CroniterBadDateError: 

522 if not clean_split: 

523 raise 

524 t2 = None 

525 

526 if t1 is None: 

527 if t2 is None: 

528 raise CroniterBadDateError( 

529 "failed to find prev date" if is_prev else "failed to find next date" 

530 ) 

531 return t2 

532 if t2 is None: 

533 return t1 

534 if is_prev: 

535 return t1 if t1 > t2 else t2 

536 return t1 if t1 < t2 else t2 

537 

538 return self._calc(current, expanded, nth_weekday_of_month, is_prev) 

539 

540 def _calc( 

541 self, 

542 now: datetime.datetime, 

543 expanded: list[ExpandedExpression], 

544 nth_weekday_of_month: dict[int, set[int]], 

545 is_prev: bool, 

546 ) -> datetime.datetime: 

547 if is_prev: 

548 nearest_diff_method = self._get_prev_nearest_diff 

549 offset = relativedelta(microseconds=-1) 

550 else: 

551 nearest_diff_method = self._get_next_nearest_diff 

552 if len(expanded) > UNIX_CRON_LEN: 

553 offset = relativedelta(seconds=1) 

554 else: 

555 offset = relativedelta(minutes=1) 

556 # Calculate the next cron time in local time a.k.a. timezone unaware time. 

557 unaware_time = now.replace(tzinfo=None) + offset 

558 if len(expanded) > UNIX_CRON_LEN: 

559 unaware_time = unaware_time.replace(microsecond=0) 

560 else: 

561 unaware_time = unaware_time.replace(second=0, microsecond=0) 

562 

563 month = unaware_time.month 

564 year = current_year = unaware_time.year 

565 

566 def proc_year(d): 

567 if len(expanded) == YEAR_CRON_LEN: 

568 try: 

569 expanded[YEAR_FIELD].index("*") 

570 except ValueError: 

571 # use None as range_val to indicate no loop 

572 diff_year = nearest_diff_method(d.year, expanded[YEAR_FIELD], None) 

573 if diff_year is None: 

574 return None, d 

575 if diff_year != 0: 

576 if is_prev: 

577 d += relativedelta( 

578 years=diff_year, month=12, day=31, hour=23, minute=59, second=59 

579 ) 

580 else: 

581 d += relativedelta( 

582 years=diff_year, month=1, day=1, hour=0, minute=0, second=0 

583 ) 

584 return True, d 

585 return False, d 

586 

587 def proc_month(d): 

588 try: 

589 expanded[MONTH_FIELD].index("*") 

590 except ValueError: 

591 diff_month = nearest_diff_method( 

592 d.month, expanded[MONTH_FIELD], self.MONTHS_IN_YEAR 

593 ) 

594 reset_day = 1 

595 

596 if diff_month is not None and diff_month != 0: 

597 if is_prev: 

598 d += relativedelta(months=diff_month) 

599 reset_day = _last_day_of_month(d.year, d.month) 

600 d += relativedelta(day=reset_day, hour=23, minute=59, second=59) 

601 else: 

602 d += relativedelta( 

603 months=diff_month, day=reset_day, hour=0, minute=0, second=0 

604 ) 

605 return True, d 

606 return False, d 

607 

608 def proc_day_of_month(d): 

609 try: 

610 expanded[DAY_FIELD].index("*") 

611 except ValueError: 

612 days = _last_day_of_month(year, month) 

613 if "l" in expanded[DAY_FIELD] and days == d.day: 

614 return False, d 

615 

616 if is_prev: 

617 prev_month = (month - 2) % self.MONTHS_IN_YEAR + 1 

618 prev_year = year - 1 if month == 1 else year 

619 days_in_prev_month = _last_day_of_month(prev_year, prev_month) 

620 diff_day = nearest_diff_method(d.day, expanded[DAY_FIELD], days_in_prev_month) 

621 else: 

622 diff_day = nearest_diff_method(d.day, expanded[DAY_FIELD], days) 

623 

624 if diff_day is not None and diff_day != 0: 

625 if is_prev: 

626 d += relativedelta(days=diff_day, hour=23, minute=59, second=59) 

627 else: 

628 d += relativedelta(days=diff_day, hour=0, minute=0, second=0) 

629 return True, d 

630 return False, d 

631 

632 def proc_day_of_week(d): 

633 try: 

634 expanded[DOW_FIELD].index("*") 

635 except ValueError: 

636 diff_day_of_week = nearest_diff_method(d.isoweekday() % 7, expanded[DOW_FIELD], 7) 

637 if diff_day_of_week is not None and diff_day_of_week != 0: 

638 if is_prev: 

639 d += relativedelta(days=diff_day_of_week, hour=23, minute=59, second=59) 

640 else: 

641 d += relativedelta(days=diff_day_of_week, hour=0, minute=0, second=0) 

642 return True, d 

643 return False, d 

644 

645 def proc_day_of_week_nth(d): 

646 if "*" in nth_weekday_of_month: 

647 s = nth_weekday_of_month["*"] 

648 for i in range(0, 7): 

649 if i in nth_weekday_of_month: 

650 nth_weekday_of_month[i].update(s) 

651 else: 

652 nth_weekday_of_month[i] = s 

653 del nth_weekday_of_month["*"] 

654 

655 candidates = [] 

656 for wday, nth in nth_weekday_of_month.items(): 

657 c = self._get_nth_weekday_of_month(d.year, d.month, wday) 

658 for n in nth: 

659 if n == "l": 

660 candidate = c[-1] 

661 elif len(c) < n: 

662 continue 

663 else: 

664 candidate = c[n - 1] 

665 if (is_prev and candidate <= d.day) or (not is_prev and d.day <= candidate): 

666 candidates.append(candidate) 

667 

668 if not candidates: 

669 if is_prev: 

670 d += relativedelta(days=-d.day, hour=23, minute=59, second=59) 

671 else: 

672 days = _last_day_of_month(year, month) 

673 d += relativedelta(days=(days - d.day + 1), hour=0, minute=0, second=0) 

674 return True, d 

675 

676 candidates.sort() 

677 diff_day = (candidates[-1] if is_prev else candidates[0]) - d.day 

678 if diff_day != 0: 

679 if is_prev: 

680 d += relativedelta(days=diff_day, hour=23, minute=59, second=59) 

681 else: 

682 d += relativedelta(days=diff_day, hour=0, minute=0, second=0) 

683 return True, d 

684 return False, d 

685 

686 def proc_nearest_weekday(d): 

687 """Process W (nearest weekday) day-of-month entries.""" 

688 candidates = [] 

689 for w_day in self.nearest_weekday: 

690 candidate = self._get_nearest_weekday(d.year, d.month, w_day) 

691 if (is_prev and candidate <= d.day) or (not is_prev and d.day <= candidate): 

692 candidates.append(candidate) 

693 

694 if not candidates: 

695 if is_prev: 

696 d += relativedelta(days=-d.day, hour=23, minute=59, second=59) 

697 else: 

698 days = _last_day_of_month(year, month) 

699 d += relativedelta(days=(days - d.day + 1), hour=0, minute=0, second=0) 

700 return True, d 

701 

702 candidates.sort() 

703 diff_day = (candidates[-1] if is_prev else candidates[0]) - d.day 

704 if diff_day != 0: 

705 if is_prev: 

706 d += relativedelta(days=diff_day, hour=23, minute=59, second=59) 

707 else: 

708 d += relativedelta(days=diff_day, hour=0, minute=0, second=0) 

709 return True, d 

710 return False, d 

711 

712 def proc_hour(d): 

713 try: 

714 expanded[HOUR_FIELD].index("*") 

715 except ValueError: 

716 diff_hour = nearest_diff_method(d.hour, expanded[HOUR_FIELD], 24) 

717 if diff_hour is not None and diff_hour != 0: 

718 if is_prev: 

719 d += relativedelta(hours=diff_hour, minute=59, second=59) 

720 else: 

721 d += relativedelta(hours=diff_hour, minute=0, second=0) 

722 return True, d 

723 return False, d 

724 

725 def proc_minute(d): 

726 try: 

727 expanded[MINUTE_FIELD].index("*") 

728 except ValueError: 

729 diff_min = nearest_diff_method(d.minute, expanded[MINUTE_FIELD], 60) 

730 if diff_min is not None and diff_min != 0: 

731 if is_prev: 

732 d += relativedelta(minutes=diff_min, second=59) 

733 else: 

734 d += relativedelta(minutes=diff_min, second=0) 

735 return True, d 

736 return False, d 

737 

738 def proc_second(d): 

739 if len(expanded) > UNIX_CRON_LEN: 

740 try: 

741 expanded[SECOND_FIELD].index("*") 

742 except ValueError: 

743 diff_sec = nearest_diff_method(d.second, expanded[SECOND_FIELD], 60) 

744 if diff_sec is not None and diff_sec != 0: 

745 d += relativedelta(seconds=diff_sec) 

746 return True, d 

747 else: 

748 d += relativedelta(second=0) 

749 return False, d 

750 

751 procs = [ 

752 proc_year, 

753 proc_month, 

754 (proc_nearest_weekday if self.nearest_weekday else proc_day_of_month), 

755 (proc_day_of_week_nth if nth_weekday_of_month else proc_day_of_week), 

756 proc_hour, 

757 proc_minute, 

758 proc_second, 

759 ] 

760 

761 while abs(year - current_year) <= self._max_years_between_matches: 

762 next = False 

763 stop = False 

764 for proc in procs: 

765 (changed, unaware_time) = proc(unaware_time) 

766 # `None` can be set mostly for year processing 

767 # so please see proc_year / _get_prev_nearest_diff / _get_next_nearest_diff 

768 if changed is None: 

769 stop = True 

770 break 

771 if changed: 

772 month, year = unaware_time.month, unaware_time.year 

773 next = True 

774 break 

775 if stop: 

776 break 

777 if next: 

778 continue 

779 

780 unaware_time = unaware_time.replace(microsecond=0) 

781 if now.tzinfo is None: 

782 return unaware_time 

783 

784 # Add timezone information back and handle DST changes 

785 aware_time, exists = _add_tzinfo(unaware_time, now, is_prev) 

786 

787 if not exists and ( 

788 not _is_successor(aware_time, now, is_prev) or "*" in expanded[HOUR_FIELD] 

789 ): 

790 # The calculated local date does not exist and moving the time forward 

791 # to the next valid time isn't the correct solution. Search for the 

792 # next matching cron time that exists. 

793 while not exists: 

794 unaware_time = self._calc( 

795 unaware_time, expanded, nth_weekday_of_month, is_prev 

796 ) 

797 aware_time, exists = _add_tzinfo(unaware_time, now, is_prev) 

798 

799 offset_delta = _timezone_delta(now, aware_time) 

800 if not offset_delta: 

801 # There was no DST change. 

802 return aware_time 

803 

804 # There was a DST change. So check if there is a alternative cron time 

805 # for the other UTC offset. 

806 alternative_unaware_time = now.replace(tzinfo=None) + offset_delta 

807 alternative_unaware_time = self._calc( 

808 alternative_unaware_time, expanded, nth_weekday_of_month, is_prev 

809 ) 

810 alternative_aware_time, exists = _add_tzinfo(alternative_unaware_time, now, is_prev) 

811 

812 if not _is_successor(alternative_aware_time, now, is_prev): 

813 # The alternative time is an ancestor of now. Thus it is not an alternative. 

814 return aware_time 

815 

816 if _is_successor(aware_time, alternative_aware_time, is_prev): 

817 return alternative_aware_time 

818 

819 return aware_time 

820 

821 if is_prev: 

822 raise CroniterBadDateError("failed to find prev date") 

823 raise CroniterBadDateError("failed to find next date") 

824 

825 @staticmethod 

826 def _get_next_nearest_diff(x, to_check, range_val): 

827 """ 

828 `range_val` is the range of a field. 

829 If no available time, we can move to next loop(like next month). 

830 `range_val` can also be set to `None` to indicate that there is no loop. 

831 ( Currently, should only used for `year` field ) 

832 """ 

833 for i, d in enumerate(to_check): 

834 if range_val is not None: 

835 if d == "l": 

836 # if 'l' then it is the last day of month 

837 # => its value of range_val 

838 d = range_val 

839 elif d > range_val: 

840 continue 

841 if d >= x: 

842 return d - x 

843 # When range_val is None and x not exists in to_check, 

844 # `None` will be returned to suggest no more available time 

845 if range_val is None: 

846 return None 

847 return to_check[0] - x + range_val 

848 

849 @staticmethod 

850 def _get_prev_nearest_diff(x, to_check, range_val): 

851 """ 

852 `range_val` is the range of a field. 

853 If no available time, we can move to previous loop(like previous month). 

854 Range_val can also be set to `None` to indicate that there is no loop. 

855 ( Currently should only used for `year` field ) 

856 """ 

857 candidates = to_check[:] 

858 candidates.reverse() 

859 for d in candidates: 

860 if d != "l" and d <= x: 

861 return d - x 

862 if "l" in candidates: 

863 return -x 

864 # When range_val is None and x not exists in to_check, 

865 # `None` will be returned to suggest no more available time 

866 if range_val is None: 

867 return None 

868 candidate = candidates[0] 

869 for c in candidates: 

870 # fixed: c < range_val 

871 # this code will reject all 31 day of month, 12 month, 59 second, 

872 # 23 hour and so on. 

873 # if candidates has just a element, this will not harmful. 

874 # but candidates have multiple elements, then values equal to 

875 # range_val will rejected. 

876 if c <= range_val: 

877 candidate = c 

878 break 

879 # fix crontab "0 6 30 3 *" condidates only a element, then get_prev error 

880 # return 2021-03-02 06:00:00 

881 if candidate > range_val: 

882 return -range_val 

883 return candidate - x - range_val 

884 

885 @staticmethod 

886 def _get_nth_weekday_of_month(year: int, month: int, day_of_week: int) -> tuple[int, ...]: 

887 """For a given year/month return a list of days in nth-day-of-month order. 

888 The last weekday of the month is always [-1]. 

889 """ 

890 w = (day_of_week + 6) % 7 

891 c = calendar.Calendar(w).monthdayscalendar(year, month) 

892 if c[0][0] == 0: 

893 c.pop(0) 

894 return tuple(i[0] for i in c) 

895 

896 @staticmethod 

897 def _get_nearest_weekday(year, month, day): 

898 """Get the nearest weekday (Mon-Fri) to the given day in the given month. 

899 

900 Rules: 

901 - If the day is a weekday, return it. 

902 - If Saturday, return Friday (day-1), unless that crosses into previous month, 

903 then return Monday (day+2). 

904 - If Sunday, return Monday (day+1), unless that crosses into next month, 

905 then return Friday (day-2). 

906 """ 

907 last_day = _last_day_of_month(year, month) 

908 day = min(day, last_day) 

909 weekday = calendar.weekday(year, month, day) # 0=Mon, 6=Sun 

910 if weekday < 5: # Mon-Fri 

911 return day 

912 if weekday == 5: # Saturday 

913 if day > 1: 

914 return day - 1 # Friday 

915 else: 

916 return day + 2 # Monday (1st is Sat, so 3rd is Mon) 

917 # Sunday 

918 if day < last_day: 

919 return day + 1 # Monday 

920 else: 

921 return day - 2 # Friday (last day is Sun, go back to Fri) 

922 

923 @classmethod 

924 def value_alias(cls, val, field_index, len_expressions=UNIX_CRON_LEN): 

925 if isinstance(len_expressions, (list, dict, tuple, set)): 

926 len_expressions = len(len_expressions) 

927 if val in cls.LOWMAP[field_index] and not ( 

928 # do not support 0 as a month either for classical 5 fields cron, 

929 # 6fields second repeat form or 7 fields year form 

930 # but still let conversion happen if day field is shifted 

931 (field_index in [DAY_FIELD, MONTH_FIELD] and len_expressions == UNIX_CRON_LEN) 

932 or (field_index in [MONTH_FIELD, DOW_FIELD] and len_expressions == SECOND_CRON_LEN) 

933 or ( 

934 field_index in [DAY_FIELD, MONTH_FIELD, DOW_FIELD] 

935 and len_expressions == YEAR_CRON_LEN 

936 ) 

937 ): 

938 val = cls.LOWMAP[field_index][val] 

939 return val 

940 

941 # Maximum days in each month (non-leap year for Feb) 

942 DAYS_IN_MONTH = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30, 7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31} 

943 

944 @classmethod 

945 def _expand(cls, expr_format, hash_id=None, second_at_beginning=False, from_timestamp=None, from_timestamp_tz=None, strict=False, strict_year=None): 

946 # Split the expression in components, and normalize L -> l, MON -> mon, 

947 # etc. Keep expr_format untouched so we can use it in the exception 

948 # messages. 

949 expr_aliases = { 

950 "@midnight": ("0 0 * * *", "h h(0-2) * * * h"), 

951 "@hourly": ("0 * * * *", "h * * * * h"), 

952 "@daily": ("0 0 * * *", "h h * * * h"), 

953 "@weekly": ("0 0 * * 0", "h h * * h h"), 

954 "@monthly": ("0 0 1 * *", "h h h * * h"), 

955 "@yearly": ("0 0 1 1 *", "h h h h * h"), 

956 "@annually": ("0 0 1 1 *", "h h h h * h"), 

957 } 

958 

959 efl = expr_format.lower() 

960 hash_id_expr = 1 if hash_id is not None else 0 

961 try: 

962 efl = expr_aliases[efl][hash_id_expr] 

963 except KeyError: 

964 pass 

965 

966 expressions = efl.split() 

967 

968 if len(expressions) not in VALID_LEN_EXPRESSION: 

969 raise CroniterBadCronError( 

970 "Exactly 5, 6 or 7 columns has to be specified for iterator expression." 

971 ) 

972 

973 if len(expressions) > UNIX_CRON_LEN and second_at_beginning: 

974 # move second to it's own(6th) field to process by same logical 

975 expressions.insert(SECOND_FIELD, expressions.pop(0)) 

976 

977 expanded = [] 

978 nth_weekday_of_month = {} 

979 nearest_weekday = set() 

980 

981 for field_index, expr in enumerate(expressions): 

982 for expanderid, expander in EXPANDERS.items(): 

983 expr = expander(cls).expand( 

984 efl, field_index, expr, hash_id=hash_id, from_timestamp=from_timestamp 

985 ) 

986 

987 if "?" in expr: 

988 if expr != "?": 

989 raise CroniterBadCronError( 

990 f"[{expr_format}] is not acceptable." 

991 f" Question mark can not used with other characters" 

992 ) 

993 if field_index not in [DAY_FIELD, DOW_FIELD]: 

994 raise CroniterBadCronError( 

995 f"[{expr_format}] is not acceptable. " 

996 f"Question mark can only used in day_of_month or day_of_week" 

997 ) 

998 # currently just trade `?` as `*` 

999 expr = "*" 

1000 

1001 e_list = expr.split(",") 

1002 res = [] 

1003 seen = set() 

1004 

1005 while len(e_list) > 0: 

1006 e = e_list.pop() 

1007 nth = None 

1008 

1009 if field_index == DOW_FIELD: 

1010 # Handle special case in the dow expression: 2#3, l3 

1011 special_dow_rem = special_dow_re.match(str(e)) 

1012 if special_dow_rem: 

1013 g = special_dow_rem.groupdict() 

1014 he, last = g.get("he", ""), g.get("last", "") 

1015 if he: 

1016 e = he 

1017 try: 

1018 nth = int(last) 

1019 assert 5 >= nth >= 1 

1020 except (KeyError, ValueError, AssertionError): 

1021 raise CroniterBadCronError( 

1022 f"[{expr_format}] is not acceptable." 

1023 f" Invalid day_of_week value: '{nth}'" 

1024 ) 

1025 elif last: 

1026 e = last 

1027 nth = g["pre"] # 'l' 

1028 

1029 if field_index == DAY_FIELD: 

1030 # Handle W (nearest weekday) in day-of-month: 15w, w15 

1031 w_match = nearest_weekday_re.match(str(e)) 

1032 if w_match: 

1033 w_day = int(w_match.group(1) or w_match.group(2)) 

1034 if w_day < 1 or w_day > 31: 

1035 raise CroniterBadCronError( 

1036 f"[{expr_format}] is not acceptable," 

1037 f" nearest weekday day value '{w_day}' out of range" 

1038 ) 

1039 if len(e_list) > 0 or len(res) > 0: 

1040 raise CroniterBadCronError( 

1041 f"[{expr_format}] is not acceptable." 

1042 f" 'W' can only be used with a single day value," 

1043 f" not in a list or range" 

1044 ) 

1045 nearest_weekday.add(w_day) 

1046 res.append(w_day) 

1047 continue 

1048 

1049 # Before matching step_search_re, normalize "*" to "{min}-{max}". 

1050 # Example: in the minute field, "*/5" normalizes to "0-59/5" 

1051 t = re.sub( 

1052 r"^\*(\/.+)$", 

1053 r"%d-%d\1" % (cls.RANGES[field_index][0], cls.RANGES[field_index][1]), 

1054 str(e), 

1055 ) 

1056 m = step_search_re.search(t) 

1057 

1058 # "{start}/{step}" is its own shape, not a range. It is normalized 

1059 # below to "{start}-{max}/{step}" so that one regex parses both, but 

1060 # the two must not be conflated afterwards: "Jan-Jan" is an explicit 

1061 # equal range that croniter deliberately expands to the whole cycle, 

1062 # whereas "DEC/3" is a start with a step and denotes just [DEC]. 

1063 start_with_step = False 

1064 if not m: 

1065 # Before matching step_search_re, 

1066 # normalize "{start}/{step}" to "{start}-{max}/{step}". 

1067 # Example: in the minute field, "10/5" normalizes to "10-59/5" 

1068 t = re.sub(r"^(.+)\/(.+)$", r"\1-%d/\2" % (cls.RANGES[field_index][1]), str(e)) 

1069 m = step_search_re.search(t) 

1070 start_with_step = bool(m) 

1071 

1072 if m: 

1073 # early abort if low/high are out of bounds 

1074 (low, high, step) = m.group(1), m.group(2), m.group(4) or 1 

1075 if field_index == DAY_FIELD and high == "l": 

1076 high = "31" 

1077 

1078 if not only_int_re.search(low): 

1079 low = str(cls._alphaconv(field_index, low, expressions)) 

1080 

1081 if not only_int_re.search(high): 

1082 high = str(cls._alphaconv(field_index, high, expressions)) 

1083 

1084 # normally, it's already guarded by the RE that should not accept 

1085 # not-int values. 

1086 if not only_int_re.search(str(step)): 

1087 raise CroniterBadCronError( 

1088 f"[{expr_format}] step '{step}'" 

1089 f" in field {field_index} is not acceptable" 

1090 ) 

1091 step = int(step) 

1092 if step == 0: 

1093 raise CroniterBadCronError( 

1094 f"[{expr_format}] step '{step}'" 

1095 f" in field {field_index} is not acceptable" 

1096 ) 

1097 

1098 for band in low, high: 

1099 if not only_int_re.search(str(band)): 

1100 raise CroniterBadCronError( 

1101 f"[{expr_format}] bands '{low}-{high}'" 

1102 f" in field {field_index} are not acceptable" 

1103 ) 

1104 

1105 low, high = ( 

1106 cls.value_alias(int(_val), field_index, expressions) 

1107 for _val in (low, high) 

1108 ) 

1109 

1110 if max(low, high) > max( 

1111 cls.RANGES[field_index][0], cls.RANGES[field_index][1] 

1112 ): 

1113 raise CroniterBadCronError(f"{expr_format} is out of bands") 

1114 

1115 # "{start}/{step}" normalizes to "{start}-{max}/{step}", so when 

1116 # the start IS the field maximum the two bounds collide and the 

1117 # token becomes indistinguishable from an explicitly written equal 

1118 # range. That is the whole bug: "59/15" arrived at the ``low == 

1119 # high`` branch below -- which exists for "Jan-Jan" and expands to 

1120 # the whole cycle -- and so fired at :00/:15/:30/:45 instead of 

1121 # :59. Recognising the collision here is what keeps the two apart. 

1122 # 

1123 # Deliberately ``low == high`` and not the wider ``low + step > 

1124 # high``. Both fix the reported bug, but the wider form also 

1125 # suppresses the re-base below for starts that are not at the 

1126 # maximum, silently changing ~365 additional 

1127 # ``expand_from_start_time`` schedules that were never broken. 

1128 # That is a separate question about what an explicit lower bound 

1129 # should mean under that flag, and it is not this fix's to answer. 

1130 start_at_field_max = start_with_step and low == high 

1131 

1132 # ``from_timestamp`` re-bases the start of a *cycle* on the start 

1133 # time. A single point has no cycle to re-base, and rewriting its 

1134 # bound here would leave the bug reachable in 

1135 # ``expand_from_start_time`` mode while looking fixed by default. 

1136 if from_timestamp and not start_at_field_max: 

1137 low = cls._get_low_from_current_date_number( 

1138 field_index, int(step), int(from_timestamp), from_timestamp_tz 

1139 ) 

1140 

1141 # Handle when the second bound of the range is in backtracking order: 

1142 # eg: X-Sun or X-7 (Sat-Sun) in DOW, or X-Jan (Apr-Jan) in MONTH 

1143 if start_at_field_max: 

1144 rng = [low] 

1145 elif low > high: 

1146 whole_field_range = list( 

1147 range(cls.RANGES[field_index][0], cls.RANGES[field_index][1] + 1, 1) 

1148 ) 

1149 # Add FirstBound -> ENDRANGE, respecting step 

1150 rng = list(range(low, cls.RANGES[field_index][1] + 1, step)) 

1151 # Then 0 -> SecondBound, but skipping n first occurences according to step 

1152 # EG to respect such expressions : Apr-Jan/3 

1153 to_skip = 0 

1154 if rng: 

1155 already_skipped = list(reversed(whole_field_range)).index(rng[-1]) 

1156 curpos = whole_field_range.index(rng[-1]) 

1157 if ((curpos + step) > len(whole_field_range)) and ( 

1158 already_skipped < step 

1159 ): 

1160 to_skip = step - already_skipped 

1161 rng += list(range(cls.RANGES[field_index][0] + to_skip, high + 1, step)) 

1162 # if we include a range type: Jan-Jan, or Sun-Sun, 

1163 # it means the whole cycle (all days of week, # all monthes of year, etc) 

1164 elif low == high: 

1165 rng = list( 

1166 range(cls.RANGES[field_index][0], cls.RANGES[field_index][1] + 1, step) 

1167 ) 

1168 else: 

1169 try: 

1170 rng = list(range(low, high + 1, step)) 

1171 except ValueError as exc: 

1172 raise CroniterBadCronError(f"invalid range: {exc}") 

1173 

1174 if field_index == DOW_FIELD and nth and nth != "l": 

1175 rng = [f"{item}#{nth}" for item in rng] 

1176 e_list += [a for a in rng if a not in seen] 

1177 seen.update(rng) 

1178 else: 

1179 if t.startswith("-"): 

1180 raise CroniterBadCronError( 

1181 f"[{expr_format}] is not acceptable, negative numbers not allowed" 

1182 ) 

1183 if not star_or_int_re.search(t): 

1184 t = cls._alphaconv(field_index, t, expressions) 

1185 

1186 try: 

1187 t = int(t) 

1188 except ValueError: 

1189 pass 

1190 

1191 t = cls.value_alias(t, field_index, expressions) 

1192 

1193 if t not in ["*", "l"] and ( 

1194 int(t) < cls.RANGES[field_index][0] or int(t) > cls.RANGES[field_index][1] 

1195 ): 

1196 raise CroniterBadCronError( 

1197 f"[{expr_format}] is not acceptable, out of range" 

1198 ) 

1199 

1200 res.append(t) 

1201 

1202 if field_index == DOW_FIELD and nth: 

1203 if t not in nth_weekday_of_month: 

1204 nth_weekday_of_month[t] = set() 

1205 nth_weekday_of_month[t].add(nth) 

1206 

1207 res = set(res) 

1208 res = sorted(res, key=lambda i: f"{i:02}" if isinstance(i, int) else i) 

1209 if len(res) == cls.LEN_MEANS_ALL[field_index]: 

1210 # Make sure the wildcard is used in the correct way (avoid over-optimization) 

1211 if (field_index == DAY_FIELD and "*" not in expressions[DOW_FIELD]) or ( 

1212 field_index == DOW_FIELD and "*" not in expressions[DAY_FIELD] 

1213 ): 

1214 pass 

1215 else: 

1216 res = ["*"] 

1217 

1218 expanded.append(["*"] if (len(res) == 1 and res[0] == "*") else res) 

1219 

1220 # Check to make sure the dow combo in use is supported 

1221 if nth_weekday_of_month: 

1222 dow_expanded_set = set(expanded[DOW_FIELD]) 

1223 dow_expanded_set = dow_expanded_set.difference(nth_weekday_of_month.keys()) 

1224 dow_expanded_set.discard("*") 

1225 # Skip: if it's all weeks instead of wildcard 

1226 if dow_expanded_set and len(set(expanded[DOW_FIELD])) != cls.LEN_MEANS_ALL[DOW_FIELD]: 

1227 raise CroniterUnsupportedSyntaxError( 

1228 f"day-of-week field does not support mixing literal values and nth" 

1229 f" day of week syntax. Cron: '{expr_format}'" 

1230 f" dow={dow_expanded_set} vs nth={nth_weekday_of_month}" 

1231 ) 

1232 

1233 if strict: 

1234 # Cross-validate day-of-month against month (and optionally year) 

1235 # to reject impossible combinations like "0 0 31 2 *" (Feb 31st). 

1236 days = expanded[DAY_FIELD] 

1237 months = expanded[MONTH_FIELD] 

1238 if days != ["*"] and days != ["l"] and months != ["*"]: 

1239 int_days = [d for d in days if isinstance(d, int)] 

1240 int_months = [m for m in months if isinstance(m, int)] 

1241 if int_days and int_months: 

1242 # Determine max days per month, accounting for leap years 

1243 days_in_month = dict(cls.DAYS_IN_MONTH) 

1244 if 2 in int_months: 

1245 has_leap_year = True # assume possible by default 

1246 if strict_year is not None: 

1247 # Year explicitly provided as parameter 

1248 if isinstance(strict_year, int): 

1249 has_leap_year = calendar.isleap(strict_year) 

1250 else: 

1251 has_leap_year = any(calendar.isleap(y) for y in strict_year) 

1252 elif len(expanded) > YEAR_FIELD: 

1253 years = expanded[YEAR_FIELD] 

1254 if years != ["*"]: 

1255 int_years = [y for y in years if isinstance(y, int)] 

1256 if int_years: 

1257 has_leap_year = any(calendar.isleap(y) for y in int_years) 

1258 if has_leap_year: 

1259 days_in_month[2] = 29 

1260 min_day = min(int_days) 

1261 max_possible = max(days_in_month[m] for m in int_months) 

1262 if min_day > max_possible: 

1263 raise CroniterBadCronError( 

1264 f"[{expr_format}] is not acceptable. Day(s) {int_days}" 

1265 f" can never occur in month(s) {int_months}" 

1266 ) 

1267 

1268 return expanded, nth_weekday_of_month, expressions, nearest_weekday 

1269 

1270 @classmethod 

1271 def expand( 

1272 cls, 

1273 expr_format: str, 

1274 hash_id: Optional[Union[bytes, str]] = None, 

1275 second_at_beginning: bool = False, 

1276 from_timestamp: Optional[float] = None, 

1277 from_timestamp_tz: Optional[datetime.tzinfo] = None, 

1278 strict: bool = False, 

1279 strict_year: Optional[Union[int, list[int]]] = None, 

1280 ) -> tuple[list[ExpandedExpression], dict[int, set[int]]]: 

1281 """ 

1282 Expand a cron expression format into a noramlized format of 

1283 list[list[int | 'l' | '*']]. The first list representing each element 

1284 of the epxression, and each sub-list representing the allowed values 

1285 for that expression component. 

1286 

1287 A tuple is returned, the first value being the expanded epxression 

1288 list, and the second being a `nth_weekday_of_month` mapping. 

1289 

1290 Examples: 

1291 

1292 # Every minute 

1293 >>> croniter.expand('* * * * *') 

1294 ([['*'], ['*'], ['*'], ['*'], ['*']], {}) 

1295 

1296 # On the hour 

1297 >>> croniter.expand('0 0 * * *') 

1298 ([[0], [0], ['*'], ['*'], ['*']], {}) 

1299 

1300 # Hours 0-5 and 10 monday through friday 

1301 >>> croniter.expand('0-5,10 * * * mon-fri') 

1302 ([[0, 1, 2, 3, 4, 5, 10], ['*'], ['*'], ['*'], [1, 2, 3, 4, 5]], {}) 

1303 

1304 Note that some special values such as nth day of week are expanded to a 

1305 special mapping format for later processing: 

1306 

1307 # Every minute on the 3rd tuesday of the month 

1308 >>> croniter.expand('* * * * 2#3') 

1309 ([['*'], ['*'], ['*'], ['*'], [2]], {2: {3}}) 

1310 

1311 # Every hour on the last day of the month 

1312 >>> croniter.expand('0 * l * *') 

1313 ([[0], ['*'], ['l'], ['*'], ['*']], {}) 

1314 

1315 # On the hour every 15 seconds 

1316 >>> croniter.expand('0 0 * * * */15') 

1317 ([[0], [0], ['*'], ['*'], ['*'], [0, 15, 30, 45]], {}) 

1318 """ 

1319 try: 

1320 expanded, nth_weekday_of_month, _expressions, _nearest_weekday = cls._expand( 

1321 expr_format, 

1322 hash_id=hash_id, 

1323 second_at_beginning=second_at_beginning, 

1324 from_timestamp=from_timestamp, 

1325 from_timestamp_tz=from_timestamp_tz, 

1326 strict=strict, 

1327 strict_year=strict_year, 

1328 ) 

1329 return expanded, nth_weekday_of_month 

1330 except (ValueError,) as exc: 

1331 if isinstance(exc, CroniterError): 

1332 raise 

1333 trace = _traceback.format_exc() 

1334 raise CroniterBadCronError(trace) 

1335 

1336 @classmethod 

1337 def _get_low_from_current_date_number(cls, field_index, step, from_timestamp, tzinfo=None): 

1338 # Read the start time back in its own timezone. A naive start_time was 

1339 # converted to a timestamp as if it were UTC, so UTC round-trips it to the 

1340 # same wall clock and nothing changes for that case. 

1341 dt = datetime.datetime.fromtimestamp(from_timestamp, tz=tzinfo or UTC_DT) 

1342 if field_index == MINUTE_FIELD: 

1343 return dt.minute % step 

1344 if field_index == HOUR_FIELD: 

1345 return dt.hour % step 

1346 if field_index == DAY_FIELD: 

1347 return ((dt.day - 1) % step) + 1 

1348 if field_index == MONTH_FIELD: 

1349 return ((dt.month - 1) % step) + 1 

1350 if field_index == DOW_FIELD: 

1351 return (dt.isoweekday() % 7) % step 

1352 if field_index == SECOND_FIELD: 

1353 return dt.second % step 

1354 if field_index == YEAR_FIELD: 

1355 # Like day and month, the year field does not start at 0, so the phase is 

1356 # taken from the field minimum rather than from the value itself. 

1357 year_start = cls.RANGES[YEAR_FIELD][0] 

1358 return ((dt.year - year_start) % step) + year_start 

1359 

1360 raise ValueError(f"Can't get current date number for field index {field_index}") 

1361 

1362 @classmethod 

1363 def is_valid(cls, expression, hash_id=None, encoding="UTF-8", second_at_beginning=False, strict=False, strict_year=None): 

1364 if hash_id: 

1365 if not isinstance(hash_id, (bytes, str)): 

1366 raise TypeError("hash_id must be bytes or UTF-8 string") 

1367 if not isinstance(hash_id, bytes): 

1368 hash_id = hash_id.encode(encoding) 

1369 try: 

1370 cls.expand(expression, hash_id=hash_id, second_at_beginning=second_at_beginning, strict=strict, strict_year=strict_year) 

1371 except CroniterError: 

1372 return False 

1373 return True 

1374 

1375 @classmethod 

1376 def match( 

1377 cls, 

1378 cron_expression, 

1379 testdate, 

1380 day_or=True, 

1381 second_at_beginning=False, 

1382 precision_in_seconds=None, 

1383 ): 

1384 return cls.match_range( 

1385 cron_expression, testdate, testdate, day_or, second_at_beginning, precision_in_seconds 

1386 ) 

1387 

1388 @classmethod 

1389 def match_range( 

1390 cls, 

1391 cron_expression, 

1392 from_datetime, 

1393 to_datetime, 

1394 day_or=True, 

1395 second_at_beginning=False, 

1396 precision_in_seconds=None, 

1397 ): 

1398 cron = cls( 

1399 cron_expression, 

1400 to_datetime, 

1401 ret_type=datetime.datetime, 

1402 day_or=day_or, 

1403 second_at_beginning=second_at_beginning, 

1404 ) 

1405 tdp = cron.get_current(datetime.datetime) 

1406 if not tdp.microsecond: 

1407 tdp += relativedelta(microseconds=1) 

1408 cron.set_current(tdp, force=True) 

1409 try: 

1410 tdt = cron.get_prev() 

1411 except CroniterBadDateError: 

1412 return False 

1413 if precision_in_seconds is None: 

1414 precision_in_seconds = 1 if len(cron.expanded) > UNIX_CRON_LEN else 60 

1415 duration_in_second = (to_datetime - from_datetime).total_seconds() + precision_in_seconds 

1416 return (max(tdp, tdt) - min(tdp, tdt)).total_seconds() < duration_in_second 

1417 

1418 

1419def croniter_range( 

1420 start, 

1421 stop, 

1422 expr_format, 

1423 ret_type=None, 

1424 day_or=True, 

1425 exclude_ends=False, 

1426 _croniter=None, 

1427 second_at_beginning=False, 

1428 expand_from_start_time=False, 

1429): 

1430 """ 

1431 Generator that provides all times from start to stop matching the given cron expression. 

1432 If the cron expression matches either 'start' and/or 'stop', those times will be returned as 

1433 well unless 'exclude_ends=True' is passed. 

1434 

1435 You can think of this function as sibling to the builtin range function for datetime objects. 

1436 Like range(start,stop,step), except that here 'step' is a cron expression. 

1437 """ 

1438 _croniter = _croniter or croniter 

1439 auto_rt = datetime.datetime 

1440 # type is used in first if branch for perfs reasons 

1441 if type(start) is not type(stop) and not ( 

1442 isinstance(start, type(stop)) or isinstance(stop, type(start)) 

1443 ): 

1444 raise CroniterBadTypeRangeError( 

1445 f"The start and stop must be same type. {type(start)} != {type(stop)}" 

1446 ) 

1447 if isinstance(start, (float, int)): 

1448 start, stop = ( 

1449 datetime.datetime.fromtimestamp(t, tzutc()).replace(tzinfo=None) for t in (start, stop) 

1450 ) 

1451 auto_rt = float 

1452 if ret_type is None: 

1453 ret_type = auto_rt 

1454 if not exclude_ends: 

1455 ms1 = relativedelta(microseconds=1) 

1456 if start < stop: # Forward (normal) time order 

1457 start -= ms1 

1458 stop += ms1 

1459 else: # Reverse time order 

1460 start += ms1 

1461 stop -= ms1 

1462 year_span = math.floor(abs(stop.year - start.year)) + 1 

1463 ic = _croniter( 

1464 expr_format, 

1465 start, 

1466 ret_type=datetime.datetime, 

1467 day_or=day_or, 

1468 max_years_between_matches=year_span, 

1469 second_at_beginning=second_at_beginning, 

1470 expand_from_start_time=expand_from_start_time, 

1471 ) 

1472 # define a continue (cont) condition function and step function for the main while loop 

1473 if start < stop: # Forward 

1474 

1475 def cont(v): 

1476 return v < stop 

1477 

1478 step = ic.get_next 

1479 else: # Reverse 

1480 

1481 def cont(v): 

1482 return v > stop 

1483 

1484 step = ic.get_prev 

1485 try: 

1486 dt = step() 

1487 while cont(dt): 

1488 if ret_type is float: 

1489 yield ic.get_current(float) 

1490 else: 

1491 yield dt 

1492 dt = step() 

1493 except CroniterBadDateError: 

1494 # Stop iteration when this exception is raised; no match found within the given year range 

1495 return 

1496 

1497 

1498class HashExpander: 

1499 def __init__(self, cronit): 

1500 self.cron = cronit 

1501 

1502 def do(self, idx, hash_type="h", hash_id=None, range_end=None, range_begin=None): 

1503 """Return a hashed/random integer given range/hash information""" 

1504 if range_end is None: 

1505 range_end = self.cron.RANGES[idx][1] 

1506 if range_begin is None: 

1507 range_begin = self.cron.RANGES[idx][0] 

1508 if hash_type == "r": 

1509 crc = random.randint(0, 0xFFFFFFFF) 

1510 else: 

1511 crc = binascii.crc32(hash_id) & 0xFFFFFFFF 

1512 return ((crc >> idx) % (range_end - range_begin + 1)) + range_begin 

1513 

1514 def match(self, efl, idx, expr, hash_id=None, **kw): 

1515 return hash_expression_re.match(expr) 

1516 

1517 def _expand_divisor(self, idx, m, hash_id, range_begin, range_end): 

1518 """Hash a start offset into the first period, then step to range_end. 

1519 

1520 The offset is drawn from the first period, [range_begin, range_begin + 

1521 divisor - 1], but never past range_end: a divisor wider than the range has 

1522 only the range itself to draw from. And when the offset lands on range_end 

1523 the step cannot reach a second value, so the result is that single value -- 

1524 emitting "{end}-{end}/{divisor}" instead would read as an explicit equal 

1525 range, which croniter expands to the whole field. 

1526 """ 

1527 divisor = int(m["divisor"]) 

1528 x = self.do( 

1529 idx, 

1530 hash_type=m["hash_type"], 

1531 hash_id=hash_id, 

1532 range_begin=range_begin, 

1533 range_end=min(range_begin + divisor - 1, range_end), 

1534 ) 

1535 if x == range_end: 

1536 return str(x) 

1537 return f"{x}-{range_end}/{divisor}" 

1538 

1539 def expand(self, efl, idx, expr, hash_id=None, match="", **kw): 

1540 """Expand a hashed/random expression to its normal representation""" 

1541 if match == "": 

1542 match = self.match(efl, idx, expr, hash_id, **kw) 

1543 if not match: 

1544 return expr 

1545 m = match.groupdict() 

1546 

1547 if m["hash_type"] == "h" and hash_id is None: 

1548 raise CroniterBadCronError("Hashed definitions must include hash_id") 

1549 

1550 if m["range_begin"] and m["range_end"]: 

1551 if int(m["range_begin"]) >= int(m["range_end"]): 

1552 raise CroniterBadCronError("Range end must be greater than range begin") 

1553 

1554 if m["range_begin"] and m["range_end"] and m["divisor"]: 

1555 # Example: H(30-59)/10 -> 34-59/10 (i.e. 34,44,54) 

1556 if int(m["divisor"]) == 0: 

1557 raise CroniterBadCronError(f"Bad expression: {expr}") 

1558 

1559 return self._expand_divisor( 

1560 idx, m, hash_id, int(m["range_begin"]), int(m["range_end"]) 

1561 ) 

1562 if m["range_begin"] and m["range_end"]: 

1563 # Example: H(0-29) -> 12 

1564 return str( 

1565 self.do( 

1566 idx, 

1567 hash_type=m["hash_type"], 

1568 hash_id=hash_id, 

1569 range_end=int(m["range_end"]), 

1570 range_begin=int(m["range_begin"]), 

1571 ) 

1572 ) 

1573 if m["divisor"]: 

1574 # Example: H/15 -> 7-59/15 (i.e. 7,22,37,52) 

1575 if int(m["divisor"]) == 0: 

1576 raise CroniterBadCronError(f"Bad expression: {expr}") 

1577 

1578 return self._expand_divisor( 

1579 idx, m, hash_id, self.cron.RANGES[idx][0], self.cron.RANGES[idx][1] 

1580 ) 

1581 

1582 # Example: H -> 32 

1583 return str(self.do(idx, hash_type=m["hash_type"], hash_id=hash_id)) 

1584 

1585 

1586EXPANDERS = {"hash": HashExpander}