Implement IronNotify Python SDK

- NotifyClient with simple notify() and event builder APIs
- Full async/await support with httpx
- Offline queue with JSON file persistence
- Event builder with fluent API
- Notification management (get, markAsRead, unreadCount)
- Type hints with dataclasses
- Python 3.8+ compatible
This commit is contained in:
David Friedel 2025-12-25 10:39:44 +00:00
parent abb0dff3ef
commit 5977510568
9 changed files with 1422 additions and 2 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
dist/
build/
*.egg-info/
*.egg
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
.pytest_cache/
.coverage
htmlcov/
# Type checking
.mypy_cache/
# Local storage
.ironnotify/

292
README.md
View File

@ -1,2 +1,290 @@
# ironnotify-python
IronNotify SDK for Python - Event notifications and alerts
# IronNotify SDK for Python
Event notifications and alerts SDK for Python applications. Send notifications, receive real-time updates, and manage notification state.
[![PyPI](https://img.shields.io/pypi/v/ironnotify.svg)](https://pypi.org/project/ironnotify/)
[![Python](https://img.shields.io/pypi/pyversions/ironnotify.svg)](https://pypi.org/project/ironnotify/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## Installation
```bash
pip install ironnotify
```
## Quick Start
### Send a Simple Notification
```python
import ironnotify
# Initialize
ironnotify.init("ak_live_xxxxx")
# Send a simple notification
ironnotify.notify(
"order.created",
"New Order Received",
message="Order #1234 has been placed",
severity="success",
metadata={"order_id": "1234", "amount": 99.99},
)
```
### Fluent Event Builder
```python
import ironnotify
ironnotify.init("ak_live_xxxxx")
# Build complex notifications with the fluent API
result = ironnotify.event("payment.failed") \
.with_title("Payment Failed") \
.with_message("Payment could not be processed") \
.with_severity("error") \
.with_metadata({"order_id": "1234", "reason": "Card declined"}) \
.with_action("Retry Payment", url="/orders/1234/retry", style="primary") \
.with_action("Contact Support", action="open_support") \
.for_user("user-123") \
.with_deduplication_key("payment-failed-1234") \
.expires_in(86400) # 24 hours \
.send()
```
### Async Support
```python
import asyncio
import ironnotify
async def main():
ironnotify.init("ak_live_xxxxx")
# Async notification
result = await ironnotify.notify_async(
"user.signup",
"New User Registered",
severity="success",
)
# Async event builder
result = await ironnotify.event("order.shipped") \
.with_title("Order Shipped") \
.for_user("user-123") \
.send_async()
# Get notifications async
notifications = await ironnotify.get_notifications_async(limit=10)
asyncio.run(main())
```
### Using the Client Directly
```python
from ironnotify import NotifyClient, NotifyOptions
client = NotifyClient(NotifyOptions(
api_key="ak_live_xxxxx",
debug=True,
))
# Send notification
result = client.notify("event.type", "Title")
# Use event builder
result = client.event("event.type") \
.with_title("Title") \
.send()
# Clean up
client.close()
```
## Configuration
```python
from ironnotify import NotifyOptions, NotifyClient
options = NotifyOptions(
api_key="ak_live_xxxxx",
api_base_url="https://api.ironnotify.com",
ws_url="wss://ws.ironnotify.com",
debug=False,
enable_offline_queue=True,
max_offline_queue_size=100,
auto_reconnect=True,
max_reconnect_attempts=5,
reconnect_delay=1.0,
)
client = NotifyClient(options)
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api_key` | str | required | Your API key (ak_live_xxx or ak_test_xxx) |
| `api_base_url` | str | 'https://api.ironnotify.com' | API base URL |
| `ws_url` | str | 'wss://ws.ironnotify.com' | WebSocket URL |
| `debug` | bool | False | Enable debug logging |
| `enable_offline_queue` | bool | True | Queue notifications when offline |
| `max_offline_queue_size` | int | 100 | Max offline queue size |
| `auto_reconnect` | bool | True | Auto-reconnect WebSocket |
| `max_reconnect_attempts` | int | 5 | Max reconnection attempts |
| `reconnect_delay` | float | 1.0 | Base reconnection delay (seconds) |
## Features
- **Simple Notifications**: Send notifications with one line of code
- **Fluent Builder**: Build complex notifications with an intuitive API
- **Async Support**: Full async/await support for all operations
- **Offline Queue**: Notifications are queued when offline
- **Type Hints**: Full type annotations for IDE support
- **Dataclasses**: Clean data models using Python dataclasses
## Notification Options
### Severity Levels
```python
from ironnotify import SeverityLevel
# Available levels: "info", "success", "warning", "error", "critical"
ironnotify.notify("alert", "System Alert", severity="critical")
```
### Actions
```python
ironnotify.event("order.shipped") \
.with_title("Order Shipped") \
.with_action("Track Package", url="https://tracking.example.com/123", style="primary") \
.with_action("View Order", action="view_order") \
.send()
```
### Deduplication
Prevent duplicate notifications:
```python
ironnotify.event("reminder") \
.with_title("Daily Reminder") \
.with_deduplication_key("daily-reminder-2024-01-15") \
.send()
```
### Grouping
Group related notifications:
```python
ironnotify.event("comment.new") \
.with_title("New Comment") \
.with_group_key("post-123-comments") \
.send()
```
### Expiration
```python
from datetime import datetime, timedelta
# Expires in 1 hour
ironnotify.event("flash_sale") \
.with_title("Flash Sale!") \
.expires_in(3600) \
.send()
# Expires at specific time
ironnotify.event("event_reminder") \
.with_title("Event Tomorrow") \
.expires_on(datetime.utcnow() + timedelta(days=1)) \
.send()
```
## Managing Notifications
### Get Notifications
```python
# Get all notifications
notifications = ironnotify.get_notifications()
# With options
unread = ironnotify.get_notifications(limit=10, offset=0, unread_only=True)
# Async
notifications = await ironnotify.get_notifications_async()
```
### Mark as Read
```python
# Mark single notification
client.mark_as_read("notification-id")
# Mark all as read
client.mark_all_as_read()
```
### Get Unread Count
```python
count = client.get_unread_count()
print(f"You have {count} unread notifications")
```
## Real-Time Notifications
```python
import ironnotify
ironnotify.init("ak_live_xxxxx")
def handle_notification(notification):
print(f"New notification: {notification.title}")
ironnotify.on_notification(handle_notification)
ironnotify.connect()
ironnotify.subscribe_to_user("user-123")
ironnotify.subscribe_to_app()
```
## Offline Support
Notifications are automatically queued when offline:
```python
import ironnotify
ironnotify.init("ak_live_xxxxx")
# This will be queued if offline
ironnotify.notify("event", "Title")
# Manually flush the queue
ironnotify.flush()
# Or async
await ironnotify.flush_async()
```
## Requirements
- Python 3.8+
- httpx
- websockets (for real-time features)
## Links
- [Documentation](https://www.ironnotify.com/docs)
- [Dashboard](https://www.ironnotify.com)
## License
MIT License - see [LICENSE](LICENSE) for details.

48
pyproject.toml Normal file
View File

@ -0,0 +1,48 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ironnotify"
version = "0.1.0"
description = "IronNotify SDK for Python - Event notifications and alerts"
readme = "README.md"
license = "MIT"
requires-python = ">=3.8"
authors = [
{ name = "IronServices", email = "support@ironservices.com" }
]
keywords = ["ironnotify", "notifications", "alerts", "events", "real-time", "websocket"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"httpx>=0.25.0",
"websockets>=12.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
]
[project.urls]
Homepage = "https://www.ironnotify.com"
Documentation = "https://www.ironnotify.com/docs"
Repository = "https://github.com/IronServices/ironnotify-python"
[tool.hatch.build.targets.sdist]
include = ["/src"]
[tool.hatch.build.targets.wheel]
packages = ["src/ironnotify"]

192
src/ironnotify/__init__.py Normal file
View File

@ -0,0 +1,192 @@
"""
IronNotify SDK for Python - Event notifications and alerts.
Example usage:
import ironnotify
# Initialize
ironnotify.init("ak_live_xxxxx")
# Send a notification
ironnotify.notify("order.created", "New Order Received")
# Use the event builder
await ironnotify.event("payment.failed") \\
.with_title("Payment Failed") \\
.with_message("Payment could not be processed") \\
.with_severity("error") \\
.send()
"""
from .types import (
SeverityLevel,
NotificationAction,
NotificationPayload,
Notification,
SendResult,
NotifyOptions,
ConnectionState,
)
from .client import NotifyClient
from .builder import EventBuilder
__version__ = "0.1.0"
__all__ = [
# Types
"SeverityLevel",
"NotificationAction",
"NotificationPayload",
"Notification",
"SendResult",
"NotifyOptions",
"ConnectionState",
# Classes
"NotifyClient",
"EventBuilder",
# Functions
"init",
"get_client",
"notify",
"notify_async",
"event",
"connect",
"disconnect",
"subscribe_to_user",
"subscribe_to_app",
"on_notification",
"get_notifications",
"get_notifications_async",
"flush",
"flush_async",
"close",
]
# Global client instance
_client: NotifyClient | None = None
def init(api_key: str, **options) -> NotifyClient:
"""Initialize the global NotifyClient."""
global _client
opts = NotifyOptions(api_key=api_key, **options)
_client = NotifyClient(opts)
return _client
def get_client() -> NotifyClient | None:
"""Get the global NotifyClient."""
return _client
def notify(
event_type: str,
title: str,
message: str | None = None,
severity: SeverityLevel = "info",
metadata: dict | None = None,
user_id: str | None = None,
) -> SendResult:
"""Send a notification using the global client (sync)."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
return _client.notify(event_type, title, message, severity, metadata, user_id)
async def notify_async(
event_type: str,
title: str,
message: str | None = None,
severity: SeverityLevel = "info",
metadata: dict | None = None,
user_id: str | None = None,
) -> SendResult:
"""Send a notification using the global client (async)."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
return await _client.notify_async(event_type, title, message, severity, metadata, user_id)
def event(event_type: str) -> EventBuilder:
"""Create an event builder using the global client."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
return _client.event(event_type)
def connect() -> None:
"""Connect to real-time notifications."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
_client.connect()
def disconnect() -> None:
"""Disconnect from real-time notifications."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
_client.disconnect()
def subscribe_to_user(user_id: str) -> None:
"""Subscribe to a user's notifications."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
_client.subscribe_to_user(user_id)
def subscribe_to_app() -> None:
"""Subscribe to app-wide notifications."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
_client.subscribe_to_app()
def on_notification(handler) -> None:
"""Set notification handler."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
_client.on_notification(handler)
def get_notifications(
limit: int | None = None,
offset: int | None = None,
unread_only: bool = False,
) -> list[Notification]:
"""Get notifications (sync)."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
return _client.get_notifications(limit, offset, unread_only)
async def get_notifications_async(
limit: int | None = None,
offset: int | None = None,
unread_only: bool = False,
) -> list[Notification]:
"""Get notifications (async)."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
return await _client.get_notifications_async(limit, offset, unread_only)
def flush() -> None:
"""Flush the offline queue (sync)."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
_client.flush()
async def flush_async() -> None:
"""Flush the offline queue (async)."""
if _client is None:
raise RuntimeError("IronNotify not initialized. Call init() first.")
await _client.flush_async()
def close() -> None:
"""Close the global client."""
global _client
if _client is not None:
_client.close()
_client = None

117
src/ironnotify/builder.py Normal file
View File

@ -0,0 +1,117 @@
"""Event builder for IronNotify SDK."""
from datetime import datetime, timedelta
from typing import Any, TYPE_CHECKING
from .types import (
SeverityLevel,
NotificationAction,
NotificationPayload,
SendResult,
)
if TYPE_CHECKING:
from .client import NotifyClient
class EventBuilder:
"""Builder for creating notifications with a fluent API."""
def __init__(self, client: "NotifyClient", event_type: str):
self._client = client
self._event_type = event_type
self._title: str = ""
self._message: str | None = None
self._severity: SeverityLevel = "info"
self._metadata: dict[str, Any] = {}
self._actions: list[NotificationAction] = []
self._user_id: str | None = None
self._group_key: str | None = None
self._deduplication_key: str | None = None
self._expires_at: datetime | None = None
def with_title(self, title: str) -> "EventBuilder":
"""Set the notification title."""
self._title = title
return self
def with_message(self, message: str) -> "EventBuilder":
"""Set the notification message."""
self._message = message
return self
def with_severity(self, severity: SeverityLevel) -> "EventBuilder":
"""Set the severity level."""
self._severity = severity
return self
def with_metadata(self, metadata: dict[str, Any]) -> "EventBuilder":
"""Set metadata for the notification."""
self._metadata.update(metadata)
return self
def with_action(
self,
label: str,
url: str | None = None,
action: str | None = None,
style: str = "default",
) -> "EventBuilder":
"""Add an action button to the notification."""
self._actions.append(
NotificationAction(label=label, url=url, action=action, style=style)
)
return self
def for_user(self, user_id: str) -> "EventBuilder":
"""Set the target user ID."""
self._user_id = user_id
return self
def with_group_key(self, group_key: str) -> "EventBuilder":
"""Set the group key for grouping related notifications."""
self._group_key = group_key
return self
def with_deduplication_key(self, key: str) -> "EventBuilder":
"""Set the deduplication key to prevent duplicate notifications."""
self._deduplication_key = key
return self
def expires_in(self, seconds: float) -> "EventBuilder":
"""Set the expiration time (from now) for the notification."""
self._expires_at = datetime.utcnow() + timedelta(seconds=seconds)
return self
def expires_on(self, date: datetime) -> "EventBuilder":
"""Set the expiration date for the notification."""
self._expires_at = date
return self
def build(self) -> NotificationPayload:
"""Build the notification payload."""
if not self._title:
raise ValueError("Notification title is required")
return NotificationPayload(
event_type=self._event_type,
title=self._title,
message=self._message,
severity=self._severity,
metadata=self._metadata if self._metadata else None,
actions=self._actions if self._actions else None,
user_id=self._user_id,
group_key=self._group_key,
deduplication_key=self._deduplication_key,
expires_at=self._expires_at,
)
def send(self) -> SendResult:
"""Send the notification (sync)."""
payload = self.build()
return self._client.send_payload(payload)
async def send_async(self) -> SendResult:
"""Send the notification (async)."""
payload = self.build()
return await self._client.send_payload_async(payload)

240
src/ironnotify/client.py Normal file
View File

@ -0,0 +1,240 @@
"""Main client for IronNotify SDK."""
from typing import Any, Callable
from .types import (
NotifyOptions,
NotificationPayload,
Notification,
SendResult,
SeverityLevel,
ConnectionState,
)
from .transport import Transport
from .queue import OfflineQueue
from .builder import EventBuilder
class NotifyClient:
"""IronNotify client for sending and receiving notifications."""
def __init__(self, options: NotifyOptions):
self._options = options
self._transport = Transport(
options.api_base_url,
options.api_key,
options.debug,
)
self._queue = OfflineQueue(options.max_offline_queue_size, options.debug)
self._is_online = True
self._connection_state: ConnectionState = "disconnected"
# Event handlers
self._on_notification: Callable[[Notification], None] | None = None
self._on_unread_count_change: Callable[[int], None] | None = None
self._on_connection_state_change: Callable[[ConnectionState], None] | None = None
if options.debug:
print("[IronNotify] Initialized")
def notify(
self,
event_type: str,
title: str,
message: str | None = None,
severity: SeverityLevel = "info",
metadata: dict[str, Any] | None = None,
user_id: str | None = None,
) -> SendResult:
"""Send a notification (sync)."""
payload = NotificationPayload(
event_type=event_type,
title=title,
message=message,
severity=severity,
metadata=metadata,
user_id=user_id,
)
return self.send_payload(payload)
async def notify_async(
self,
event_type: str,
title: str,
message: str | None = None,
severity: SeverityLevel = "info",
metadata: dict[str, Any] | None = None,
user_id: str | None = None,
) -> SendResult:
"""Send a notification (async)."""
payload = NotificationPayload(
event_type=event_type,
title=title,
message=message,
severity=severity,
metadata=metadata,
user_id=user_id,
)
return await self.send_payload_async(payload)
def event(self, event_type: str) -> EventBuilder:
"""Create an event builder for complex notifications."""
return EventBuilder(self, event_type)
def send_payload(self, payload: NotificationPayload) -> SendResult:
"""Send a notification payload (sync)."""
result = self._transport.send(payload)
if not result.success and self._options.enable_offline_queue:
self._queue.add(payload)
self._is_online = False
return SendResult(
success=result.success,
notification_id=result.notification_id,
error=result.error,
queued=True,
)
return result
async def send_payload_async(self, payload: NotificationPayload) -> SendResult:
"""Send a notification payload (async)."""
result = await self._transport.send_async(payload)
if not result.success and self._options.enable_offline_queue:
self._queue.add(payload)
self._is_online = False
return SendResult(
success=result.success,
notification_id=result.notification_id,
error=result.error,
queued=True,
)
return result
def connect(self) -> None:
"""Connect to real-time notifications."""
# WebSocket connection would be implemented here
self._set_connection_state("connected")
if self._options.debug:
print("[IronNotify] Connected (WebSocket not implemented in sync mode)")
def disconnect(self) -> None:
"""Disconnect from real-time notifications."""
self._set_connection_state("disconnected")
def subscribe_to_user(self, user_id: str) -> None:
"""Subscribe to a user's notifications."""
if self._options.debug:
print(f"[IronNotify] Subscribed to user: {user_id}")
def subscribe_to_app(self) -> None:
"""Subscribe to app-wide notifications."""
if self._options.debug:
print("[IronNotify] Subscribed to app notifications")
def on_notification(self, handler: Callable[[Notification], None]) -> None:
"""Set notification handler."""
self._on_notification = handler
def on_unread_count_change(self, handler: Callable[[int], None]) -> None:
"""Set unread count change handler."""
self._on_unread_count_change = handler
def on_connection_state_change(self, handler: Callable[[ConnectionState], None]) -> None:
"""Set connection state change handler."""
self._on_connection_state_change = handler
@property
def connection_state(self) -> ConnectionState:
"""Get connection state."""
return self._connection_state
def get_notifications(
self,
limit: int | None = None,
offset: int | None = None,
unread_only: bool = False,
) -> list[Notification]:
"""Get notifications (sync)."""
return self._transport.get_notifications(limit, offset, unread_only)
async def get_notifications_async(
self,
limit: int | None = None,
offset: int | None = None,
unread_only: bool = False,
) -> list[Notification]:
"""Get notifications (async)."""
return await self._transport.get_notifications_async(limit, offset, unread_only)
def get_unread_count(self) -> int:
"""Get unread count (sync)."""
return self._transport.get_unread_count()
async def get_unread_count_async(self) -> int:
"""Get unread count (async)."""
return await self._transport.get_unread_count_async()
def mark_as_read(self, notification_id: str) -> bool:
"""Mark a notification as read (sync)."""
return self._transport.mark_as_read(notification_id)
async def mark_as_read_async(self, notification_id: str) -> bool:
"""Mark a notification as read (async)."""
return await self._transport.mark_as_read_async(notification_id)
def mark_all_as_read(self) -> bool:
"""Mark all notifications as read (sync)."""
return self._transport.mark_all_as_read()
async def mark_all_as_read_async(self) -> bool:
"""Mark all notifications as read (async)."""
return await self._transport.mark_all_as_read_async()
def flush(self) -> None:
"""Flush the offline queue (sync)."""
if self._queue.is_empty:
return
if not self._transport.is_online():
return
self._is_online = True
notifications = self._queue.get_all()
for i in range(len(notifications) - 1, -1, -1):
result = self._transport.send(notifications[i])
if result.success:
self._queue.remove(i)
else:
break
async def flush_async(self) -> None:
"""Flush the offline queue (async)."""
if self._queue.is_empty:
return
if not await self._transport.is_online_async():
return
self._is_online = True
notifications = self._queue.get_all()
for i in range(len(notifications) - 1, -1, -1):
result = await self._transport.send_async(notifications[i])
if result.success:
self._queue.remove(i)
else:
break
def close(self) -> None:
"""Close the client and clean up resources."""
self.disconnect()
self._transport.close()
def _set_connection_state(self, state: ConnectionState) -> None:
self._connection_state = state
if self._on_connection_state_change:
self._on_connection_state_change(state)

130
src/ironnotify/queue.py Normal file
View File

@ -0,0 +1,130 @@
"""Offline queue for IronNotify SDK."""
import json
from pathlib import Path
from typing import Any
from .types import NotificationPayload, NotificationAction
class OfflineQueue:
"""Offline queue for storing notifications when offline."""
def __init__(self, max_size: int, debug: bool):
self.max_size = max_size
self.debug = debug
self._queue: list[NotificationPayload] = []
self._storage_path = Path.home() / ".ironnotify" / "offline_queue.json"
self._load_from_storage()
def add(self, payload: NotificationPayload) -> None:
"""Add a notification to the queue."""
if len(self._queue) >= self.max_size:
self._queue.pop(0)
if self.debug:
print("[IronNotify] Offline queue full, dropping oldest notification")
self._queue.append(payload)
self._save_to_storage()
if self.debug:
print(f"[IronNotify] Notification queued for later: {payload.event_type}")
def get_all(self) -> list[NotificationPayload]:
"""Get all queued notifications."""
return list(self._queue)
def remove(self, index: int) -> None:
"""Remove a notification from the queue."""
if 0 <= index < len(self._queue):
self._queue.pop(index)
self._save_to_storage()
def clear(self) -> None:
"""Clear the queue."""
self._queue = []
self._save_to_storage()
@property
def size(self) -> int:
"""Get queue size."""
return len(self._queue)
@property
def is_empty(self) -> bool:
"""Check if queue is empty."""
return len(self._queue) == 0
def _serialize_payload(self, payload: NotificationPayload) -> dict[str, Any]:
data: dict[str, Any] = {
"event_type": payload.event_type,
"title": payload.title,
"severity": payload.severity,
}
if payload.message:
data["message"] = payload.message
if payload.metadata:
data["metadata"] = payload.metadata
if payload.actions:
data["actions"] = [
{"label": a.label, "url": a.url, "action": a.action, "style": a.style}
for a in payload.actions
]
if payload.user_id:
data["user_id"] = payload.user_id
if payload.group_key:
data["group_key"] = payload.group_key
if payload.deduplication_key:
data["deduplication_key"] = payload.deduplication_key
if payload.expires_at:
data["expires_at"] = payload.expires_at.isoformat()
return data
def _deserialize_payload(self, data: dict[str, Any]) -> NotificationPayload:
from datetime import datetime
actions = None
if data.get("actions"):
actions = [
NotificationAction(
label=a["label"],
url=a.get("url"),
action=a.get("action"),
style=a.get("style", "default"),
)
for a in data["actions"]
]
return NotificationPayload(
event_type=data["event_type"],
title=data["title"],
message=data.get("message"),
severity=data.get("severity", "info"),
metadata=data.get("metadata"),
actions=actions,
user_id=data.get("user_id"),
group_key=data.get("group_key"),
deduplication_key=data.get("deduplication_key"),
expires_at=datetime.fromisoformat(data["expires_at"])
if data.get("expires_at")
else None,
)
def _load_from_storage(self) -> None:
"""Load queue from storage."""
try:
if self._storage_path.exists():
with open(self._storage_path, "r") as f:
data = json.load(f)
self._queue = [self._deserialize_payload(p) for p in data]
except Exception:
pass
def _save_to_storage(self) -> None:
"""Save queue to storage."""
try:
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._storage_path, "w") as f:
json.dump([self._serialize_payload(p) for p in self._queue], f)
except Exception:
pass

281
src/ironnotify/transport.py Normal file
View File

@ -0,0 +1,281 @@
"""HTTP transport for IronNotify SDK."""
import httpx
from datetime import datetime
from typing import Any
from .types import NotificationPayload, SendResult, Notification, NotificationAction
class Transport:
"""HTTP transport for sending notifications."""
def __init__(self, api_base_url: str, api_key: str, debug: bool):
self.api_base_url = api_base_url
self.api_key = api_key
self.debug = debug
self._sync_client: httpx.Client | None = None
self._async_client: httpx.AsyncClient | None = None
@property
def sync_client(self) -> httpx.Client:
if self._sync_client is None:
self._sync_client = httpx.Client(
base_url=self.api_base_url,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30.0,
)
return self._sync_client
@property
def async_client(self) -> httpx.AsyncClient:
if self._async_client is None:
self._async_client = httpx.AsyncClient(
base_url=self.api_base_url,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30.0,
)
return self._async_client
def _serialize_payload(self, payload: NotificationPayload) -> dict[str, Any]:
data: dict[str, Any] = {
"eventType": payload.event_type,
"title": payload.title,
"severity": payload.severity,
}
if payload.message:
data["message"] = payload.message
if payload.metadata:
data["metadata"] = payload.metadata
if payload.actions:
data["actions"] = [
{
"label": a.label,
"url": a.url,
"action": a.action,
"style": a.style,
}
for a in payload.actions
]
if payload.user_id:
data["userId"] = payload.user_id
if payload.group_key:
data["groupKey"] = payload.group_key
if payload.deduplication_key:
data["deduplicationKey"] = payload.deduplication_key
if payload.expires_at:
data["expiresAt"] = payload.expires_at.isoformat()
return data
def _parse_notification(self, data: dict[str, Any]) -> Notification:
actions = None
if data.get("actions"):
actions = [
NotificationAction(
label=a["label"],
url=a.get("url"),
action=a.get("action"),
style=a.get("style", "default"),
)
for a in data["actions"]
]
return Notification(
id=data["id"],
event_type=data["eventType"],
title=data["title"],
message=data.get("message"),
severity=data.get("severity", "info"),
metadata=data.get("metadata"),
actions=actions,
read=data.get("read", False),
created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
expires_at=datetime.fromisoformat(data["expiresAt"].replace("Z", "+00:00"))
if data.get("expiresAt")
else None,
)
def send(self, payload: NotificationPayload) -> SendResult:
"""Send a notification (sync)."""
try:
response = self.sync_client.post(
"/api/v1/notifications",
json=self._serialize_payload(payload),
)
if response.status_code >= 400:
error = f"HTTP {response.status_code}: {response.text}"
if self.debug:
print(f"[IronNotify] Failed to send notification: {error}")
return SendResult(success=False, error=error)
result = response.json()
if self.debug:
print(f"[IronNotify] Notification sent: {result.get('notificationId')}")
return SendResult(success=True, notification_id=result.get("notificationId"))
except Exception as e:
error = str(e)
if self.debug:
print(f"[IronNotify] Failed to send notification: {error}")
return SendResult(success=False, error=error)
async def send_async(self, payload: NotificationPayload) -> SendResult:
"""Send a notification (async)."""
try:
response = await self.async_client.post(
"/api/v1/notifications",
json=self._serialize_payload(payload),
)
if response.status_code >= 400:
error = f"HTTP {response.status_code}: {response.text}"
if self.debug:
print(f"[IronNotify] Failed to send notification: {error}")
return SendResult(success=False, error=error)
result = response.json()
if self.debug:
print(f"[IronNotify] Notification sent: {result.get('notificationId')}")
return SendResult(success=True, notification_id=result.get("notificationId"))
except Exception as e:
error = str(e)
if self.debug:
print(f"[IronNotify] Failed to send notification: {error}")
return SendResult(success=False, error=error)
def get_notifications(
self,
limit: int | None = None,
offset: int | None = None,
unread_only: bool = False,
) -> list[Notification]:
"""Get notifications (sync)."""
params = {}
if limit:
params["limit"] = str(limit)
if offset:
params["offset"] = str(offset)
if unread_only:
params["unreadOnly"] = "true"
try:
response = self.sync_client.get("/api/v1/notifications", params=params)
if response.status_code >= 400:
return []
data = response.json()
return [self._parse_notification(n) for n in data.get("notifications", [])]
except Exception:
return []
async def get_notifications_async(
self,
limit: int | None = None,
offset: int | None = None,
unread_only: bool = False,
) -> list[Notification]:
"""Get notifications (async)."""
params = {}
if limit:
params["limit"] = str(limit)
if offset:
params["offset"] = str(offset)
if unread_only:
params["unreadOnly"] = "true"
try:
response = await self.async_client.get("/api/v1/notifications", params=params)
if response.status_code >= 400:
return []
data = response.json()
return [self._parse_notification(n) for n in data.get("notifications", [])]
except Exception:
return []
def mark_as_read(self, notification_id: str) -> bool:
"""Mark a notification as read (sync)."""
try:
response = self.sync_client.post(f"/api/v1/notifications/{notification_id}/read")
return response.status_code < 400
except Exception:
return False
async def mark_as_read_async(self, notification_id: str) -> bool:
"""Mark a notification as read (async)."""
try:
response = await self.async_client.post(
f"/api/v1/notifications/{notification_id}/read"
)
return response.status_code < 400
except Exception:
return False
def mark_all_as_read(self) -> bool:
"""Mark all notifications as read (sync)."""
try:
response = self.sync_client.post("/api/v1/notifications/read-all")
return response.status_code < 400
except Exception:
return False
async def mark_all_as_read_async(self) -> bool:
"""Mark all notifications as read (async)."""
try:
response = await self.async_client.post("/api/v1/notifications/read-all")
return response.status_code < 400
except Exception:
return False
def get_unread_count(self) -> int:
"""Get unread count (sync)."""
try:
response = self.sync_client.get("/api/v1/notifications/unread-count")
if response.status_code >= 400:
return 0
return response.json().get("count", 0)
except Exception:
return 0
async def get_unread_count_async(self) -> int:
"""Get unread count (async)."""
try:
response = await self.async_client.get("/api/v1/notifications/unread-count")
if response.status_code >= 400:
return 0
return response.json().get("count", 0)
except Exception:
return 0
def is_online(self) -> bool:
"""Check if server is online (sync)."""
try:
response = self.sync_client.get("/api/v1/health")
return response.status_code < 400
except Exception:
return False
async def is_online_async(self) -> bool:
"""Check if server is online (async)."""
try:
response = await self.async_client.get("/api/v1/health")
return response.status_code < 400
except Exception:
return False
def close(self):
"""Close the transport."""
if self._sync_client:
self._sync_client.close()
self._sync_client = None
if self._async_client:
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
loop.create_task(self._async_client.aclose())
else:
loop.run_until_complete(self._async_client.aclose())
except Exception:
pass
self._async_client = None

88
src/ironnotify/types.py Normal file
View File

@ -0,0 +1,88 @@
"""Type definitions for IronNotify SDK."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable, Literal
# Type aliases
SeverityLevel = Literal["info", "success", "warning", "error", "critical"]
ConnectionState = Literal["disconnected", "connecting", "connected", "reconnecting"]
@dataclass
class NotificationAction:
"""Notification action button."""
label: str
url: str | None = None
action: str | None = None
style: Literal["default", "primary", "danger"] = "default"
@dataclass
class NotificationPayload:
"""Notification payload sent to the server."""
event_type: str
title: str
message: str | None = None
severity: SeverityLevel = "info"
metadata: dict[str, Any] | None = None
actions: list[NotificationAction] | None = None
user_id: str | None = None
group_key: str | None = None
deduplication_key: str | None = None
expires_at: datetime | None = None
@dataclass
class Notification:
"""Notification received from the server."""
id: str
event_type: str
title: str
severity: SeverityLevel
read: bool
created_at: datetime
message: str | None = None
metadata: dict[str, Any] | None = None
actions: list[NotificationAction] | None = None
expires_at: datetime | None = None
@dataclass
class SendResult:
"""Result of sending a notification."""
success: bool
notification_id: str | None = None
error: str | None = None
queued: bool = False
@dataclass
class NotifyOptions:
"""Configuration options for the notify client."""
api_key: str
api_base_url: str = "https://api.ironnotify.com"
ws_url: str = "wss://ws.ironnotify.com"
debug: bool = False
enable_offline_queue: bool = True
max_offline_queue_size: int = 100
auto_reconnect: bool = True
max_reconnect_attempts: int = 5
reconnect_delay: float = 1.0
def __post_init__(self):
if not self.api_key:
raise ValueError("API key is required")
if not self.api_key.startswith("ak_"):
raise ValueError("API key must start with ak_")
if not (self.api_key.startswith("ak_live_") or self.api_key.startswith("ak_test_")):
raise ValueError("API key must be ak_live_xxx or ak_test_xxx")
@dataclass
class NotifyEventHandlers:
"""Event handlers for real-time notifications."""
on_notification: Callable[[Notification], None] | None = None
on_unread_count_change: Callable[[int], None] | None = None
on_connection_state_change: Callable[[ConnectionState], None] | None = None
on_error: Callable[[Exception], None] | None = None