feat: add nowcast point alarm page client with SQLite store
Adds point_alarm.py (flattening, secret masking, SQLite upsert store, paged fetch with 403 fallback, CSV/TXT export, shared filter loader), nowcast-point-page.py CLI and unit tests. Register will read the shared filter from nowcast-point-alarm-filter.json instead of repeating it in the CSV.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common import ApiError, ConfigurationError
|
||||
from point_alarm import (
|
||||
PointStore,
|
||||
fetch_pages,
|
||||
flatten_registration,
|
||||
load_point_alarm_filter,
|
||||
mask_secrets,
|
||||
parse_polygon,
|
||||
timestamped_path,
|
||||
write_records,
|
||||
)
|
||||
|
||||
|
||||
def registration(index: int, recipient: str = "r1") -> dict:
|
||||
return {
|
||||
"registrationId": f"id-{index}",
|
||||
"recipientId": recipient,
|
||||
"boundary": {"point": {"lat": 39.9, "lng": 32.8}, "radius": 1000},
|
||||
"filter": {
|
||||
"lightning": {"type": "PULSE_IN_CLOUD", "peakCurrent": 5},
|
||||
"thunderstorm": {"intersectsCellPolygon": True, "severityThreshold": "LOW"},
|
||||
"precipitation": {"intensities": ["LIGHT", "HEAVY"]},
|
||||
},
|
||||
"webhook": {"accountId": "acc", "url": "https://x", "authentication": {"password": "p"}},
|
||||
}
|
||||
|
||||
|
||||
class FakeService:
|
||||
def __init__(self, registrations: list[dict], forbid_account: bool = False) -> None:
|
||||
self.registrations = registrations
|
||||
self.forbid_account = forbid_account
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def page(self, *, page_number, page_size, account_id=None, **_):
|
||||
self.calls.append({"page": page_number, "account": account_id})
|
||||
if self.forbid_account and account_id:
|
||||
raise ApiError("forbidden", status_code=403)
|
||||
start = page_number * page_size
|
||||
return {
|
||||
"registrations": self.registrations[start : start + page_size],
|
||||
"totalRecords": len(self.registrations),
|
||||
}
|
||||
|
||||
|
||||
class PointAlarmTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.dir = Path(self.tmp.name)
|
||||
self.store = PointStore(self.dir / "t.db")
|
||||
self.addCleanup(self.store.close)
|
||||
|
||||
def test_flatten(self) -> None:
|
||||
flat = flatten_registration(registration(1))
|
||||
self.assertEqual(flat["precipitationIntensities"], "LIGHT|HEAVY")
|
||||
self.assertEqual(flat["intersectsCellPolygon"], "true")
|
||||
self.assertIsNone(flat["speedThreshold"])
|
||||
|
||||
def test_mask(self) -> None:
|
||||
masked = mask_secrets(registration(1)["webhook"])
|
||||
self.assertEqual(masked["authentication"]["password"], "***")
|
||||
self.assertEqual(masked["url"], "https://x")
|
||||
|
||||
def test_fetch_all_pages_upserts_without_duplicates(self) -> None:
|
||||
service = FakeService([registration(i) for i in range(5)])
|
||||
summary = fetch_pages(service, self.store, account_id="acc", page_size=2)
|
||||
self.assertEqual(summary.distinct, 5)
|
||||
self.assertEqual(summary.missing, 0)
|
||||
fetch_pages(service, self.store, account_id="acc", page_size=2)
|
||||
self.assertEqual(self.store.count(), 5)
|
||||
self.assertEqual(self.store.webhooks("acc")[0]["authentication"]["password"], "***")
|
||||
|
||||
def test_single_page(self) -> None:
|
||||
service = FakeService([registration(i) for i in range(5)])
|
||||
summary = fetch_pages(service, self.store, account_id="acc", page_size=2, page_number=1)
|
||||
self.assertEqual(summary.received, 2)
|
||||
self.assertEqual(len(service.calls), 1)
|
||||
|
||||
def test_forbidden_account_retries_without_it(self) -> None:
|
||||
service = FakeService([registration(1)], forbid_account=True)
|
||||
summary = fetch_pages(service, self.store, account_id="acc")
|
||||
self.assertEqual(summary.distinct, 1)
|
||||
self.assertIsNone(service.calls[-1]["account"])
|
||||
|
||||
def test_csv_and_txt_output(self) -> None:
|
||||
fetch_pages(FakeService([registration(1)]), self.store, account_id="acc")
|
||||
records = self.store.records("acc")
|
||||
write_records(self.dir / "o.csv", records, self.store.webhooks("acc"))
|
||||
with (self.dir / "o.csv").open(encoding="utf-8-sig", newline="") as handle:
|
||||
rows = list(csv.reader(handle, delimiter=";"))
|
||||
self.assertEqual(rows[0][0], "#webhook")
|
||||
self.assertEqual(rows[1][0], "registrationId")
|
||||
self.assertEqual(rows[2][0], "id-1")
|
||||
write_records(self.dir / "o.txt", records, [])
|
||||
self.assertIn('"registrationId": "id-1"', (self.dir / "o.txt").read_text(encoding="utf-8"))
|
||||
with self.assertRaises(ConfigurationError):
|
||||
write_records(self.dir / "o.json", records, [])
|
||||
|
||||
def test_timestamped_path(self) -> None:
|
||||
path = timestamped_path(Path("a/out.csv"), datetime(2026, 10, 23, 9, 5, 7))
|
||||
self.assertEqual(path, Path("a/out-20261023-090507.csv"))
|
||||
|
||||
def test_polygon(self) -> None:
|
||||
ring = parse_polygon("0,0;1,0;1,1;0,0")
|
||||
self.assertEqual(ring[0], [0.0, 0.0])
|
||||
with self.assertRaises(ConfigurationError):
|
||||
parse_polygon("0,0;1,0;1,1")
|
||||
|
||||
def test_filter_validation(self) -> None:
|
||||
good = load_point_alarm_filter(Path("nowcast-point-alarm-filter.example.json"))
|
||||
self.assertIn("lightning", good)
|
||||
bad = self.dir / "bad.json"
|
||||
bad.write_text('{"thunderstorm": {"severityThreshold": "X"}}', encoding="utf-8")
|
||||
with self.assertRaises(ConfigurationError):
|
||||
load_point_alarm_filter(bad)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user