cron_parser.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. """
  2. Cron Expression Parser for Trixy Scheduler System
  3. This module provides comprehensive parsing and validation of standard cron expressions
  4. with support for all standard cron syntax features including ranges, lists, steps,
  5. and special characters.
  6. Standard Cron Format:
  7. minute hour day_of_month month day_of_week
  8. Field Ranges:
  9. - minute: 0-59
  10. - hour: 0-23
  11. - day_of_month: 1-31
  12. - month: 1-12 (or JAN-DEC)
  13. - day_of_week: 0-7 (0 and 7 = Sunday, or SUN-SAT)
  14. Special Characters:
  15. - * : Any value
  16. - , : List separator (e.g., 1,3,5)
  17. - - : Range (e.g., 1-5)
  18. - / : Step values (e.g., */5 or 1-10/2)
  19. - ? : No specific value (day_of_month or day_of_week only)
  20. Examples:
  21. - "0 9 * * 1-5" : 9 AM on weekdays
  22. - "*/15 * * * *" : Every 15 minutes
  23. - "0 0 1 * *" : First day of every month at midnight
  24. - "30 14 * * 0" : 2:30 PM every Sunday
  25. - "0 */2 * * *" : Every 2 hours
  26. - "0 9-17 * * 1-5" : Every hour from 9 AM to 5 PM on weekdays
  27. Usage:
  28. from trixy_core.scheduler.cron_parser import CronParser
  29. # Parse cron expression
  30. parser = CronParser()
  31. cron_expr = parser.parse("0 9 * * 1-5")
  32. # Check if current time matches
  33. import time
  34. if cron_expr.matches(time.time()):
  35. print("Cron expression matches current time")
  36. # Get next execution time
  37. next_time = cron_expr.get_next_time()
  38. print(f"Next execution: {next_time}")
  39. """
  40. import re
  41. import time
  42. import calendar
  43. from datetime import datetime, timedelta
  44. from typing import List, Set, Optional, Union, Tuple, Dict, Any
  45. from dataclasses import dataclass
  46. from enum import Enum
  47. def pprint(message: str) -> None:
  48. """
  49. Cron parser logging function that adapts based on mode.
  50. Uses the same pattern as specified in CLAUDE.md.
  51. """
  52. print(f"[SCHEDULER.CRON] {message}")
  53. class CronParseError(Exception):
  54. """Raised when cron expression cannot be parsed."""
  55. pass
  56. class CronValidationError(Exception):
  57. """Raised when cron expression is invalid."""
  58. pass
  59. class CronFieldType(Enum):
  60. """Types of cron fields."""
  61. MINUTE = "minute"
  62. HOUR = "hour"
  63. DAY_OF_MONTH = "day_of_month"
  64. MONTH = "month"
  65. DAY_OF_WEEK = "day_of_week"
  66. @dataclass
  67. class CronFieldSpec:
  68. """Specification for a cron field."""
  69. field_type: CronFieldType
  70. min_value: int
  71. max_value: int
  72. aliases: Dict[str, int]
  73. allow_question_mark: bool = False
  74. # Cron field specifications
  75. CRON_FIELD_SPECS = {
  76. CronFieldType.MINUTE: CronFieldSpec(
  77. field_type=CronFieldType.MINUTE,
  78. min_value=0,
  79. max_value=59,
  80. aliases={}
  81. ),
  82. CronFieldType.HOUR: CronFieldSpec(
  83. field_type=CronFieldType.HOUR,
  84. min_value=0,
  85. max_value=23,
  86. aliases={}
  87. ),
  88. CronFieldType.DAY_OF_MONTH: CronFieldSpec(
  89. field_type=CronFieldType.DAY_OF_MONTH,
  90. min_value=1,
  91. max_value=31,
  92. aliases={},
  93. allow_question_mark=True
  94. ),
  95. CronFieldType.MONTH: CronFieldSpec(
  96. field_type=CronFieldType.MONTH,
  97. min_value=1,
  98. max_value=12,
  99. aliases={
  100. 'JAN': 1, 'FEB': 2, 'MAR': 3, 'APR': 4, 'MAY': 5, 'JUN': 6,
  101. 'JUL': 7, 'AUG': 8, 'SEP': 9, 'OCT': 10, 'NOV': 11, 'DEC': 12
  102. }
  103. ),
  104. CronFieldType.DAY_OF_WEEK: CronFieldSpec(
  105. field_type=CronFieldType.DAY_OF_WEEK,
  106. min_value=0,
  107. max_value=7, # 0 and 7 both represent Sunday
  108. aliases={
  109. 'SUN': 0, 'MON': 1, 'TUE': 2, 'WED': 3, 'THU': 4, 'FRI': 5, 'SAT': 6
  110. },
  111. allow_question_mark=True
  112. )
  113. }
  114. class CronField:
  115. """
  116. Represents a single field in a cron expression.
  117. Handles parsing and validation of individual cron fields with support
  118. for wildcards, ranges, lists, steps, and aliases.
  119. """
  120. def __init__(self, field_type: CronFieldType, expression: str):
  121. """
  122. Initialize a cron field.
  123. Args:
  124. field_type: Type of the cron field
  125. expression: String expression for this field
  126. Raises:
  127. CronParseError: If expression cannot be parsed
  128. CronValidationError: If expression is invalid
  129. """
  130. self.field_type = field_type
  131. self.expression = expression.strip().upper()
  132. self.spec = CRON_FIELD_SPECS[field_type]
  133. # Parse the expression into a set of valid values
  134. self.values: Set[int] = set()
  135. self.is_wildcard = False
  136. self.is_question_mark = False
  137. self._parse_expression()
  138. self._validate_values()
  139. def _parse_expression(self) -> None:
  140. """Parse the field expression into valid values."""
  141. expr = self.expression
  142. # Handle wildcard
  143. if expr == '*':
  144. self.is_wildcard = True
  145. self.values = set(range(self.spec.min_value, self.spec.max_value + 1))
  146. return
  147. # Handle question mark
  148. if expr == '?':
  149. if not self.spec.allow_question_mark:
  150. raise CronParseError(f"Question mark not allowed in {self.field_type.value} field")
  151. self.is_question_mark = True
  152. return
  153. # Split by commas for list values
  154. parts = expr.split(',')
  155. for part in parts:
  156. part = part.strip()
  157. if not part:
  158. continue
  159. # Check for step values (/)
  160. if '/' in part:
  161. range_part, step_part = part.split('/', 1)
  162. try:
  163. step = int(step_part)
  164. if step <= 0:
  165. raise CronParseError(f"Step value must be positive: {step}")
  166. except ValueError:
  167. raise CronParseError(f"Invalid step value: {step_part}")
  168. # Parse the range part
  169. if range_part == '*':
  170. start, end = self.spec.min_value, self.spec.max_value
  171. elif '-' in range_part:
  172. start_str, end_str = range_part.split('-', 1)
  173. start = self._parse_value(start_str.strip())
  174. end = self._parse_value(end_str.strip())
  175. else:
  176. start = self._parse_value(range_part)
  177. end = self.spec.max_value
  178. # Add stepped values
  179. current = start
  180. while current <= end:
  181. if self.spec.min_value <= current <= self.spec.max_value:
  182. self.values.add(current)
  183. current += step
  184. # Check for range values (-)
  185. elif '-' in part:
  186. start_str, end_str = part.split('-', 1)
  187. start = self._parse_value(start_str.strip())
  188. end = self._parse_value(end_str.strip())
  189. if start > end:
  190. raise CronParseError(f"Invalid range: {start}-{end} (start > end)")
  191. for value in range(start, end + 1):
  192. self.values.add(value)
  193. # Single value
  194. else:
  195. value = self._parse_value(part)
  196. self.values.add(value)
  197. def _parse_value(self, value_str: str) -> int:
  198. """
  199. Parse a single value, handling aliases.
  200. Args:
  201. value_str: String representation of the value
  202. Returns:
  203. int: Parsed integer value
  204. Raises:
  205. CronParseError: If value cannot be parsed
  206. """
  207. value_str = value_str.strip().upper()
  208. # Check aliases first
  209. if value_str in self.spec.aliases:
  210. return self.spec.aliases[value_str]
  211. # Try to parse as integer
  212. try:
  213. value = int(value_str)
  214. except ValueError:
  215. raise CronParseError(f"Invalid value: {value_str}")
  216. return value
  217. def _validate_values(self) -> None:
  218. """Validate that all parsed values are within valid range."""
  219. if self.is_question_mark or self.is_wildcard:
  220. return
  221. for value in self.values:
  222. if not (self.spec.min_value <= value <= self.spec.max_value):
  223. # Special case for day_of_week: 7 is also Sunday (same as 0)
  224. if self.field_type == CronFieldType.DAY_OF_WEEK and value == 7:
  225. self.values.remove(7)
  226. self.values.add(0)
  227. else:
  228. raise CronValidationError(
  229. f"Value {value} out of range for {self.field_type.value} "
  230. f"(valid range: {self.spec.min_value}-{self.spec.max_value})"
  231. )
  232. def matches(self, value: int) -> bool:
  233. """
  234. Check if a value matches this cron field.
  235. Args:
  236. value: Value to check
  237. Returns:
  238. bool: True if value matches this field
  239. """
  240. if self.is_question_mark:
  241. return True
  242. if self.is_wildcard:
  243. return self.spec.min_value <= value <= self.spec.max_value
  244. # Special handling for day_of_week where 7 = Sunday = 0
  245. if self.field_type == CronFieldType.DAY_OF_WEEK and value == 7:
  246. value = 0
  247. return value in self.values
  248. def get_next_value(self, current_value: int) -> Optional[int]:
  249. """
  250. Get the next valid value after the current value.
  251. Args:
  252. current_value: Current value
  253. Returns:
  254. Optional[int]: Next valid value or None if no more values
  255. """
  256. if self.is_question_mark:
  257. return current_value
  258. valid_values = sorted(self.values)
  259. for value in valid_values:
  260. if value > current_value:
  261. return value
  262. # Wrap around to the first value
  263. return valid_values[0] if valid_values else None
  264. def to_dict(self) -> Dict[str, Any]:
  265. """Convert field to dictionary representation."""
  266. return {
  267. 'field_type': self.field_type.value,
  268. 'expression': self.expression,
  269. 'values': sorted(list(self.values)) if not (self.is_wildcard or self.is_question_mark) else None,
  270. 'is_wildcard': self.is_wildcard,
  271. 'is_question_mark': self.is_question_mark
  272. }
  273. def __str__(self) -> str:
  274. """String representation of the cron field."""
  275. return f"CronField({self.field_type.value}='{self.expression}')"
  276. def __repr__(self) -> str:
  277. """Detailed representation of the cron field."""
  278. return (
  279. f"CronField(type={self.field_type.value}, "
  280. f"expr='{self.expression}', "
  281. f"values={sorted(list(self.values)) if self.values else None})"
  282. )
  283. class CronExpression:
  284. """
  285. Represents a complete cron expression.
  286. Parses and validates a 5-field cron expression and provides methods
  287. for checking matches and calculating next execution times.
  288. """
  289. def __init__(self, expression: str):
  290. """
  291. Initialize a cron expression.
  292. Args:
  293. expression: Complete cron expression string
  294. Raises:
  295. CronParseError: If expression cannot be parsed
  296. CronValidationError: If expression is invalid
  297. """
  298. self.original_expression = expression.strip()
  299. self.fields: List[CronField] = []
  300. # Parse the expression
  301. self._parse_expression()
  302. # Validate the combination
  303. self._validate_expression()
  304. def _parse_expression(self) -> None:
  305. """Parse the complete cron expression."""
  306. parts = self.original_expression.split()
  307. if len(parts) != 5:
  308. raise CronParseError(
  309. f"Cron expression must have exactly 5 fields, got {len(parts)}: {self.original_expression}"
  310. )
  311. field_types = [
  312. CronFieldType.MINUTE,
  313. CronFieldType.HOUR,
  314. CronFieldType.DAY_OF_MONTH,
  315. CronFieldType.MONTH,
  316. CronFieldType.DAY_OF_WEEK
  317. ]
  318. for i, (field_type, field_expr) in enumerate(zip(field_types, parts)):
  319. try:
  320. field = CronField(field_type, field_expr)
  321. self.fields.append(field)
  322. except (CronParseError, CronValidationError) as e:
  323. raise CronParseError(f"Error in field {i+1} ({field_type.value}): {e}")
  324. def _validate_expression(self) -> None:
  325. """Validate the complete cron expression."""
  326. # Check for conflicting day specifications
  327. day_of_month_field = self.fields[2] # day_of_month
  328. day_of_week_field = self.fields[4] # day_of_week
  329. # If both day_of_month and day_of_week are specified (not * or ?),
  330. # this creates an OR condition which is valid but worth noting
  331. if (not day_of_month_field.is_wildcard and not day_of_month_field.is_question_mark and
  332. not day_of_week_field.is_wildcard and not day_of_week_field.is_question_mark):
  333. pprint(f"Warning: Both day_of_month and day_of_week specified - schedule will run when EITHER matches")
  334. def matches(self, timestamp: Optional[float] = None) -> bool:
  335. """
  336. Check if the cron expression matches a given timestamp.
  337. Args:
  338. timestamp: Unix timestamp to check (defaults to current time)
  339. Returns:
  340. bool: True if expression matches the timestamp
  341. """
  342. if timestamp is None:
  343. timestamp = time.time()
  344. dt = datetime.fromtimestamp(timestamp)
  345. # Check each field
  346. values = [
  347. dt.minute, # minute
  348. dt.hour, # hour
  349. dt.day, # day_of_month
  350. dt.month, # month
  351. dt.weekday() + 1 # day_of_week (convert from Monday=0 to Sunday=0)
  352. ]
  353. # Convert Sunday from 1 to 0 for day_of_week
  354. if values[4] == 7:
  355. values[4] = 0
  356. # Special handling for day_of_month and day_of_week
  357. # If both are specified, it's an OR condition
  358. day_of_month_matches = self.fields[2].matches(values[2])
  359. day_of_week_matches = self.fields[4].matches(values[4])
  360. # Check other fields (minute, hour, month)
  361. other_fields_match = all(
  362. field.matches(value)
  363. for i, (field, value) in enumerate(zip(self.fields, values))
  364. if i not in [2, 4] # Skip day_of_month and day_of_week
  365. )
  366. if not other_fields_match:
  367. return False
  368. # Handle day_of_month and day_of_week logic
  369. day_of_month_specified = not (self.fields[2].is_wildcard or self.fields[2].is_question_mark)
  370. day_of_week_specified = not (self.fields[4].is_wildcard or self.fields[4].is_question_mark)
  371. if day_of_month_specified and day_of_week_specified:
  372. # Both specified: OR condition
  373. return day_of_month_matches or day_of_week_matches
  374. elif day_of_month_specified:
  375. # Only day_of_month specified
  376. return day_of_month_matches
  377. elif day_of_week_specified:
  378. # Only day_of_week specified
  379. return day_of_week_matches
  380. else:
  381. # Neither specified (both * or ?)
  382. return True
  383. def get_next_time(self, after_timestamp: Optional[float] = None) -> float:
  384. """
  385. Get the next time this cron expression will match.
  386. Args:
  387. after_timestamp: Find next time after this timestamp (defaults to current time)
  388. Returns:
  389. float: Unix timestamp of next execution
  390. Raises:
  391. CronValidationError: If no valid next time can be found
  392. """
  393. if after_timestamp is None:
  394. after_timestamp = time.time()
  395. # Start from the next minute
  396. dt = datetime.fromtimestamp(after_timestamp)
  397. dt = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
  398. # Limit search to avoid infinite loops
  399. max_iterations = 366 * 24 * 60 # One year worth of minutes
  400. iterations = 0
  401. while iterations < max_iterations:
  402. if self.matches(dt.timestamp()):
  403. return dt.timestamp()
  404. # Advance to next minute
  405. dt += timedelta(minutes=1)
  406. iterations += 1
  407. raise CronValidationError("Could not find next execution time within reasonable timeframe")
  408. def get_previous_time(self, before_timestamp: Optional[float] = None) -> Optional[float]:
  409. """
  410. Get the previous time this cron expression matched.
  411. Args:
  412. before_timestamp: Find previous time before this timestamp (defaults to current time)
  413. Returns:
  414. Optional[float]: Unix timestamp of previous execution or None if not found
  415. """
  416. if before_timestamp is None:
  417. before_timestamp = time.time()
  418. # Start from the current minute
  419. dt = datetime.fromtimestamp(before_timestamp)
  420. dt = dt.replace(second=0, microsecond=0)
  421. # Limit search to avoid infinite loops
  422. max_iterations = 366 * 24 * 60 # One year worth of minutes
  423. iterations = 0
  424. while iterations < max_iterations:
  425. if self.matches(dt.timestamp()):
  426. return dt.timestamp()
  427. # Go back one minute
  428. dt -= timedelta(minutes=1)
  429. iterations += 1
  430. return None
  431. def get_description(self) -> str:
  432. """
  433. Get a human-readable description of the cron expression.
  434. Returns:
  435. str: Human-readable description
  436. """
  437. descriptions = []
  438. # Minute
  439. minute_field = self.fields[0]
  440. if minute_field.is_wildcard:
  441. descriptions.append("every minute")
  442. elif len(minute_field.values) == 1:
  443. minute = list(minute_field.values)[0]
  444. descriptions.append(f"at minute {minute}")
  445. else:
  446. minutes = sorted(minute_field.values)
  447. descriptions.append(f"at minutes {', '.join(map(str, minutes))}")
  448. # Hour
  449. hour_field = self.fields[1]
  450. if not hour_field.is_wildcard:
  451. if len(hour_field.values) == 1:
  452. hour = list(hour_field.values)[0]
  453. descriptions.append(f"at {hour:02d}:00")
  454. else:
  455. hours = sorted(hour_field.values)
  456. descriptions.append(f"during hours {', '.join(map(str, hours))}")
  457. # Day descriptions
  458. day_descriptions = []
  459. # Day of month
  460. dom_field = self.fields[2]
  461. if not (dom_field.is_wildcard or dom_field.is_question_mark):
  462. if len(dom_field.values) == 1:
  463. day = list(dom_field.values)[0]
  464. day_descriptions.append(f"on day {day} of the month")
  465. else:
  466. days = sorted(dom_field.values)
  467. day_descriptions.append(f"on days {', '.join(map(str, days))} of the month")
  468. # Day of week
  469. dow_field = self.fields[4]
  470. if not (dow_field.is_wildcard or dow_field.is_question_mark):
  471. day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
  472. if len(dow_field.values) == 1:
  473. day = list(dow_field.values)[0]
  474. day_descriptions.append(f"on {day_names[day]}")
  475. else:
  476. days = sorted(dow_field.values)
  477. day_descriptions.append(f"on {', '.join(day_names[d] for d in days)}")
  478. if day_descriptions:
  479. if len(day_descriptions) == 2:
  480. descriptions.append(f"({' OR '.join(day_descriptions)})")
  481. else:
  482. descriptions.extend(day_descriptions)
  483. # Month
  484. month_field = self.fields[3]
  485. if not month_field.is_wildcard:
  486. month_names = ['', 'January', 'February', 'March', 'April', 'May', 'June',
  487. 'July', 'August', 'September', 'October', 'November', 'December']
  488. if len(month_field.values) == 1:
  489. month = list(month_field.values)[0]
  490. descriptions.append(f"in {month_names[month]}")
  491. else:
  492. months = sorted(month_field.values)
  493. descriptions.append(f"in {', '.join(month_names[m] for m in months)}")
  494. return ' '.join(descriptions)
  495. def to_dict(self) -> Dict[str, Any]:
  496. """Convert cron expression to dictionary representation."""
  497. return {
  498. 'expression': self.original_expression,
  499. 'description': self.get_description(),
  500. 'fields': [field.to_dict() for field in self.fields]
  501. }
  502. def __str__(self) -> str:
  503. """String representation of the cron expression."""
  504. return self.original_expression
  505. def __repr__(self) -> str:
  506. """Detailed representation of the cron expression."""
  507. return f"CronExpression('{self.original_expression}')"
  508. class CronParser:
  509. """
  510. Main cron parser class for parsing and validating cron expressions.
  511. Provides static methods for common cron parsing operations and maintains
  512. a cache of parsed expressions for performance.
  513. """
  514. def __init__(self, cache_size: int = 100):
  515. """
  516. Initialize the cron parser.
  517. Args:
  518. cache_size: Maximum number of parsed expressions to cache
  519. """
  520. self._cache: Dict[str, CronExpression] = {}
  521. self._cache_size = cache_size
  522. def parse(self, expression: str) -> CronExpression:
  523. """
  524. Parse a cron expression.
  525. Args:
  526. expression: Cron expression string to parse
  527. Returns:
  528. CronExpression: Parsed cron expression
  529. Raises:
  530. CronParseError: If expression cannot be parsed
  531. CronValidationError: If expression is invalid
  532. """
  533. expression = expression.strip()
  534. # Check cache first
  535. if expression in self._cache:
  536. return self._cache[expression]
  537. # Parse new expression
  538. cron_expr = CronExpression(expression)
  539. # Add to cache
  540. if len(self._cache) >= self._cache_size:
  541. # Remove oldest entry (simple FIFO)
  542. oldest_key = next(iter(self._cache))
  543. del self._cache[oldest_key]
  544. self._cache[expression] = cron_expr
  545. return cron_expr
  546. def validate(self, expression: str) -> List[str]:
  547. """
  548. Validate a cron expression without parsing.
  549. Args:
  550. expression: Cron expression string to validate
  551. Returns:
  552. List[str]: List of validation errors (empty if valid)
  553. """
  554. try:
  555. self.parse(expression)
  556. return []
  557. except (CronParseError, CronValidationError) as e:
  558. return [str(e)]
  559. def clear_cache(self) -> None:
  560. """Clear the parser cache."""
  561. self._cache.clear()
  562. # Convenience functions for direct use
  563. def parse_cron_expression(expression: str) -> CronExpression:
  564. """
  565. Parse a cron expression directly.
  566. Args:
  567. expression: Cron expression string
  568. Returns:
  569. CronExpression: Parsed cron expression
  570. """
  571. return CronExpression(expression)
  572. def validate_cron_expression(expression: str) -> bool:
  573. """
  574. Validate a cron expression.
  575. Args:
  576. expression: Cron expression string
  577. Returns:
  578. bool: True if valid, False otherwise
  579. """
  580. try:
  581. parse_cron_expression(expression)
  582. return True
  583. except (CronParseError, CronValidationError):
  584. return False
  585. def get_next_cron_time(expression: str, after_timestamp: Optional[float] = None) -> float:
  586. """
  587. Get the next execution time for a cron expression.
  588. Args:
  589. expression: Cron expression string
  590. after_timestamp: Find next time after this timestamp (defaults to current time)
  591. Returns:
  592. float: Unix timestamp of next execution
  593. """
  594. cron_expr = parse_cron_expression(expression)
  595. return cron_expr.get_next_time(after_timestamp)
  596. def cron_matches_time(expression: str, timestamp: Optional[float] = None) -> bool:
  597. """
  598. Check if a cron expression matches a given time.
  599. Args:
  600. expression: Cron expression string
  601. timestamp: Unix timestamp to check (defaults to current time)
  602. Returns:
  603. bool: True if expression matches the timestamp
  604. """
  605. cron_expr = parse_cron_expression(expression)
  606. return cron_expr.matches(timestamp)
  607. # Module exports
  608. __all__ = [
  609. 'CronParser',
  610. 'CronExpression',
  611. 'CronField',
  612. 'CronParseError',
  613. 'CronValidationError',
  614. 'CronFieldType',
  615. 'parse_cron_expression',
  616. 'validate_cron_expression',
  617. 'get_next_cron_time',
  618. 'cron_matches_time',
  619. 'pprint'
  620. ]