#Standard Libary Imports: import json import os import time from typing import Callable, Any, List, Dict #3rd Party Imports: import schedule # File where all jobs are persisted JOBS_FILE = "scheduling\\jobs.json" # Ensure directory exists os.makedirs(os.path.dirname(JOBS_FILE), exist_ok=True) # Registry of functions that can be scheduled FUNCTION_MAP: Dict[str, Callable] = {} # ------------------------------- # Function Registration # ------------------------------- def register_function(name: str, func: Callable): """ Register a function so it can be called by name later. Example: register_function("say_hello", say_hello) """ FUNCTION_MAP[name] = func # ------------------------------- # Persistence Helpers # ------------------------------- def load_jobs() -> List[Dict[str, Any]]: """Load jobs from the JSON file, or return [] if none exist.""" if not os.path.exists(JOBS_FILE): return [] with open(JOBS_FILE, "r") as f: return json.load(f) def _atomic_save(path: str, data: Any): """Write JSON atomically to avoid partial writes.""" tmp = f"{path}.tmp" with open(tmp, "w") as f: json.dump(data, f, indent=4) os.replace(tmp, path) def save_jobs(jobs: List[Dict[str, Any]]): """Save jobs to the JSON file (overwrite).""" _atomic_save(JOBS_FILE, jobs) # ------------------------------- # Uniqueness Helpers # ------------------------------- def job_in_store(job_id: str) -> bool: """Check if a job id exists in the persisted JSON file.""" return any(j.get("id") == job_id for j in load_jobs()) def job_in_scheduler(job_id: str) -> bool: """ Check if a job with this tag exists in the in-memory scheduler. Uses schedule.get_jobs(tag=...) if available, otherwise scans tags. """ try: jobs = schedule.get_jobs(tag=job_id) # schedule >= 1.2.0 return len(jobs) > 0 except TypeError: # Fallback for older versions return any(job_id in getattr(j, "tags", set()) for j in schedule.jobs) def ensure_unique(job_id: str, on_conflict: str = "skip") -> bool: """ Ensure the job_id is unique across persistence and in-memory schedule. on_conflict: - "error": raise ValueError if exists. - "skip" : print and return False. - "replace": remove existing (in-memory + JSON), then continue. """ exists = job_in_store(job_id) or job_in_scheduler(job_id) if not exists: return True if on_conflict == "error": raise ValueError(f"Job id '{job_id}' already exists.") elif on_conflict == "skip": print(f"[INFO] Job '{job_id}' already exists. Skipping creation.") return False elif on_conflict == "replace": # Clear from scheduler schedule.clear(job_id) # Remove from persistence jobs = [j for j in load_jobs() if j.get("id") != job_id] save_jobs(jobs) return True else: raise ValueError(f"Unsupported on_conflict policy: {on_conflict}") # ------------------------------- # Internal scheduling (no persistence) # ------------------------------- def _schedule_once(job_id: str, func_name: str, run_at_timestamp: float, args=None, kwargs=None): args = args or [] kwargs = kwargs or {} def job_wrapper(): """Executes the job once, then removes it.""" if func_name not in FUNCTION_MAP: print(f"[ERROR] Function '{func_name}' is not registered.") return FUNCTION_MAP[func_name](*args, **kwargs) # Remove from persistence jobs = load_jobs() jobs = [j for j in jobs if j["id"] != job_id] save_jobs(jobs) # Clear from in-memory schedule schedule.clear(job_id) delay_seconds = run_at_timestamp - time.time() if delay_seconds <= 0: print(f"[WARN] Job {job_id} scheduled in the past. Skipping.") return # Schedule via schedule library schedule.every(int(delay_seconds)).seconds.do(job_wrapper).tag(job_id) def _schedule_recurring(job_id: str, func_name: str, interval: int, unit: str, args=None, kwargs=None): args = args or [] kwargs = kwargs or {} def job_wrapper(): if func_name not in FUNCTION_MAP: print(f"[ERROR] Function '{func_name}' is not registered.") return FUNCTION_MAP[func_name](*args, **kwargs) if unit == "seconds": schedule.every(interval).seconds.do(job_wrapper).tag(job_id) elif unit == "minutes": schedule.every(interval).minutes.do(job_wrapper).tag(job_id) elif unit == "hours": schedule.every(interval).hours.do(job_wrapper).tag(job_id) elif unit == "days": schedule.every(interval).days.do(job_wrapper).tag(job_id) else: raise ValueError(f"Unsupported unit: {unit}") # ------------------------------- # Public APIs (with uniqueness + persistence) # ------------------------------- def run_once_job( job_id: str, func_name: str, run_at_timestamp: float, args=None, kwargs=None, *, replace: bool = False, persist: bool = True, ): """ Schedule a job to run once at a specific timestamp. replace: if True, replace existing job with same id; otherwise print and skip. persist: if False, do not write to JSON (used by reload_jobs()). """ if persist: policy = "replace" if replace else "skip" if not ensure_unique(job_id, on_conflict=policy): return else: if job_in_scheduler(job_id): schedule.clear(job_id) _schedule_once(job_id, func_name, run_at_timestamp, args, kwargs) if persist: jobs = [j for j in load_jobs() if j["id"] != job_id] jobs.append({ "id": job_id, "type": "once", "run_at": run_at_timestamp, "function": func_name, "args": args or [], "kwargs": kwargs or {} }) save_jobs(jobs) def recurring_job( job_id: str, func_name: str, interval: int, unit: str, args=None, kwargs=None, *, replace: bool = False, persist: bool = True, ): """ Schedule a recurring job. replace: if True, replace existing job with same id; otherwise print and skip. persist: if False, do not write to JSON (used by reload_jobs()). """ if persist: policy = "replace" if replace else "skip" if not ensure_unique(job_id, on_conflict=policy): return else: if job_in_scheduler(job_id): schedule.clear(job_id) _schedule_recurring(job_id, func_name, interval, unit, args, kwargs) if persist: jobs = [j for j in load_jobs() if j["id"] != job_id] jobs.append({ "id": job_id, "type": "recurring", "interval": interval, "unit": unit, "function": func_name, "args": args or [], "kwargs": kwargs or {} }) save_jobs(jobs) def find_and_prioritize_jobs_by_pid(pid_substring: str, new_delay_seconds: float = 1.0): """ Find all jobs whose ID contains the given PID substring and reschedule them to run sooner. """ jobs = load_jobs() matched_jobs = [job for job in jobs if pid_substring in job.get("id", "")] if not matched_jobs: print(f"[INFO] No jobs found containing PID substring '{pid_substring}'.") return print(f"[INFO] Found {len(matched_jobs)} job(s) containing '{pid_substring}':") for job in matched_jobs: job_id = job["id"] print(f" - Prioritizing job: {job_id}") # Clear existing job from scheduler schedule.clear(job_id) # Reschedule based on job type if job["type"] == "once": run_once_job( job_id, job["function"], time.time() + new_delay_seconds, job.get("args"), job.get("kwargs"), replace=True, persist=True ) elif job["type"] == "recurring": recurring_job( job_id, job["function"], job["interval"], job["unit"], job.get("args"), job.get("kwargs"), replace=True, persist=True ) else: print(f"[WARN] Unknown job type for job '{job_id}'") # ------------------------------- # Reload Saved Jobs # ------------------------------- def reload_jobs(): """Reload jobs from JSON and reschedule them (no re-persist).""" jobs = load_jobs() for job in jobs: if job["type"] == "once": if job["run_at"] > time.time(): run_once_job( job["id"], job["function"], job["run_at"], job.get("args"), job.get("kwargs"), persist=False ) elif job["type"] == "recurring": recurring_job( job["id"], job["function"], job["interval"], job["unit"], job.get("args"), job.get("kwargs"), persist=False ) # ------------------------------- # Scheduler Loop # ------------------------------- def start_scheduler(): """ Start the scheduler loop (blocking). Call this once in main to begin. """ try: while True: schedule.run_pending() time.sleep(0.5) except KeyboardInterrupt: print("[INFO] Scheduler stopped.")