scitex-notification 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ # Timestamp: "2026-03-16 (ywatanabe)"
3
+ # File: /home/ywatanabe/proj/scitex-notification/src/scitex_notification/__init__.py
4
+
5
+ """SciTeX Notification Module - User alerts and feedback.
6
+
7
+ Usage:
8
+ import scitex_notification as stxn
9
+
10
+ # Simple alert - uses fallback priority (audio -> emacs -> desktop -> ...)
11
+ stxn.alert("2FA required!")
12
+
13
+ # Specify backend (no fallback)
14
+ stxn.alert("Error", backend="email")
15
+
16
+ # Multiple backends (tries all)
17
+ stxn.alert("Critical", backend=["audio", "email"])
18
+
19
+ # Use fallback explicitly
20
+ stxn.alert("Important", fallback=True)
21
+
22
+ # Make a phone call via Twilio
23
+ stxn.call("Critical alert!")
24
+
25
+ # Send an SMS via Twilio
26
+ stxn.sms("Build done!")
27
+
28
+ Environment Variables:
29
+ SCITEX_NOTIFY_DEFAULT_BACKEND: audio, email, desktop, webhook
30
+ SCITEX_UI_DEFAULT_BACKEND: (deprecated) use SCITEX_NOTIFY_DEFAULT_BACKEND
31
+ SCITEX_NOTIFICATION_ENV_SRC: path to .env file to auto-load on import
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import os
38
+ from typing import Optional, Union
39
+
40
+ from ._env_loader import load_scitex_notification_env as _load_env
41
+
42
+ _load_env()
43
+
44
+ from ._backends import NotifyLevel as _AlertLevel
45
+ from ._backends import available_backends as _available_backends
46
+ from ._backends import get_backend as _get_backend
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "alert",
52
+ "alert_async",
53
+ "available_backends",
54
+ "call",
55
+ "call_async",
56
+ "sms",
57
+ "sms_async",
58
+ "__version__",
59
+ ]
60
+
61
+ # Default fallback priority order
62
+ DEFAULT_FALLBACK_ORDER = [
63
+ "audio", # 1st: TTS audio (non-blocking, immediate)
64
+ "emacs", # 2nd: Emacs minibuffer (if in Emacs)
65
+ "matplotlib", # 3rd: Visual popup
66
+ "playwright", # 4th: Browser popup
67
+ "email", # 5th: Email (slowest, most reliable)
68
+ ]
69
+
70
+
71
+ def available_backends() -> list[str]:
72
+ """Return list of available alert backends."""
73
+ return _available_backends()
74
+
75
+
76
+ async def alert_async(
77
+ message: str,
78
+ title: Optional[str] = None,
79
+ backend: Optional[Union[str, list[str]]] = None,
80
+ level: str = "info",
81
+ fallback: bool = True,
82
+ **kwargs,
83
+ ) -> bool:
84
+ """Send alert asynchronously.
85
+
86
+ Parameters
87
+ ----------
88
+ message : str
89
+ Alert message
90
+ title : str, optional
91
+ Alert title
92
+ backend : str or list[str], optional
93
+ Backend(s) to use. If None, uses default with fallback.
94
+ level : str
95
+ Alert level: info, warning, error, critical
96
+ fallback : bool
97
+ If True and backend fails, try next in priority order.
98
+ Default True when backend=None, False when backend specified.
99
+
100
+ Returns
101
+ -------
102
+ bool
103
+ True if any backend succeeded
104
+ """
105
+ try:
106
+ lvl = _AlertLevel(level.lower())
107
+ except ValueError:
108
+ lvl = _AlertLevel.INFO
109
+
110
+ # Determine backends to try
111
+ if backend is None:
112
+ # No backend specified: use fallback priority
113
+ default = (
114
+ os.getenv("SCITEX_NOTIFICATION_DEFAULT_BACKEND")
115
+ or os.getenv("SCITEX_NOTIFY_DEFAULT_BACKEND")
116
+ or os.getenv("SCITEX_UI_DEFAULT_BACKEND", "audio")
117
+ )
118
+ if fallback:
119
+ # Start with default, then try others in priority order
120
+ backends = [default] + [b for b in DEFAULT_FALLBACK_ORDER if b != default]
121
+ else:
122
+ backends = [default]
123
+ else:
124
+ # Backend specified: use it (with optional fallback)
125
+ backends = [backend] if isinstance(backend, str) else list(backend)
126
+ if fallback and len(backends) == 1:
127
+ # Add fallback backends after the specified one
128
+ backends = backends + [
129
+ b for b in DEFAULT_FALLBACK_ORDER if b not in backends
130
+ ]
131
+
132
+ # Try backends until one succeeds
133
+ available = _available_backends()
134
+ for name in backends:
135
+ if name not in available:
136
+ continue
137
+ try:
138
+ b = _get_backend(name)
139
+ result = await b.send(message, title=title, level=lvl, **kwargs)
140
+ if result.success:
141
+ return True
142
+ except Exception:
143
+ pass
144
+
145
+ return False
146
+
147
+
148
+ def alert(
149
+ message: str,
150
+ title: Optional[str] = None,
151
+ backend: Optional[Union[str, list[str]]] = None,
152
+ level: str = "info",
153
+ fallback: bool = True,
154
+ **kwargs,
155
+ ) -> bool:
156
+ """Send alert synchronously.
157
+
158
+ Parameters
159
+ ----------
160
+ message : str
161
+ Alert message
162
+ title : str, optional
163
+ Alert title
164
+ backend : str or list[str], optional
165
+ Backend(s) to use. If None, uses fallback priority order.
166
+ level : str
167
+ Alert level: info, warning, error, critical
168
+ fallback : bool
169
+ If True and backend fails, try next in priority order.
170
+
171
+ Returns
172
+ -------
173
+ bool
174
+ True if any backend succeeded
175
+
176
+ Fallback Order
177
+ --------------
178
+ 1. audio - TTS (fast, non-blocking)
179
+ 2. emacs - Minibuffer message
180
+ 3. matplotlib - Visual popup
181
+ 4. playwright - Browser popup
182
+ 5. email - Email (slowest)
183
+ """
184
+ try:
185
+ asyncio.get_running_loop()
186
+ import concurrent.futures
187
+
188
+ with concurrent.futures.ThreadPoolExecutor() as executor:
189
+ future = executor.submit(
190
+ asyncio.run,
191
+ alert_async(message, title, backend, level, fallback, **kwargs),
192
+ )
193
+ return future.result(timeout=30)
194
+ except RuntimeError:
195
+ return asyncio.run(
196
+ alert_async(message, title, backend, level, fallback, **kwargs)
197
+ )
198
+
199
+
200
+ def call(
201
+ message: str,
202
+ title: Optional[str] = None,
203
+ level: str = "info",
204
+ to_number: Optional[str] = None,
205
+ **kwargs,
206
+ ) -> bool:
207
+ """Make a phone call via Twilio.
208
+
209
+ Convenience wrapper for alert(backend="twilio").
210
+ """
211
+ return alert(
212
+ message,
213
+ title=title,
214
+ backend="twilio",
215
+ level=level,
216
+ fallback=False,
217
+ to_number=to_number,
218
+ **kwargs,
219
+ )
220
+
221
+
222
+ async def call_async(
223
+ message: str,
224
+ title: Optional[str] = None,
225
+ level: str = "info",
226
+ to_number: Optional[str] = None,
227
+ **kwargs,
228
+ ) -> bool:
229
+ """Make a phone call via Twilio (async)."""
230
+ return await alert_async(
231
+ message,
232
+ title=title,
233
+ backend="twilio",
234
+ level=level,
235
+ fallback=False,
236
+ to_number=to_number,
237
+ **kwargs,
238
+ )
239
+
240
+
241
+ async def sms_async(
242
+ message: str,
243
+ title: Optional[str] = None,
244
+ to_number: Optional[str] = None,
245
+ **kwargs,
246
+ ) -> bool:
247
+ """Send an SMS via Twilio (async).
248
+
249
+ Parameters
250
+ ----------
251
+ message : str
252
+ SMS body text
253
+ title : str, optional
254
+ Prepended to message if provided
255
+ to_number : str, optional
256
+ Override SCITEX_NOTIFY_TWILIO_TO
257
+
258
+ Returns
259
+ -------
260
+ bool
261
+ True if SMS sent successfully
262
+ """
263
+ from ._backends._twilio import send_sms as _send_sms
264
+
265
+ result = await _send_sms(
266
+ message,
267
+ title=title,
268
+ to_number=to_number,
269
+ **kwargs,
270
+ )
271
+ return result.success
272
+
273
+
274
+ def sms(
275
+ message: str,
276
+ title: Optional[str] = None,
277
+ to_number: Optional[str] = None,
278
+ **kwargs,
279
+ ) -> bool:
280
+ """Send an SMS via Twilio.
281
+
282
+ Parameters
283
+ ----------
284
+ message : str
285
+ SMS body text
286
+ title : str, optional
287
+ Prepended to message if provided
288
+ to_number : str, optional
289
+ Override SCITEX_NOTIFY_TWILIO_TO
290
+
291
+ Returns
292
+ -------
293
+ bool
294
+ True if SMS sent successfully
295
+ """
296
+ try:
297
+ asyncio.get_running_loop()
298
+ import concurrent.futures
299
+
300
+ with concurrent.futures.ThreadPoolExecutor() as executor:
301
+ future = executor.submit(
302
+ asyncio.run,
303
+ sms_async(message, title, to_number, **kwargs),
304
+ )
305
+ return future.result(timeout=30)
306
+ except RuntimeError:
307
+ return asyncio.run(sms_async(message, title, to_number, **kwargs))
308
+
309
+
310
+ # Apply @supports_return_as decorator if scitex_dev is available
311
+ try:
312
+ from scitex_dev.decorators import supports_return_as as _supports_return_as
313
+
314
+ alert = _supports_return_as(alert)
315
+ call = _supports_return_as(call)
316
+ sms = _supports_return_as(sms)
317
+ except ImportError:
318
+ pass
319
+
320
+
321
+ # EOF
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """Allow running scitex-notification as: python -m scitex_notification."""
3
+
4
+ from scitex_notification._cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ # Timestamp: "2026-01-13 (ywatanabe)"
3
+ # File: /home/ywatanabe/proj/scitex-notification/src/scitex_notification/_backends/__init__.py
4
+
5
+ """Notification backend registry and utilities."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._audio import AudioBackend
10
+ from ._desktop import DesktopBackend
11
+ from ._emacs import EmacsBackend
12
+ from ._email import EmailBackend
13
+ from ._matplotlib import MatplotlibBackend
14
+ from ._playwright import PlaywrightBackend
15
+ from ._twilio import TwilioBackend
16
+ from ._types import BaseNotifyBackend, NotifyLevel, NotifyResult
17
+ from ._webhook import WebhookBackend
18
+
19
+ __all__ = [
20
+ "NotifyLevel",
21
+ "NotifyResult",
22
+ "BaseNotifyBackend",
23
+ "AudioBackend",
24
+ "EmailBackend",
25
+ "DesktopBackend",
26
+ "EmacsBackend",
27
+ "WebhookBackend",
28
+ "MatplotlibBackend",
29
+ "PlaywrightBackend",
30
+ "TwilioBackend",
31
+ "BACKENDS",
32
+ "get_backend",
33
+ "available_backends",
34
+ ]
35
+
36
+ # Registry of available backends
37
+ BACKENDS: dict[str, type[BaseNotifyBackend]] = {
38
+ "audio": AudioBackend,
39
+ "email": EmailBackend,
40
+ "desktop": DesktopBackend,
41
+ "emacs": EmacsBackend,
42
+ "webhook": WebhookBackend,
43
+ "matplotlib": MatplotlibBackend,
44
+ "playwright": PlaywrightBackend,
45
+ "twilio": TwilioBackend,
46
+ }
47
+
48
+
49
+ def get_backend(name: str, **kwargs) -> BaseNotifyBackend:
50
+ """Get a notification backend by name."""
51
+ if name not in BACKENDS:
52
+ raise ValueError(f"Unknown backend: {name}. Available: {list(BACKENDS.keys())}")
53
+ return BACKENDS[name](**kwargs)
54
+
55
+
56
+ def available_backends() -> list[str]:
57
+ """Return list of available notification backends."""
58
+ available = []
59
+ for name, cls in BACKENDS.items():
60
+ try:
61
+ backend = cls()
62
+ if backend.is_available():
63
+ available.append(name)
64
+ except Exception:
65
+ pass
66
+ return available
67
+
68
+
69
+ # EOF
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ # Timestamp: "2026-01-13 (ywatanabe)"
3
+ # File: /home/ywatanabe/proj/scitex-notification/src/scitex_notification/_backends/_audio.py
4
+
5
+ """Audio notification backend via TTS."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ from ._types import BaseNotifyBackend, NotifyLevel, NotifyResult
14
+
15
+ try:
16
+ from scitex_audio import available_backends as _audio_available_backends
17
+ from scitex_audio import speak as _audio_speak
18
+
19
+ _AUDIO_AVAILABLE = True
20
+ except ImportError:
21
+ _AUDIO_AVAILABLE = False
22
+ _audio_speak = None
23
+ _audio_available_backends = None
24
+
25
+
26
+ class AudioBackend(BaseNotifyBackend):
27
+ """Audio notification via scitex_audio TTS."""
28
+
29
+ name = "audio"
30
+
31
+ def __init__(
32
+ self,
33
+ backend: str = "gtts",
34
+ speed: float = 1.5,
35
+ rate: int = 180,
36
+ ):
37
+ self.tts_backend = backend
38
+ self.speed = speed
39
+ self.rate = rate
40
+
41
+ def is_available(self) -> bool:
42
+ if not _AUDIO_AVAILABLE:
43
+ return False
44
+ try:
45
+ return len(_audio_available_backends()) > 0
46
+ except Exception:
47
+ return False
48
+
49
+ async def send(
50
+ self,
51
+ message: str,
52
+ title: Optional[str] = None,
53
+ level: NotifyLevel = NotifyLevel.INFO,
54
+ **kwargs,
55
+ ) -> NotifyResult:
56
+ try:
57
+ if not _AUDIO_AVAILABLE or _audio_speak is None:
58
+ raise ImportError(
59
+ "scitex_audio is not installed. "
60
+ "Install it with: pip install scitex-audio"
61
+ )
62
+
63
+ # Prepend title if provided
64
+ full_message = f"{title}. {message}" if title else message
65
+
66
+ # Add urgency prefix for critical/error levels
67
+ if level == NotifyLevel.CRITICAL:
68
+ full_message = f"Critical alert! {full_message}"
69
+ elif level == NotifyLevel.ERROR:
70
+ full_message = f"Error. {full_message}"
71
+
72
+ # Run TTS in executor to not block
73
+ loop = asyncio.get_event_loop()
74
+ await loop.run_in_executor(
75
+ None,
76
+ lambda: _audio_speak(
77
+ full_message,
78
+ backend=kwargs.get("tts_backend", self.tts_backend),
79
+ speed=kwargs.get("speed", self.speed),
80
+ rate=kwargs.get("rate", self.rate),
81
+ ),
82
+ )
83
+
84
+ return NotifyResult(
85
+ success=True,
86
+ backend=self.name,
87
+ message=message,
88
+ timestamp=datetime.now().isoformat(),
89
+ )
90
+ except Exception as e:
91
+ return NotifyResult(
92
+ success=False,
93
+ backend=self.name,
94
+ message=message,
95
+ timestamp=datetime.now().isoformat(),
96
+ error=str(e),
97
+ )
98
+
99
+
100
+ # EOF