Implemented the full clean rewrite for Gitea
This commit is contained in:
3
src/blackout_notifier/__init__.py
Normal file
3
src/blackout_notifier/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Planned electricity outage notifier."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
4
src/blackout_notifier/__main__.py
Normal file
4
src/blackout_notifier/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from blackout_notifier.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
68
src/blackout_notifier/cli.py
Normal file
68
src/blackout_notifier/cli.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from blackout_notifier.clients import EitaaClient, SaapaClient
|
||||
from blackout_notifier.config import Config, ConfigurationError
|
||||
from blackout_notifier.models import tehran_now
|
||||
from blackout_notifier.service import OutageService
|
||||
from blackout_notifier.state import StateError, StateRepository
|
||||
|
||||
|
||||
class ConsoleNotifier:
|
||||
def send(self, text: str) -> None:
|
||||
print("\n--- notification preview ---")
|
||||
print(text)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Check and announce planned power outages")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
check_parser = subparsers.add_parser("check", help="fetch and process planned outages")
|
||||
check_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="print notifications without sending them or changing state",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = build_parser().parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
|
||||
try:
|
||||
config = Config.from_env()
|
||||
state_repository = StateRepository(config.state_file)
|
||||
notifier = (
|
||||
ConsoleNotifier()
|
||||
if arguments.dry_run
|
||||
else EitaaClient(
|
||||
config.eitaayar_token,
|
||||
config.chat_id,
|
||||
timeout=config.request_timeout_seconds,
|
||||
)
|
||||
)
|
||||
service = OutageService(
|
||||
bill_ids=config.bill_ids,
|
||||
lookahead_days=config.lookahead_days,
|
||||
saapa=SaapaClient(
|
||||
config.bargheman_token,
|
||||
timeout=config.request_timeout_seconds,
|
||||
max_events_per_bill=config.max_events_per_bill,
|
||||
),
|
||||
notifier=notifier,
|
||||
state_repository=state_repository,
|
||||
)
|
||||
result = service.run(tehran_now(), persist=not arguments.dry_run)
|
||||
except (ConfigurationError, StateError) as error:
|
||||
print(f"Configuration/state error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(
|
||||
f"Check complete: sent={result.sent}, state_changed={result.state_changed}, "
|
||||
f"errors={len(result.errors)}"
|
||||
)
|
||||
return 1 if result.errors else 0
|
||||
136
src/blackout_notifier/clients.py
Normal file
136
src/blackout_notifier/clients.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from blackout_notifier.models import Outage, OutageDataError
|
||||
|
||||
SAAPA_URL = "https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport"
|
||||
EITAAYAR_BASE_URL = "https://eitaayar.ir/api"
|
||||
|
||||
|
||||
class ExternalServiceError(RuntimeError):
|
||||
"""Raised when an external API fails or returns unsafe data."""
|
||||
|
||||
|
||||
class Notifier(Protocol):
|
||||
def send(self, text: str) -> None: ...
|
||||
|
||||
|
||||
class SaapaClient:
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
timeout: float,
|
||||
max_events_per_bill: int,
|
||||
session: requests.Session | None = None,
|
||||
) -> None:
|
||||
self._token = token
|
||||
self._timeout = timeout
|
||||
self._max_events_per_bill = max_events_per_bill
|
||||
self._session = session or _retrying_session({"POST"})
|
||||
|
||||
def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]:
|
||||
try:
|
||||
response = self._session.post(
|
||||
SAAPA_URL,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
},
|
||||
json={"bill_id": bill_id, "from_date": from_date, "to_date": to_date},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
raise ExternalServiceError(
|
||||
f"SAAPA request failed for bill {bill_id}: {error}"
|
||||
) from error
|
||||
|
||||
if not response.ok:
|
||||
raise ExternalServiceError(
|
||||
f"SAAPA returned HTTP {response.status_code} for bill {bill_id}"
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except requests.JSONDecodeError as error:
|
||||
raise ExternalServiceError(f"SAAPA returned invalid JSON for bill {bill_id}") from error
|
||||
if not isinstance(body, dict) or not isinstance(body.get("data"), list):
|
||||
raise ExternalServiceError(f"SAAPA response schema is invalid for bill {bill_id}")
|
||||
|
||||
raw_events = body["data"]
|
||||
if len(raw_events) > self._max_events_per_bill:
|
||||
raise ExternalServiceError(
|
||||
f"SAAPA returned {len(raw_events)} events for bill {bill_id}; "
|
||||
f"limit is {self._max_events_per_bill}"
|
||||
)
|
||||
|
||||
events: list[Outage] = []
|
||||
seen_numbers: set[str] = set()
|
||||
try:
|
||||
for raw_event in raw_events:
|
||||
event = Outage.from_api(raw_event)
|
||||
if event.outage_number in seen_numbers:
|
||||
raise OutageDataError(
|
||||
f"duplicate outage_number {event.outage_number} in one snapshot"
|
||||
)
|
||||
seen_numbers.add(event.outage_number)
|
||||
events.append(event)
|
||||
except OutageDataError as error:
|
||||
raise ExternalServiceError(f"Invalid outage for bill {bill_id}: {error}") from error
|
||||
return events
|
||||
|
||||
|
||||
class EitaaClient:
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
chat_id: str,
|
||||
*,
|
||||
timeout: float,
|
||||
session: requests.Session | None = None,
|
||||
) -> None:
|
||||
self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage"
|
||||
self._chat_id = chat_id
|
||||
self._timeout = timeout
|
||||
self._session = session or _retrying_session({"GET"})
|
||||
|
||||
def send(self, text: str) -> None:
|
||||
try:
|
||||
response = self._session.get(
|
||||
self._url,
|
||||
params={"chat_id": self._chat_id, "text": text},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
raise ExternalServiceError(f"Eitaa request failed: {error}") from error
|
||||
if not response.ok:
|
||||
raise ExternalServiceError(f"Eitaa returned HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except requests.JSONDecodeError as error:
|
||||
raise ExternalServiceError("Eitaa returned invalid JSON") from error
|
||||
if not isinstance(body, dict) or body.get("ok") is not True:
|
||||
description = body.get("description") if isinstance(body, dict) else None
|
||||
raise ExternalServiceError(
|
||||
f"Eitaa rejected the message: {description or 'unknown error'}"
|
||||
)
|
||||
|
||||
|
||||
def _retrying_session(methods: set[str]) -> requests.Session:
|
||||
retry = Retry(
|
||||
total=3,
|
||||
connect=3,
|
||||
read=3,
|
||||
status=3,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset(methods),
|
||||
raise_on_status=False,
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
return session
|
||||
85
src/blackout_notifier/config.py
Normal file
85
src/blackout_notifier/config.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
"""Raised when application configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Config:
|
||||
bargheman_token: str
|
||||
eitaayar_token: str
|
||||
chat_id: str
|
||||
bill_ids: tuple[str, ...]
|
||||
state_file: Path
|
||||
lookahead_days: int = 5
|
||||
request_timeout_seconds: float = 15.0
|
||||
max_events_per_bill: int = 25
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
load_dotenv(override=False)
|
||||
|
||||
values = {
|
||||
"BARGHEMAN_TOKEN": os.getenv("BARGHEMAN_TOKEN", "").strip(),
|
||||
"EITAAYAR_TOKEN": os.getenv("EITAAYAR_TOKEN", "").strip(),
|
||||
"CHAT_ID": os.getenv("CHAT_ID", "").strip(),
|
||||
}
|
||||
missing = [name for name, value in values.items() if not value]
|
||||
|
||||
raw_bill_ids = os.getenv("BILL_IDS", "")
|
||||
bill_ids = tuple(
|
||||
dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip())
|
||||
)
|
||||
if not bill_ids:
|
||||
missing.append("BILL_IDS")
|
||||
elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]:
|
||||
raise ConfigurationError(
|
||||
f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}"
|
||||
)
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(
|
||||
f"Missing required environment variables: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
bargheman_token=values["BARGHEMAN_TOKEN"],
|
||||
eitaayar_token=values["EITAAYAR_TOKEN"],
|
||||
chat_id=values["CHAT_ID"],
|
||||
bill_ids=bill_ids,
|
||||
state_file=Path(os.getenv("STATE_FILE", "state/outages.json").strip()),
|
||||
lookahead_days=_integer_setting("LOOKAHEAD_DAYS", 5, minimum=1, maximum=30),
|
||||
request_timeout_seconds=_float_setting(
|
||||
"REQUEST_TIMEOUT_SECONDS", 15.0, minimum=1.0, maximum=120.0
|
||||
),
|
||||
max_events_per_bill=_integer_setting("MAX_EVENTS_PER_BILL", 25, minimum=1, maximum=100),
|
||||
)
|
||||
|
||||
|
||||
def _integer_setting(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||
raw_value = os.getenv(name, str(default)).strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError as error:
|
||||
raise ConfigurationError(f"{name} must be an integer") from error
|
||||
if not minimum <= value <= maximum:
|
||||
raise ConfigurationError(f"{name} must be between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _float_setting(name: str, default: float, *, minimum: float, maximum: float) -> float:
|
||||
raw_value = os.getenv(name, str(default)).strip()
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except ValueError as error:
|
||||
raise ConfigurationError(f"{name} must be a number") from error
|
||||
if not minimum <= value <= maximum:
|
||||
raise ConfigurationError(f"{name} must be between {minimum:g} and {maximum:g}")
|
||||
return value
|
||||
85
src/blackout_notifier/messages.py
Normal file
85
src/blackout_notifier/messages.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import jdatetime
|
||||
|
||||
from blackout_notifier.models import TEHRAN, Outage
|
||||
|
||||
RTL = "\u200f"
|
||||
|
||||
|
||||
def new_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
|
||||
return _join(
|
||||
"⚡ اطلاعیه خاموشی جدید",
|
||||
*_outage_lines(bill_id, outage),
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def updated_outage_message(bill_id: str, previous: Outage, current: Outage, now: datetime) -> str:
|
||||
labels = {
|
||||
"outage_date": "تاریخ",
|
||||
"start_time": "ساعت شروع",
|
||||
"stop_time": "ساعت پایان",
|
||||
"address": "محل",
|
||||
"reason": "دلیل",
|
||||
"is_planned": "وضعیت برنامهریزی",
|
||||
}
|
||||
changes = []
|
||||
for field, label in labels.items():
|
||||
old_value = _display_value(getattr(previous, field))
|
||||
new_value = _display_value(getattr(current, field))
|
||||
if old_value != new_value:
|
||||
changes.append(f"✏️ {label}: {old_value} ← {new_value}")
|
||||
return _join(
|
||||
"🔄 تغییر در برنامه خاموشی",
|
||||
f"🆔 شناسه قبض: {bill_id}",
|
||||
f"🔖 شماره خاموشی: {current.outage_number}",
|
||||
*changes,
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def cancelled_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
|
||||
return _join(
|
||||
"✅ لغو احتمالی برنامه خاموشی",
|
||||
"این خاموشی در دو بررسی پیاپی مشاهده نشد.",
|
||||
*_outage_lines(bill_id, outage),
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def reactivated_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
|
||||
return _join(
|
||||
"⚠️ بازگشت برنامه خاموشی",
|
||||
*_outage_lines(bill_id, outage),
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def _outage_lines(bill_id: str, outage: Outage) -> tuple[str, ...]:
|
||||
return (
|
||||
f"🆔 شناسه قبض: {bill_id}",
|
||||
f"🔖 شماره خاموشی: {outage.outage_number}",
|
||||
f"📅 تاریخ: {outage.outage_date}",
|
||||
f"⏰ ساعت: {outage.start_time} تا {outage.stop_time}",
|
||||
f"📍 محل: {outage.address or 'نامشخص'}",
|
||||
f"📝 دلیل: {outage.reason or 'اعلام نشده'}",
|
||||
)
|
||||
|
||||
|
||||
def _sent_at(now: datetime) -> str:
|
||||
local_now = now.astimezone(TEHRAN)
|
||||
jalali_now = jdatetime.datetime.fromgregorian(datetime=local_now.replace(tzinfo=None))
|
||||
return f"📥 ارسال در {jalali_now.strftime('%Y/%m/%d %H:%M:%S')}"
|
||||
|
||||
|
||||
def _display_value(value: str | bool) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "برنامهریزیشده" if value else "برنامهریزینشده"
|
||||
return value or "نامشخص"
|
||||
|
||||
|
||||
def _join(*lines: str) -> str:
|
||||
return f"{RTL}\n{RTL}".join(lines)
|
||||
125
src/blackout_notifier/models.py
Normal file
125
src/blackout_notifier/models.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import jdatetime
|
||||
|
||||
TEHRAN = ZoneInfo("Asia/Tehran")
|
||||
|
||||
|
||||
class OutageDataError(ValueError):
|
||||
"""Raised when an outage payload cannot be normalized safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Outage:
|
||||
outage_number: str
|
||||
outage_date: str
|
||||
start_time: str
|
||||
stop_time: str
|
||||
address: str
|
||||
reason: str
|
||||
is_planned: bool
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: Any) -> Outage:
|
||||
if not isinstance(payload, dict):
|
||||
raise OutageDataError("outage entry must be an object")
|
||||
|
||||
outage_number = _required_text(payload, "outage_number")
|
||||
outage_date = _required_text(payload, "outage_date")
|
||||
start_time = _required_text(payload, "outage_start_time")
|
||||
stop_time = _required_text(payload, "outage_stop_time")
|
||||
address = str(payload.get("outage_address") or payload.get("address") or "").strip()
|
||||
reason = str(payload.get("reason_outage") or "").strip()
|
||||
is_planned = payload.get("is_planned", True)
|
||||
if not isinstance(is_planned, bool):
|
||||
raise OutageDataError("is_planned must be a boolean")
|
||||
|
||||
outage = cls(
|
||||
outage_number=outage_number,
|
||||
outage_date=outage_date,
|
||||
start_time=start_time,
|
||||
stop_time=stop_time,
|
||||
address=address,
|
||||
reason=reason,
|
||||
is_planned=is_planned,
|
||||
)
|
||||
outage.start_datetime()
|
||||
outage.stop_datetime()
|
||||
return outage
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Any) -> Outage:
|
||||
if not isinstance(payload, dict):
|
||||
raise OutageDataError("stored outage snapshot must be an object")
|
||||
try:
|
||||
outage = cls(
|
||||
outage_number=str(payload["outage_number"]),
|
||||
outage_date=str(payload["outage_date"]),
|
||||
start_time=str(payload["start_time"]),
|
||||
stop_time=str(payload["stop_time"]),
|
||||
address=str(payload["address"]),
|
||||
reason=str(payload["reason"]),
|
||||
is_planned=payload["is_planned"],
|
||||
)
|
||||
except (KeyError, TypeError) as error:
|
||||
raise OutageDataError("stored outage snapshot has an invalid schema") from error
|
||||
if not isinstance(outage.is_planned, bool):
|
||||
raise OutageDataError("stored is_planned must be a boolean")
|
||||
outage.start_datetime()
|
||||
outage.stop_datetime()
|
||||
return outage
|
||||
|
||||
def to_dict(self) -> dict[str, str | bool]:
|
||||
return asdict(self)
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
serialized = json.dumps(
|
||||
self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
|
||||
def start_datetime(self) -> datetime:
|
||||
return _jalali_datetime(self.outage_date, self.start_time)
|
||||
|
||||
def stop_datetime(self) -> datetime:
|
||||
start = self.start_datetime()
|
||||
stop = _jalali_datetime(self.outage_date, self.stop_time)
|
||||
if stop <= start:
|
||||
stop += timedelta(days=1)
|
||||
return stop
|
||||
|
||||
|
||||
def jalali_query_window(now: datetime, lookahead_days: int) -> tuple[str, str]:
|
||||
local_now = now.astimezone(TEHRAN)
|
||||
start = jdatetime.date.fromgregorian(date=local_now.date())
|
||||
end = jdatetime.date.fromgregorian(date=local_now.date() + timedelta(days=lookahead_days))
|
||||
return start.strftime("%Y/%m/%d"), end.strftime("%Y/%m/%d")
|
||||
|
||||
|
||||
def tehran_now() -> datetime:
|
||||
return datetime.now(TEHRAN)
|
||||
|
||||
|
||||
def _required_text(payload: dict[str, Any], field: str) -> str:
|
||||
value = payload.get(field)
|
||||
if value is None or not str(value).strip():
|
||||
raise OutageDataError(f"{field} is required")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _jalali_datetime(date_text: str, time_text: str) -> datetime:
|
||||
try:
|
||||
year, month, day = (int(part) for part in date_text.split("/"))
|
||||
hour, minute = (int(part) for part in time_text.split(":"))
|
||||
gregorian_date: date = jdatetime.date(year, month, day).togregorian()
|
||||
parsed_time = time(hour, minute)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise OutageDataError(f"invalid Jalali date/time: {date_text} {time_text}") from error
|
||||
return datetime.combine(gregorian_date, parsed_time, tzinfo=TEHRAN)
|
||||
165
src/blackout_notifier/service.py
Normal file
165
src/blackout_notifier/service.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from blackout_notifier.clients import ExternalServiceError, Notifier, SaapaClient
|
||||
from blackout_notifier.messages import (
|
||||
cancelled_outage_message,
|
||||
new_outage_message,
|
||||
reactivated_outage_message,
|
||||
updated_outage_message,
|
||||
)
|
||||
from blackout_notifier.models import Outage, OutageDataError, jalali_query_window
|
||||
from blackout_notifier.state import StateRepository
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunResult:
|
||||
sent: int = 0
|
||||
state_changed: bool = False
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class OutageService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bill_ids: tuple[str, ...],
|
||||
lookahead_days: int,
|
||||
saapa: SaapaClient,
|
||||
notifier: Notifier,
|
||||
state_repository: StateRepository,
|
||||
) -> None:
|
||||
self._bill_ids = bill_ids
|
||||
self._lookahead_days = lookahead_days
|
||||
self._saapa = saapa
|
||||
self._notifier = notifier
|
||||
self._state_repository = state_repository
|
||||
|
||||
def run(self, now: datetime, *, persist: bool = True) -> RunResult:
|
||||
state = self._state_repository.load()
|
||||
result = RunResult()
|
||||
from_date, to_date = jalali_query_window(now, self._lookahead_days)
|
||||
|
||||
for bill_id in self._bill_ids:
|
||||
try:
|
||||
outages = self._saapa.fetch(bill_id, from_date, to_date)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
continue
|
||||
self._process_bill(state, bill_id, outages, now, result)
|
||||
|
||||
if self._prune_expired(state, now):
|
||||
result.state_changed = True
|
||||
if result.state_changed and persist:
|
||||
state["updated_at"] = now.isoformat()
|
||||
self._state_repository.save(state)
|
||||
return result
|
||||
|
||||
def _process_bill(
|
||||
self,
|
||||
state: dict[str, Any],
|
||||
bill_id: str,
|
||||
outages: list[Outage],
|
||||
now: datetime,
|
||||
result: RunResult,
|
||||
) -> None:
|
||||
bills = state["bills"]
|
||||
bill_state = bills.get(bill_id)
|
||||
if bill_state is None:
|
||||
bill_state = {"events": {}}
|
||||
records: dict[str, dict[str, Any]] = bill_state["events"]
|
||||
current = {
|
||||
outage.outage_number: outage for outage in outages if outage.stop_datetime() > now
|
||||
}
|
||||
|
||||
for event_id, outage in current.items():
|
||||
record = records.get(event_id)
|
||||
if record is None:
|
||||
if self._notify(new_outage_message(bill_id, outage, now), result):
|
||||
records[event_id] = _record(outage, now)
|
||||
result.state_changed = True
|
||||
continue
|
||||
|
||||
previous = Outage.from_dict(record["snapshot"])
|
||||
if record["status"] == "cancelled":
|
||||
if self._notify(reactivated_outage_message(bill_id, outage, now), result):
|
||||
records[event_id] = _record(outage, now)
|
||||
result.state_changed = True
|
||||
continue
|
||||
|
||||
if outage.fingerprint() != record["notified_hash"]:
|
||||
if self._notify(updated_outage_message(bill_id, previous, outage, now), result):
|
||||
records[event_id] = _record(outage, now)
|
||||
result.state_changed = True
|
||||
continue
|
||||
|
||||
if record["missing_checks"]:
|
||||
record["missing_checks"] = 0
|
||||
record["last_seen_at"] = now.isoformat()
|
||||
result.state_changed = True
|
||||
|
||||
for event_id, record in list(records.items()):
|
||||
if event_id in current or record["status"] != "active":
|
||||
continue
|
||||
try:
|
||||
previous = Outage.from_dict(record["snapshot"])
|
||||
except OutageDataError as error:
|
||||
result.errors.append(f"Invalid stored outage for bill {bill_id}: {error}")
|
||||
continue
|
||||
if previous.start_datetime() <= now:
|
||||
continue
|
||||
if record["missing_checks"] == 0:
|
||||
record["missing_checks"] = 1
|
||||
result.state_changed = True
|
||||
elif self._notify(cancelled_outage_message(bill_id, previous, now), result):
|
||||
record["status"] = "cancelled"
|
||||
record["missing_checks"] = 2
|
||||
record["notified_at"] = now.isoformat()
|
||||
result.state_changed = True
|
||||
|
||||
if records and bill_id not in bills:
|
||||
bills[bill_id] = bill_state
|
||||
|
||||
def _notify(self, message: str, result: RunResult) -> bool:
|
||||
try:
|
||||
self._notifier.send(message)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
return False
|
||||
result.sent += 1
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _prune_expired(state: dict[str, Any], now: datetime) -> bool:
|
||||
changed = False
|
||||
cutoff = now - timedelta(days=30)
|
||||
for bill_id, bill_state in list(state["bills"].items()):
|
||||
records = bill_state["events"]
|
||||
for event_id, record in list(records.items()):
|
||||
outage = Outage.from_dict(record["snapshot"])
|
||||
if outage.stop_datetime() < cutoff:
|
||||
del records[event_id]
|
||||
changed = True
|
||||
if not records:
|
||||
del state["bills"][bill_id]
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _record(outage: Outage, now: datetime) -> dict[str, Any]:
|
||||
return {
|
||||
"snapshot": outage.to_dict(),
|
||||
"notified_hash": outage.fingerprint(),
|
||||
"status": "active",
|
||||
"missing_checks": 0,
|
||||
"last_seen_at": now.isoformat(),
|
||||
"notified_at": now.isoformat(),
|
||||
}
|
||||
102
src/blackout_notifier/state.py
Normal file
102
src/blackout_notifier/state.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from blackout_notifier.models import Outage, OutageDataError
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class StateError(RuntimeError):
|
||||
"""Raised when persisted state is unreadable or unsafe to overwrite."""
|
||||
|
||||
|
||||
def empty_state() -> dict[str, Any]:
|
||||
return {"schema_version": SCHEMA_VERSION, "updated_at": None, "bills": {}}
|
||||
|
||||
|
||||
class StateRepository:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return empty_state()
|
||||
try:
|
||||
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise StateError(f"Cannot read state file {self.path}: {error}") from error
|
||||
self._validate(payload)
|
||||
return payload
|
||||
|
||||
def save(self, state: dict[str, Any]) -> None:
|
||||
self._validate(state)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
dir=self.path.parent,
|
||||
prefix=f".{self.path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary_file:
|
||||
temporary_path = Path(temporary_file.name)
|
||||
json.dump(state, temporary_file, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_path, self.path)
|
||||
except OSError as error:
|
||||
raise StateError(f"Cannot save state file {self.path}: {error}") from error
|
||||
finally:
|
||||
if temporary_path and temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
|
||||
@staticmethod
|
||||
def _validate(state: Any) -> None:
|
||||
if not isinstance(state, dict) or state.get("schema_version") != SCHEMA_VERSION:
|
||||
raise StateError(f"State must use schema_version {SCHEMA_VERSION}")
|
||||
if state.get("updated_at") is not None and not isinstance(state.get("updated_at"), str):
|
||||
raise StateError("State updated_at must be a string or null")
|
||||
bills = state.get("bills")
|
||||
if not isinstance(bills, dict):
|
||||
raise StateError("State bills must be an object")
|
||||
for bill_id, bill_state in bills.items():
|
||||
if not isinstance(bill_id, str) or not isinstance(bill_state, dict):
|
||||
raise StateError("Invalid bill state")
|
||||
events = bill_state.get("events")
|
||||
if not isinstance(events, dict):
|
||||
raise StateError(f"State events for bill {bill_id} must be an object")
|
||||
for event_id, record in events.items():
|
||||
_validate_record(bill_id, event_id, record)
|
||||
|
||||
|
||||
def _validate_record(bill_id: str, event_id: Any, record: Any) -> None:
|
||||
if not isinstance(event_id, str) or not isinstance(record, dict):
|
||||
raise StateError(f"Invalid event record for bill {bill_id}")
|
||||
try:
|
||||
outage = Outage.from_dict(record.get("snapshot"))
|
||||
except OutageDataError as error:
|
||||
raise StateError(
|
||||
f"Invalid snapshot for bill {bill_id}, event {event_id}: {error}"
|
||||
) from error
|
||||
if outage.outage_number != event_id:
|
||||
raise StateError(f"Event key does not match outage_number for bill {bill_id}")
|
||||
if record.get("status") not in {"active", "cancelled"}:
|
||||
raise StateError(f"Invalid event status for bill {bill_id}, event {event_id}")
|
||||
missing_checks = record.get("missing_checks")
|
||||
if (
|
||||
not isinstance(missing_checks, int)
|
||||
or isinstance(missing_checks, bool)
|
||||
or missing_checks < 0
|
||||
):
|
||||
raise StateError(f"Invalid missing_checks for bill {bill_id}, event {event_id}")
|
||||
for name in ("notified_hash", "last_seen_at", "notified_at"):
|
||||
if not isinstance(record.get(name), str):
|
||||
raise StateError(f"Invalid {name} for bill {bill_id}, event {event_id}")
|
||||
Reference in New Issue
Block a user