"""Reports when a scrape last ran and when cron will next run it. "Last ran" is read from the mtime of the scraper's output file - true regardless of how/when the scrape was actually invoked. "Next run" requires telling this module the cron schedule via an env var (there's no reliable, portable way to introspect another process's crontab from here), given as a standard 5-field cron expression, e.g. "0 * * * *". """ from datetime import datetime from pathlib import Path from typing import Optional from croniter import croniter def _now_local() -> datetime: return datetime.now().astimezone() def _file_mtime_local(path: Path) -> Optional[datetime]: try: return datetime.fromtimestamp(path.stat().st_mtime).astimezone() except FileNotFoundError: return None def _next_cron_run(cron_expr: str, after: datetime) -> Optional[datetime]: try: naive_after = after.replace(tzinfo=None) return croniter(cron_expr, naive_after).get_next(datetime).astimezone() except (ValueError, KeyError): return None def get_schedule_info(path: Path, cron_expr: Optional[str]) -> dict: """Build the last/next-scrape block for one scraper output file. ``cron_expr`` is that scrape's cron expression (e.g. "0 * * * *"); None/invalid -> next-run fields are null. """ now = _now_local() cron_expr = (cron_expr or "").strip() or None last_scrape_at = _file_mtime_local(path) next_scrape_at = _next_cron_run(cron_expr, now) if cron_expr else None return { "cron_schedule": cron_expr, "last_scrape_at": last_scrape_at.isoformat() if last_scrape_at else None, "seconds_since_last_scrape": ( round((now - last_scrape_at).total_seconds()) if last_scrape_at else None ), "next_scrape_at": next_scrape_at.isoformat() if next_scrape_at else None, "seconds_until_next_scrape": ( round((next_scrape_at - now).total_seconds()) if next_scrape_at else None ), }