"""scheduler_locale.py — scheduler locale su Ufficio, sostituisce il tunnel Nave<->Ufficio (deciso con Mauro il 03/09/2026, vedi archivio/Mauro/lavoro/ lavoro-python-ecosistema/tunnel-nave-ufficio.md). Un solo processo, gira SOLO su Ufficio. Legge schedule.json direttamente dal filesystem locale (sincronizzato via Dropbox/git da Nave, ma letto qui senza nessuna richiesta cross-machine) e decide/esegue da se': nessun tunnel, nessuna busta richiesta/risposta, nessun mittente. schedule.json — schema esteso: {job_name: {cron, command, timeout_seconds}} "command" e' relativo alla root ufficio/ (dove vive questo stesso file). Ogni job dovuto viene lanciato come processo OS a se' via run_job.py (fire- and-forget, subprocess.Popen senza .wait()) — il loop di controllo cron non aspetta mai un job, per costruzione non puo' bloccarsi per colpa di un job lento o di una contesa I/O sul file di log (vedi run_job.py per il ragionamento completo). Uso: python scheduler_locale.py # loop continuo, controllo ogni 60s python scheduler_locale.py --once # un solo controllo, poi esce (test) """ import argparse import os import signal import sys import time from datetime import datetime from pathlib import Path from croniter import croniter UFFICIO_ROOT = Path(__file__).resolve().parent DEFAULT_SCHEDULE_PATH = UFFICIO_ROOT.parent / "nave" / "schedule.json" PID_FILE = UFFICIO_ROOT / "scheduler.pid" LOG_DIR = UFFICIO_ROOT / "logs" CHECK_INTERVAL_SECONDS = 60 _running = True def _handle_shutdown(signum, frame): global _running print(f"Segnale {signum} ricevuto, arresto dopo il ciclo corrente...") _running = False def _already_running() -> bool: """True SOLO se scheduler.pid esiste, il PID e' vivo, E quel PID e' davvero un'istanza di questo scheduler (la sua cmdline contiene 'scheduler_locale.py'). Non basta pid_exists(): dopo una morte esterna (es. logout forzato per inattivita' che uccide l'intera sessione) il PID nel file resta scritto e il SO puo' riassegnarlo a un altro processo qualunque — visto in produzione il 09/09/2026, PID riassegnato a 'svchost.exe'. In quel caso pid_exists() da' True e ogni rilancio esce subito silenzioso, bloccando il recovery. Qui se il PID non e' ispezionabile (AccessDenied: processo di sistema di altro utente) o la sua cmdline non nomina lo script, lo trattiamo come pid file orfano -> "non in esecuzione", si riparte.""" if not PID_FILE.exists(): return False try: pid = int(PID_FILE.read_text(encoding="utf-8").strip()) except (ValueError, OSError): return False try: import psutil except ImportError: return False try: proc = psutil.Process(pid) cmdline = " ".join(proc.cmdline()) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError): return False return "scheduler_locale.py" in cmdline def load_schedule(schedule_path: Path) -> dict: """Ritorna {job_name: {cron, command, timeout_seconds}}. Dizionario vuoto se il file non esiste ancora.""" if not schedule_path.exists(): return {} import json return json.loads(schedule_path.read_text(encoding="utf-8")) def is_due(cron: str, now: datetime) -> bool: """croniter.match verifica se 'now' (troncato al minuto) coincide con l'espressione cron — non 'e' passato da quando', proprio 'e' questo il minuto giusto'.""" return croniter.match(cron, now.replace(second=0, microsecond=0)) def dispatch_job(job_name: str, job_def: dict, python_exe: str = None): """Lancia run_job.py come processo OS indipendente, senza aspettarlo.""" command = job_def["command"] timeout_seconds = job_def.get("timeout_seconds", 3600) exe = python_exe or sys.executable run_job_script = str(UFFICIO_ROOT / "run_job.py") subprocess_args = [exe, run_job_script, job_name, command, str(timeout_seconds), str(LOG_DIR)] import subprocess subprocess.Popen( subprocess_args, cwd=str(UFFICIO_ROOT), creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, ) print(f"[{time.strftime('%H:%M:%S')}] dispatch: {job_name} ({command})") def run_loop(schedule_path: Path, once: bool = False, python_exe: str = None): dispatched_this_minute: set[tuple[str, str]] = set() last_minute_key = None print(f"scheduler_locale avviato — controllo ogni {CHECK_INTERVAL_SECONDS}s su {schedule_path}") while _running: now = datetime.now() minute_key = now.strftime("%Y-%m-%d %H:%M") if minute_key != last_minute_key: dispatched_this_minute.clear() last_minute_key = minute_key schedule = load_schedule(schedule_path) for job_name, job_def in schedule.items(): cron = job_def.get("cron") if not cron: continue key = (job_name, minute_key) if key in dispatched_this_minute: continue if is_due(cron, now): dispatch_job(job_name, job_def, python_exe=python_exe) dispatched_this_minute.add(key) if once: break time.sleep(CHECK_INTERVAL_SECONDS) def main(): parser = argparse.ArgumentParser(description="scheduler_locale — scheduler locale Ufficio") parser.add_argument("--once", action="store_true", help="un solo controllo, poi esce (per test)") parser.add_argument("--schedule", type=str, default=None, help="path a schedule.json (default: nave/schedule.json)") args = parser.parse_args() signal.signal(signal.SIGINT, _handle_shutdown) signal.signal(signal.SIGTERM, _handle_shutdown) schedule_path = Path(args.schedule) if args.schedule else DEFAULT_SCHEDULE_PATH if not args.once: if _already_running(): print("scheduler_locale risulta gia' in esecuzione (scheduler.pid valido) — esco.") return PID_FILE.write_text(str(os.getpid()), encoding="utf-8") try: run_loop(schedule_path, once=args.once) finally: if not args.once and PID_FILE.exists(): PID_FILE.unlink() print("scheduler_locale arrestato.") if __name__ == "__main__": sys.exit(main())