Files
iklim-sandbox/nowcast_common.py
T
mozdemir 0958d26e9f 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.
2026-10-23 19:11:34 +03:00

168 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 ApiError, ConfigurationError, IklimClient
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()
def fetch_account_id(client: IklimClient) -> str:
"""Return the caller's account id from /v1/users/me."""
response = client.request_json("GET", "/v1/users/me")
data = response.data
account = data.get("account") if isinstance(data, dict) else None
account_id = account.get("id") if isinstance(account, dict) else None
if not as_uuid(account_id):
raise ApiError("/v1/users/me yanıtında geçerli account.id bulunamadı.")
return account_id