"""Sequential foreground polling with injectable time and output.""" import sys import time from collections.abc import Callable from datetime import UTC, datetime from typing import TextIO from production_analytics.calculations.config import finite_number from production_analytics.enlyze.exploration import ConfigurationError, ExplorationError from production_analytics.service.material_polling import MaterialPollingService class MaterialStateError(RuntimeError): """State operation failed; message contains only the operation and error class.""" def utc_now() -> datetime: return datetime.now(UTC) class MaterialPollingRunner: def __init__( self, service: MaterialPollingService, *, machine_id: str, poll_interval_seconds: float, clock: Callable[[], datetime] = utc_now, sleep: Callable[[float], None] = time.sleep, stdout: TextIO | None = None, stderr: TextIO | None = None, ) -> None: self.service = service self.machine_id = machine_id self.interval = finite_number(poll_interval_seconds, "poll interval", positive=True) self.clock = clock self.sleep = sleep self.stdout = stdout if stdout is not None else sys.stdout self.stderr = stderr if stderr is not None else sys.stderr def run(self) -> None: try: while True: now = self.clock() if now.tzinfo is None or now.utcoffset() is None: raise ConfigurationError("Runner clock must return timezone-aware timestamps") now = now.astimezone(UTC) prefix = f"{now.isoformat()} machine={self.machine_id!r}" try: result = self.service.poll_once(now=now) except ConfigurationError: raise except Exception as exc: if isinstance(exc, MaterialStateError): detail = str(exc) elif isinstance(exc, ExplorationError): detail = f"ENLYZE/gateway: {type(exc).__name__}" else: detail = f"gateway/poll cycle: {type(exc).__name__}" # Do not echo arbitrary exception messages or response bodies. print(f"{prefix} error {detail}", file=self.stderr, flush=True) else: if result is None: detail = "no open Production Run / no eligible polling window" else: detail = ( f"production_order={result.run.production_order!r} " f"consumption_kg={result.state.cumulative_consumption_kg:.9f} " f"running_seconds={result.state.integrated_running_seconds:.3f}" ) print(f"{prefix} {detail}", file=self.stdout, flush=True) # Fixed delay after completion, including errors; never catch up or overlap. self.sleep(self.interval) except KeyboardInterrupt: print("Material polling stopped", file=self.stdout, flush=True)