diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a94a47 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 489497e..e03f7cf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3aa9164 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/ironnotify/__init__.py b/src/ironnotify/__init__.py new file mode 100644 index 0000000..9afd227 --- /dev/null +++ b/src/ironnotify/__init__.py @@ -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 diff --git a/src/ironnotify/builder.py b/src/ironnotify/builder.py new file mode 100644 index 0000000..23d6017 --- /dev/null +++ b/src/ironnotify/builder.py @@ -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) diff --git a/src/ironnotify/client.py b/src/ironnotify/client.py new file mode 100644 index 0000000..8593c89 --- /dev/null +++ b/src/ironnotify/client.py @@ -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) diff --git a/src/ironnotify/queue.py b/src/ironnotify/queue.py new file mode 100644 index 0000000..0d317d6 --- /dev/null +++ b/src/ironnotify/queue.py @@ -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 diff --git a/src/ironnotify/transport.py b/src/ironnotify/transport.py new file mode 100644 index 0000000..3ff63a3 --- /dev/null +++ b/src/ironnotify/transport.py @@ -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 diff --git a/src/ironnotify/types.py b/src/ironnotify/types.py new file mode 100644 index 0000000..7fb593a --- /dev/null +++ b/src/ironnotify/types.py @@ -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