refactor: extract shared nowcast helpers into nowcast_common

Move webhook config loading, UUID and warning helpers and the atomic semicolon CSV writer out of geo_alarm so the upcoming point alarm clients can reuse them. Behavior is unchanged and the existing geo tests still pass.
This commit is contained in:
2026-10-23 19:08:15 +03:00
parent 91839e56a9
commit 7a875caa73
2 changed files with 173 additions and 145 deletions
+156
View File
@@ -0,0 +1,156 @@
"""Helpers shared by the nowcast alarm clients (geo and point)."""
from __future__ import annotations
import csv
import json
import os
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from uuid import UUID
from common import ConfigurationError
AUTHENTICATION_FIELDS = {
"BASIC": ("username", "password"),
"JWT_TOKEN": ("jwtToken",),
"API_KEY": ("apiKey", "asQueryParameter", "asHeader"),
"HMAC_SIGNATURE": ("clientId", "secret", "algorithm"),
}
def read_json_object(path: Path, description: str) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ConfigurationError(f"{description} dosyası bulunamadı: {path}") from exc
except json.JSONDecodeError as exc:
raise ConfigurationError(
f"{description} geçerli JSON değil ({path}:{exc.lineno})."
) from exc
if not isinstance(data, dict):
raise ConfigurationError(f"{description} kök değeri JSON nesnesi olmalıdır.")
return data
def required_config_value(data: dict[str, Any], key: str, location: str) -> Any:
value = data.get(key)
if value is None or (isinstance(value, str) and not value.strip()):
raise ConfigurationError(f"{location}.{key} eksik veya boş.")
if isinstance(value, str) and "change-me" in value.strip().lower():
raise ConfigurationError(f"{location}.{key} placeholder değeri değiştirilmemiş.")
return value
def load_webhook_config(path: Path) -> dict[str, Any]:
config = read_json_object(path, "Webhook config")
url = str(required_config_value(config, "url", "webhook"))
parsed_url = urlsplit(url)
if parsed_url.scheme != "https" or not parsed_url.netloc:
raise ConfigurationError("webhook.url geçerli bir HTTPS adresi olmalıdır.")
http_method = str(required_config_value(config, "httpMethod", "webhook")).upper()
if http_method not in {"POST", "PUT", "GET"}:
raise ConfigurationError("webhook.httpMethod POST, PUT veya GET olmalıdır.")
authentication = config.get("authentication")
if not isinstance(authentication, dict):
raise ConfigurationError("webhook.authentication JSON nesnesi olmalıdır.")
selected = str(
required_config_value(authentication, "selected", "webhook.authentication")
).upper()
if selected not in AUTHENTICATION_FIELDS:
allowed = ", ".join(AUTHENTICATION_FIELDS)
raise ConfigurationError(
f"webhook.authentication.selected şu değerlerden biri olmalıdır: {allowed}."
)
options = authentication.get("options")
if not isinstance(options, dict) or not isinstance(options.get(selected), dict):
raise ConfigurationError(
f"webhook.authentication.options.{selected} JSON nesnesi olmalıdır."
)
selected_options = options[selected]
auth_payload: dict[str, Any] = {"type": selected}
for field in AUTHENTICATION_FIELDS[selected]:
auth_payload[field] = required_config_value(
selected_options, field, f"webhook.authentication.options.{selected}"
)
payload: dict[str, Any] = {
"url": url,
"httpMethod": http_method,
"contentType": str(
required_config_value(config, "contentType", "webhook")
),
"authentication": auth_payload,
}
for optional_field in ("requestHeaders", "deliveryPolicy"):
if optional_field in config:
if not isinstance(config[optional_field], dict):
raise ConfigurationError(
f"webhook.{optional_field} JSON nesnesi olmalıdır."
)
payload[optional_field] = deepcopy(config[optional_field])
return payload
def as_uuid(value: Any, *, version: int | None = None) -> str | None:
if not isinstance(value, str):
return None
try:
parsed = UUID(value)
except ValueError:
return None
if version is not None and parsed.version != version:
return None
return value
def response_warnings(*objects: Any) -> list[str]:
warnings: list[str] = []
for data in objects:
if not isinstance(data, dict):
continue
for key in ("warning", "warnings"):
value = data.get(key)
if isinstance(value, str) and value.strip():
warnings.append(value.strip())
elif isinstance(value, list):
warnings.extend(str(item) for item in value if str(item).strip())
return warnings
def write_csv_atomic(
path: Path, rows: list[dict[str, Any]], fieldnames: list[str]
) -> None:
"""Rewrite a semicolon separated CSV through a temp file in the same directory."""
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8-sig",
newline="",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as temp_file:
temp_path = Path(temp_file.name)
writer = csv.DictWriter(
temp_file,
fieldnames=fieldnames,
delimiter=";",
extrasaction="ignore",
)
writer.writeheader()
writer.writerows(rows)
temp_file.flush()
os.fsync(temp_file.fileno())
os.replace(temp_path, path)
finally:
if temp_path and temp_path.exists():
temp_path.unlink()