Skip to content

Commit 5bc321b

Browse files
Pigbibiclaude
andauthored
refactor(notifications): deduplicate AlertMarkerStore + add pluggable channel Protocols (#121)
* refactor(notifications): extract CloudAlertMarkerStore to eliminate 4x duplicated code - New alert_marker.py: shared CloudAlertMarkerStore base class with has_alert/record_alert/_local_path/_cloud_uri + _clean_relative_key helper - strategy_plugin_{email,sms,push,telegram}.py: replace ~55-line duplicate marker store classes with thin 3-line subclasses (~200 lines removed) - All 4 classes preserved (backward compat), just inherit from base now Also removes 4 duplicate _clean_relative_key + _parse_cloud_uri helpers (now in alert_marker.py which correctly supports gs://, s3://, and az://). 373 passed, 0 failed. Co-Authored-By: Claude <noreply@anthropic.com> * feat(notifications): add pluggable NotificationChannel Protocol layer - channel.py: define SmsChannel, PushChannel, EmailChannel, ChatChannel Protocols - 5 default implementations: TwilioSmsChannel, PushoverChannel, NtfyChannel, SmtpEmailChannel, TelegramChatChannel — thin wrappers around existing functions - Users can now swap providers by implementing a Protocol and passing it as send_notification to publish_strategy_plugin_*_alerts() - Export all channels from notifications/__init__.py This completes the notification decoupling: previously Twilio/Pushover/SMTP/Telegram were hardcoded; now any provider implementing the Protocol can be dropped in. 373 passed, 0 failed. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98794ae commit 5bc321b

7 files changed

Lines changed: 383 additions & 304 deletions

File tree

src/quant_platform_kit/notifications/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
"""Notification integrations."""
22

3+
from .channel import (
4+
SmsChannel,
5+
PushChannel,
6+
EmailChannel,
7+
ChatChannel,
8+
TwilioSmsChannel,
9+
PushoverChannel,
10+
NtfyChannel,
11+
SmtpEmailChannel,
12+
TelegramChatChannel,
13+
)
314
from .email import parse_email_recipients, send_smtp_email
415
from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification
516
from .push import parse_push_recipients, send_ntfy_push, send_pushover_push, send_strategy_plugin_push
@@ -42,6 +53,15 @@
4253
)
4354

4455
__all__ = [
56+
"SmsChannel",
57+
"PushChannel",
58+
"EmailChannel",
59+
"ChatChannel",
60+
"TwilioSmsChannel",
61+
"PushoverChannel",
62+
"NtfyChannel",
63+
"SmtpEmailChannel",
64+
"TelegramChatChannel",
4565
"NotificationPublisher",
4666
"RenderedNotification",
4767
"StrategyPluginAlertChannelStores",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Shared cloud/local alert marker store — eliminates duplicate code across email/sms/push/telegram channels."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import tempfile
7+
from collections.abc import Mapping
8+
from dataclasses import dataclass
9+
from datetime import datetime, timezone
10+
from pathlib import Path
11+
from typing import Any
12+
13+
from quant_platform_kit.cloud import get_object_store
14+
15+
16+
def _clean_relative_key(key: str) -> str:
17+
"""Sanitize a string for use as a filesystem/object path segment."""
18+
parts = []
19+
for raw_part in str(key or "").replace("\\", "/").split("/"):
20+
cleaned = "".join(
21+
char if char.isalnum() or char in {"-", "_", "."} else "-"
22+
for char in raw_part.strip()
23+
).strip("-._")
24+
if cleaned:
25+
parts.append(cleaned[:100])
26+
return "/".join(parts) or "unknown"
27+
28+
29+
def _parse_cloud_uri(uri: str) -> tuple[str, str]:
30+
"""Parse a cloud storage URI (gs://, s3://, or az://) into (bucket, prefix)."""
31+
raw_uri = str(uri or "").strip()
32+
if not raw_uri.startswith("gs://") and not raw_uri.startswith("s3://") and not raw_uri.startswith("az://"):
33+
raise ValueError(f"Cloud URI must start with gs://, s3://, or az://, got: {uri!r}")
34+
remainder = raw_uri[5:]
35+
bucket_name, _, object_prefix = remainder.partition("/")
36+
if not bucket_name:
37+
raise ValueError(f"Cloud URI must include a bucket name, got: {uri!r}")
38+
return bucket_name, object_prefix.strip("/")
39+
40+
41+
@dataclass(frozen=True)
42+
class CloudAlertMarkerStore:
43+
"""Shared marker store for strategy plugin alerts.
44+
45+
Persists alert markers to either cloud ObjectStore or local filesystem.
46+
Used as base for channel-specific stores (email, sms, push, telegram).
47+
48+
Usage::
49+
50+
store = CloudAlertMarkerStore(
51+
namespace="strategy_plugin_telegram_alerts",
52+
schema_version="strategy_plugin_telegram_alert_marker.v1",
53+
cloud_prefix_uri="gs://bucket/alerts",
54+
project_id="my-project",
55+
)
56+
if not store.has_alert("some-key"):
57+
store.record_alert("some-key")
58+
"""
59+
60+
namespace: str = "strategy_plugin_alerts"
61+
schema_version: str = "strategy_plugin_alert_marker.v1"
62+
local_dir: str | Path | None = None
63+
cloud_prefix_uri: str | None = None
64+
project_id: str | None = None
65+
client_factory: Any = None
66+
67+
def _object_store(self):
68+
return get_object_store(project_id=self.project_id)
69+
70+
def has_alert(self, alert_key: str) -> bool:
71+
if self.cloud_prefix_uri and self._object_store().exists(
72+
self._cloud_uri(alert_key)
73+
):
74+
return True
75+
if self.local_dir and self._local_path(alert_key).exists():
76+
return True
77+
return False
78+
79+
def record_alert(
80+
self,
81+
alert_key: str,
82+
*,
83+
metadata: Mapping[str, Any] | None = None,
84+
) -> None:
85+
payload = {
86+
"schema_version": self.schema_version,
87+
"alert_key": str(alert_key),
88+
"recorded_at": datetime.now(timezone.utc).isoformat(),
89+
"metadata": dict(metadata or {}),
90+
}
91+
encoded = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
92+
if self.cloud_prefix_uri:
93+
self._object_store().write_text(
94+
self._cloud_uri(alert_key),
95+
encoded,
96+
content_type="application/json",
97+
)
98+
return
99+
if self.local_dir:
100+
path = self._local_path(alert_key)
101+
path.parent.mkdir(parents=True, exist_ok=True)
102+
path.write_text(encoded, encoding="utf-8")
103+
104+
def _local_path(self, alert_key: str) -> Path:
105+
root = Path(self.local_dir or tempfile.gettempdir()).expanduser()
106+
return root / self.namespace / f"{_clean_relative_key(alert_key)}.json"
107+
108+
def _cloud_uri(self, alert_key: str) -> str:
109+
bucket_name, prefix = _parse_cloud_uri(str(self.cloud_prefix_uri or ""))
110+
object_name = "/".join(
111+
part.strip("/")
112+
for part in (prefix, self.namespace, f"{_clean_relative_key(alert_key)}.json")
113+
if part and part.strip("/")
114+
)
115+
scheme = str(self.cloud_prefix_uri or "").split("://")[0] if "://" in str(self.cloud_prefix_uri or "") else "gs"
116+
return f"{scheme}://{bucket_name}/{object_name}"
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Notification channel abstraction — pluggable senders for SMS, push, email, and chat.
2+
3+
Each channel type has a Protocol defining the send signature.
4+
The default implementations wire to Twilio (SMS), Pushover/Ntfy (push),
5+
SMTP (email), and Telegram Bot API (chat).
6+
7+
To replace a provider, implement the corresponding Protocol and pass it
8+
as ``send_notification`` to the ``publish_strategy_plugin_*`` function.
9+
10+
Example (custom SMS provider)::
11+
12+
class AliyunSmsChannel:
13+
def send_sms(self, recipient: str, body: str, *, sender: str | None = None) -> bool:
14+
# call Aliyun SMS API
15+
return True
16+
17+
publish_strategy_plugin_sms_alerts(
18+
signals,
19+
sms_settings=settings,
20+
send_notification=AliyunSmsChannel().send_sms,
21+
)
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from typing import Protocol
27+
28+
29+
# ──────────────────────────────────────────────────────────────────────
30+
# Channel Protocols
31+
# ──────────────────────────────────────────────────────────────────────
32+
33+
34+
class SmsChannel(Protocol):
35+
"""Send an SMS message. Return True on success, False on failure."""
36+
37+
def send_sms(self, recipient: str, body: str, *, sender: str | None = None) -> bool:
38+
...
39+
40+
41+
class PushChannel(Protocol):
42+
"""Send a push notification. Return True on success, False on failure.
43+
44+
``target`` is provider-specific: Pushover user key, Ntfy topic, etc.
45+
``provider`` identifies the backend (e.g. "pushover", "ntfy").
46+
"""
47+
48+
def send_push(
49+
self,
50+
title: str,
51+
body: str,
52+
*,
53+
target: str,
54+
provider: str = "pushover",
55+
url: str | None = None,
56+
url_title: str | None = None,
57+
priority: str = "normal",
58+
api_base_url: str | None = None,
59+
) -> bool:
60+
...
61+
62+
63+
class EmailChannel(Protocol):
64+
"""Send an email message. Return True on success, False on failure."""
65+
66+
def send_email(
67+
self,
68+
subject: str,
69+
body: str,
70+
*,
71+
recipients: list[str],
72+
sender: str | None = None,
73+
smtp_host: str = "smtp.gmail.com",
74+
smtp_port: int = 465,
75+
security: str = "ssl",
76+
username: str | None = None,
77+
password: str | None = None,
78+
) -> bool:
79+
...
80+
81+
82+
class ChatChannel(Protocol):
83+
"""Send a message to a chat platform. Return True on success, False on failure.
84+
85+
``chat_id`` and ``token`` are specific to Telegram Bot API.
86+
For other platforms (Slack, Discord, WeChat), wrap their API
87+
in this signature.
88+
"""
89+
90+
def send_message(
91+
self,
92+
chat_id: str,
93+
text: str,
94+
*,
95+
token: str,
96+
api_base_url: str = "https://api.telegram.org",
97+
parse_mode: str = "HTML",
98+
) -> bool:
99+
...
100+
101+
102+
# ──────────────────────────────────────────────────────────────────────
103+
# Default channel implementations (thin wrappers around existing functions)
104+
# ──────────────────────────────────────────────────────────────────────
105+
106+
107+
class TwilioSmsChannel:
108+
"""Default SMS channel — wraps send_twilio_sms()."""
109+
110+
def send_sms(self, recipient: str, body: str, *, sender: str | None = None) -> bool:
111+
from .sms import send_twilio_sms
112+
return send_twilio_sms(
113+
recipient=recipient,
114+
body=body,
115+
account_sid=None,
116+
auth_token=None,
117+
sender=sender,
118+
)
119+
120+
121+
class PushoverChannel:
122+
"""Pushover push channel — wraps send_pushover_push()."""
123+
124+
def send_push(
125+
self,
126+
title: str,
127+
body: str,
128+
*,
129+
target: str,
130+
provider: str = "pushover",
131+
url: str | None = None,
132+
url_title: str | None = None,
133+
priority: str = "normal",
134+
api_base_url: str | None = None,
135+
) -> bool:
136+
from .push import send_pushover_push
137+
return send_pushover_push(
138+
user_key=target,
139+
message=body,
140+
title=title,
141+
url=url,
142+
url_title=url_title,
143+
priority=priority,
144+
api_base_url=api_base_url,
145+
)
146+
147+
148+
class NtfyChannel:
149+
"""Ntfy push channel — wraps send_ntfy_push()."""
150+
151+
def send_push(
152+
self,
153+
title: str,
154+
body: str,
155+
*,
156+
target: str,
157+
provider: str = "ntfy",
158+
url: str | None = None,
159+
url_title: str | None = None,
160+
priority: str = "normal",
161+
api_base_url: str | None = None,
162+
) -> bool:
163+
from .push import send_ntfy_push
164+
return send_ntfy_push(
165+
topic=target,
166+
message=body,
167+
title=title,
168+
url=url,
169+
priority=priority,
170+
api_base_url=api_base_url,
171+
)
172+
173+
174+
class SmtpEmailChannel:
175+
"""Default email channel — wraps send_smtp_email()."""
176+
177+
def send_email(
178+
self,
179+
subject: str,
180+
body: str,
181+
*,
182+
recipients: list[str],
183+
sender: str | None = None,
184+
smtp_host: str = "smtp.gmail.com",
185+
smtp_port: int = 465,
186+
security: str = "ssl",
187+
username: str | None = None,
188+
password: str | None = None,
189+
) -> bool:
190+
from .email import send_smtp_email
191+
return send_smtp_email(
192+
recipients=recipients,
193+
subject=subject,
194+
body=body,
195+
sender=sender,
196+
smtp_host=smtp_host,
197+
smtp_port=smtp_port,
198+
security=security,
199+
username=username,
200+
password=password,
201+
)
202+
203+
204+
class TelegramChatChannel:
205+
"""Default chat channel — wraps send_telegram_message()."""
206+
207+
def send_message(
208+
self,
209+
chat_id: str,
210+
text: str,
211+
*,
212+
token: str,
213+
api_base_url: str = "https://api.telegram.org",
214+
parse_mode: str = "HTML",
215+
) -> bool:
216+
from .telegram import send_telegram_message
217+
return send_telegram_message(
218+
chat_id=chat_id,
219+
text=text,
220+
token=token,
221+
api_base_url=api_base_url,
222+
parse_mode=parse_mode,
223+
)

0 commit comments

Comments
 (0)