From 31bb30d1bd306b9562a235cf11d4263cc9108392 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 12:30:42 +0530 Subject: [PATCH 01/42] Refactor: Streamline client API (e.g., client.create_template), cleanup code & docs --- README.md | 75 +++++++++++++++------ coverage.xml | 86 +++++++++++++++--------- examples/basic_usage.py | 28 +++----- examples/templates.py | 69 ++++++++++++++++++++ siren/__init__.py | 11 +--- siren/client.py | 57 +++++++--------- siren/formatter.py | 4 -- siren/templates.py | 100 +++++++++++++++++++++++++++- siren/utils.py | 4 -- siren/webhooks.py | 4 -- siren/workflows.py | 4 -- tests/__init__.py | 3 - tests/conftest.py | 17 +++++ tests/test_client.py | 75 +++++---------------- tests/test_templates.py | 141 ++++++++++++++++++++++++++++++++++++++++ 15 files changed, 487 insertions(+), 191 deletions(-) create mode 100644 examples/templates.py create mode 100644 tests/conftest.py create mode 100644 tests/test_templates.py diff --git a/README.md b/README.md index 8fe2271..9684eed 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ This is the official Python SDK for the Siren notification platform. +## Table of Contents + +- [Installation](#installation) +- [Basic Usage](#basic-usage) +- [Features](#features) + - [`get_templates()`](#get_templates) + - [`create_template()`](#create_template) +- [Getting Started for Package Developers](#getting-started-for-package-developers) + ## Installation ```bash @@ -13,19 +22,53 @@ pip install siren-ai ```python from siren import SirenClient -client = SirenClient(api_key="YOUR_API_KEY") +# Initialize the client by passing your API key: +client = SirenClient(api_key="YOUR_SIREN_API_KEY") -# Example: Send a message -response = client.send_message({ - "to": "user@example.com", - "template": "ai_task_completed", - "data": { - "task_name": "Data Cleanup", - "result": "Success" - } -}) +# Example: Get templates +# Get the first 5 templates +templates_response = client.get_templates(page=0, size=5) +print(templates_response) +``` + +## Features + +The Siren-AI Python SDK provides an interface to interact with the Siren API. -print(response) +### `get_templates()` + +Retrieves a list of notification templates. + +**Parameters:** +* Supports optional filtering (`tag_names`, `search`), sorting (`sort`), and pagination (`page`, `size`). Refer to the official Siren API documentation for detailed parameter usage. + +**Example:** +```python +# Get 5 templates, sorted by name +templates_response = client.get_templates(size=5, sort="name,asc") +print(templates_response) +``` + +### `create_template()` + +Creates a new notification template. + +**Parameters:** +* `template_data` (Dict[str, Any]): A dictionary representing the template structure. Key fields include `name`, `configurations`, etc. For the detailed payload structure, please refer to the official Siren API documentation. + +**Example:** +```python +new_template_payload = { + "name": "SDK_Quick_Template", + "configurations": { + "EMAIL": { + "subject": "Quick Test", + "body": "

Hello via SDK!

" + } + } +} +created_template_response = client.create_template(new_template_payload) +print(created_template_response) ``` ## Getting Started for Package Developers @@ -42,7 +85,7 @@ This guide will help you set up your environment to contribute to the `siren-ai` 1. **Clone the repository:** ```bash - git clone https://github.com/your-username/siren-ai.git # TODO: Update with actual repo URL + git clone https://github.com/jithu-keyvalue/siren-ai.git cd siren-ai ``` @@ -94,11 +137,3 @@ This guide will help you set up your environment to contribute to the `siren-ai` * Create a feature branch for your changes. * Commit your changes (pre-commit hooks will run). * Push your branch and open a Pull Request against the main repository branch. - -## Contributing - -Contribution guidelines will be added here. - -## License - -This project will be licensed under the [Specify License, e.g., MIT License]. diff --git a/coverage.xml b/coverage.xml index c674458..e9eac94 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,63 +1,91 @@ - + /home/jithu/projects/siren-ai/siren - + - + + + - + - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - + - - + + + + - - - - - + + + + + + + + + + + + + + - - - - - - - - diff --git a/examples/basic_usage.py b/examples/basic_usage.py index fcad8a9..ab70c11 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,38 +1,26 @@ +# examples/basic_usage.py """Basic usage examples for the Siren SDK.""" -# For local development, you might need to adjust sys.path: import os import sys from dotenv import load_dotenv +# Ensure the 'siren' package in the parent directory can be imported: sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from siren.client import SirenClient +from siren import SirenClient if __name__ == "__main__": - load_dotenv() # Load environment variables from .env file + load_dotenv() api_key = os.getenv("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY not found in .env file or environment variables.") - print( - "Please create a .env file in the project root with SIREN_API_KEY='your_key'" - ) + print("Error: SIREN_API_KEY not set.") sys.exit(1) - client = SirenClient(api_key=api_key) - try: - templates_response = client.get_templates( - page=0, size=5 - ) # Get first 5 templates - print("Successfully fetched templates:") - import json # For pretty printing - - print(json.dumps(templates_response, indent=2)) + client = SirenClient(api_key=api_key) + print("SirenClient initialized.") except Exception as e: - print(f"Error fetching templates: {e}") - - print("\nBasic usage example finished.") + print(f"Initialization Error: {e}") diff --git a/examples/templates.py b/examples/templates.py new file mode 100644 index 0000000..a88ca3a --- /dev/null +++ b/examples/templates.py @@ -0,0 +1,69 @@ +"""Examples for template-related operations using the Siren SDK.""" + +import json +import os +import sys + +from dotenv import load_dotenv + +# Ensure the 'siren' package in the parent directory can be imported: +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from siren import SirenClient + + +def run_get_templates_example(client: SirenClient): + """Runs the example for fetching templates.""" + print("--- Fetching Templates ---") + try: + templates_response = client.get_templates( + page=0, + size=2, # Get first 2 templates for brevity + ) + print("Successfully fetched templates:") + print(json.dumps(templates_response, indent=2)) + except Exception as e: + print(f"Error fetching templates: {e}") + + +def run_create_template_example(client: SirenClient): + """Runs the example for creating a template.""" + print("\n--- Creating a Template ---") + new_template_payload = { + "name": "Sample5", + "description": "A simple template created via the examples/templates.py script.", + "tagNames": ["sdk-example", "template-ops"], + "variables": [{"name": "user_name", "defaultValue": "Guest"}], + "configurations": { + "EMAIL": { + "subject": "SDK Test Email for {{user_name}} from templates.py", + "channel": "EMAIL", + "body": "

Hello {{user_name}}, this is a test from examples/templates.py!

", + "isRawHTML": True, + "isPlainText": False, + } + }, + } + try: + created_template_response = client.create_template(new_template_payload) + print("Successfully created template:") + print(json.dumps(created_template_response, indent=2)) + except Exception as e: + print(f"Error creating template: {e}") + + +if __name__ == "__main__": + load_dotenv() + + api_key = os.getenv("SIREN_API_KEY") + + if not api_key: + print( + "Error: SIREN_API_KEY is not set. Please check your .env file or environment variables." + ) + sys.exit(1) + + siren_client = SirenClient(api_key=api_key) + + run_get_templates_example(siren_client) + # run_create_template_example(siren_client) diff --git a/siren/__init__.py b/siren/__init__.py index 210cc50..1dd3719 100644 --- a/siren/__init__.py +++ b/siren/__init__.py @@ -1,12 +1,7 @@ """Siren SDK for Python.""" -# siren/__init__.py +from .client import SirenClient -# This file makes the 'siren' directory a Python package. -# We will import key classes and functions here for easier access. +__all__ = ["SirenClient"] -# For example: -# from .client import SirenClient -# from .templates import TemplateManager - -__version__ = "0.1.0" # Placeholder version +__version__ = "0.1.0" diff --git a/siren/client.py b/siren/client.py index e8c929a..6989650 100644 --- a/siren/client.py +++ b/siren/client.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional -import requests +from .templates import TemplatesManager class SirenClient: @@ -18,6 +18,9 @@ def __init__(self, api_key: str): api_key: The API key for authentication. """ self.api_key = api_key + self._templates = TemplatesManager( + api_key=self.api_key, base_url=self.BASE_API_URL + ) def get_templates( self, @@ -29,6 +32,8 @@ def get_templates( ) -> Dict[str, Any]: """Fetch templates. + Delegates to TemplatesManager.get_templates. + Args: tag_names: Filter by tag names. search: Search by field. @@ -39,35 +44,23 @@ def get_templates( Returns: A dictionary containing the API response. """ - endpoint = f"{self.BASE_API_URL}/template" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - params: Dict[str, Any] = {} - if tag_names is not None: - params["tagNames"] = tag_names - if search is not None: - params["search"] = search - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if size is not None: - params["size"] = size + return self._templates.get_templates( + tag_names=tag_names, + search=search, + sort=sort, + page=page, + size=size, + ) + + def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: + """Create a new template. + + Delegates to TemplatesManager.create_template. + + Args: + template_data: A dictionary containing the template details. - try: - response = requests.get( - endpoint, headers=headers, params=params, timeout=10 - ) - response.raise_for_status() # Raises HTTPError for bad responses (4XX or 5XX) - return response.json() - except requests.exceptions.HTTPError as http_err: - # Attempt to return JSON error from response, otherwise re-raise HTTPError - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - # For non-HTTP request issues (e.g., network, timeout) - raise req_err + Returns: + A dictionary containing the API response. + """ + return self._templates.create_template(template_data=template_data) diff --git a/siren/formatter.py b/siren/formatter.py index 1d4a699..c1a3fbf 100644 --- a/siren/formatter.py +++ b/siren/formatter.py @@ -1,5 +1 @@ """Data formatting utilities for Siren SDK.""" - -# siren/formatter.py - -# This module will handle the format_for_human functionality. diff --git a/siren/templates.py b/siren/templates.py index 88aa8d8..e312904 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -1,5 +1,99 @@ -"""Template management for Siren SDK.""" +"""Template management for the Siren SDK.""" -# siren/templates.py +from typing import Any, Dict, Optional -# This module will handle template-related functionalities. +import requests + + +class TemplatesManager: + """Manages template-related operations for the Siren API.""" + + def __init__(self, api_key: str, base_url: str): + """Initialize the TemplatesManager. + + Args: + api_key: The API key for authentication. + base_url: The base URL for the Siren API. + """ + self.api_key = api_key + self.base_url = base_url + + def get_templates( + self, + tag_names: Optional[str] = None, + search: Optional[str] = None, + sort: Optional[str] = None, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> Dict[str, Any]: + """Fetch templates. + + Args: + tag_names: Filter by tag names. + search: Search by field. + sort: Sort by field. + page: Page number. + size: Page size. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/template" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + params: Dict[str, Any] = {} + if tag_names is not None: + params["tagNames"] = tag_names + if search is not None: + params["search"] = search + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if size is not None: + params["size"] = size + + try: + response = requests.get( + endpoint, headers=headers, params=params, timeout=10 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err + + def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: + """Create a new template. + + Args: + template_data: A dictionary containing the template details. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/template" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + try: + response = requests.post( + endpoint, headers=headers, json=template_data, timeout=10 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/siren/utils.py b/siren/utils.py index c9b7383..3a8cb6e 100644 --- a/siren/utils.py +++ b/siren/utils.py @@ -1,5 +1 @@ """Utility functions for the Siren SDK.""" - -# siren/utils.py - -# This module will contain utility functions for the SDK. diff --git a/siren/webhooks.py b/siren/webhooks.py index 1fb5bd7..882e8f8 100644 --- a/siren/webhooks.py +++ b/siren/webhooks.py @@ -1,5 +1 @@ """Webhook handling for Siren SDK.""" - -# siren/webhooks.py - -# This module will handle webhook configurations and processing. diff --git a/siren/workflows.py b/siren/workflows.py index f26da33..715ec94 100644 --- a/siren/workflows.py +++ b/siren/workflows.py @@ -1,5 +1 @@ """Workflow management for Siren SDK.""" - -# siren/workflows.py - -# This module will handle workflow-related functionalities. diff --git a/tests/__init__.py b/tests/__init__.py index ca58097..5fe5917 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1 @@ """Test suite for the Siren SDK.""" - -# tests/__init__.py -# This file makes the 'tests' directory a Python package. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d599b8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Configuration and fixtures for the test suite.""" + +import os +import sys + +import pytest + +# Ensure the 'siren' package in the parent directory can be imported: +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from siren import SirenClient + + +@pytest.fixture +def client(): + """Provides a SirenClient instance for testing, using a dummy API key.""" + return SirenClient(api_key="test_api_key") diff --git a/tests/test_client.py b/tests/test_client.py index 94eaf2e..62d1ec1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,72 +1,27 @@ """Tests for the Siren API client.""" import os - -# For local development, you might need to adjust sys.path: import sys -import pytest -import requests - +# Ensure the 'siren' package in the parent directory can be imported: sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from siren.client import SirenClient - - -@pytest.fixture -def client(): - """Provides a SirenClient instance for testing.""" - return SirenClient(api_key="test_api_key") +# The 'client' fixture is automatically available from conftest.py def test_siren_client_initialization(client): """Test that the SirenClient initializes correctly.""" assert client.api_key == "test_api_key", "API key should be set on initialization" - - -def test_get_templates_success(client, requests_mock): - """Test successful retrieval of templates.""" - mock_response_data = { - "data": { - "content": [ - {"id": "tpl_1", "name": "Test Template 1"}, - {"id": "tpl_2", "name": "Test Template 2"}, - ], - "totalElements": 2, - } - } - requests_mock.get( - f"{client.BASE_API_URL}/template", - json=mock_response_data, - status_code=200, - ) - - response = client.get_templates(page=0, size=10) - assert response == mock_response_data - assert len(response["data"]["content"]) == 2 - assert response["data"]["content"][0]["name"] == "Test Template 1" - - -def test_get_templates_http_error(client, requests_mock): - """Test handling of HTTP error when getting templates.""" - error_response_data = { - "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} - } - requests_mock.get( - f"{client.BASE_API_URL}/template", - json=error_response_data, - status_code=401, - ) - - response = client.get_templates() - assert response == error_response_data - - -def test_get_templates_network_error(client, requests_mock): - """Test handling of a network error when getting templates.""" - requests_mock.get( - f"{client.BASE_API_URL}/template", exc=requests.exceptions.ConnectTimeout - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.get_templates() + assert hasattr( + client, "_templates" + ), "Client should have an internal _templates manager attribute" + assert hasattr(client, "get_templates"), "Client should have a get_templates method" + assert hasattr( + client, "create_template" + ), "Client should have a create_template method" + assert ( + client._templates.api_key == "test_api_key" + ), "Templates manager should have API key" + assert ( + client._templates.base_url == client.BASE_API_URL + ), "Templates manager should have base URL" diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..e3e15ba --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,141 @@ +"""Tests for template-related operations in the Siren API client.""" + +import os +import sys + +import pytest +import requests + +# Ensure the 'siren' package in the parent directory can be imported: +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# The 'client' fixture is automatically available from conftest.py + + +def test_get_templates_success(client, requests_mock): + """Test successful retrieval of templates.""" + mock_response_data = { + "data": { + "content": [ + {"id": "tpl_1", "name": "Test Template 1"}, + {"id": "tpl_2", "name": "Test Template 2"}, + ], + "totalElements": 2, + } + } + requests_mock.get( + f"{client._templates.base_url}/template", + json=mock_response_data, + status_code=200, + ) + + response = client.get_templates(page=0, size=10) + assert response == mock_response_data + assert len(response["data"]["content"]) == 2 + assert response["data"]["content"][0]["name"] == "Test Template 1" + + +def test_get_templates_http_error(client, requests_mock): + """Test handling of HTTP error when getting templates.""" + error_response_data = { + "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} + } + requests_mock.get( + f"{client._templates.base_url}/template", + json=error_response_data, + status_code=401, + ) + + response = client.get_templates() + assert response == error_response_data + + +def test_get_templates_network_error(client, requests_mock): + """Test handling of a network error when getting templates.""" + requests_mock.get( + f"{client._templates.base_url}/template", exc=requests.exceptions.ConnectTimeout + ) + + with pytest.raises(requests.exceptions.ConnectTimeout): + client.get_templates() + + +def test_create_template_success(client, requests_mock): + """Test successful creation of a template.""" + mock_request_payload = { + "name": "Test_Create_Template", + "description": "A test template for creation", + "tagNames": ["test", "creation"], + "variables": [{"name": "user_name", "defaultValue": "Guest"}], + "configurations": { + "EMAIL": { + "subject": "Welcome {{user_name}}!", + "channel": "EMAIL", + "body": "

Hello {{user_name}}, welcome!

", + "isRawHTML": True, + "isPlainText": False, + } + }, + } + mock_response_data = { + "data": { + "templateId": "tpl_abc123", + "templateName": "Test_Create_Template", + "draftVersionId": "ver_def456", + "channelTemplateList": [ + { + "id": "ct_email_789", + "channel": "EMAIL", + "configuration": {"channel": "EMAIL"}, + "templateVersionId": "ver_def456", + } + ], + }, + "error": None, + "errors": None, + "meta": None, + } + requests_mock.post( + f"{client._templates.base_url}/template", + json=mock_response_data, + status_code=200, + ) + + response = client.create_template(mock_request_payload) + assert response == mock_response_data + assert requests_mock.last_request.json() == mock_request_payload + + +def test_create_template_http_error(client, requests_mock): + """Test handling of HTTP error when creating a template.""" + mock_request_payload = {"name": "Invalid Template"} + error_response_data = { + "data": None, + "error": {"errorCode": "BAD_REQUEST", "message": "Bad request"}, + "errors": [ + { + "errorCode": "BAD_REQUEST", + "message": "Name is too short or missing fields", + } + ], + "meta": None, + } + requests_mock.post( + f"{client._templates.base_url}/template", + json=error_response_data, + status_code=400, + ) + + response = client.create_template(mock_request_payload) + assert response == error_response_data + + +def test_create_template_network_error(client, requests_mock): + """Test handling of a network error when creating a template.""" + mock_request_payload = {"name": "Network Error Template"} + requests_mock.post( + f"{client._templates.base_url}/template", exc=requests.exceptions.ConnectTimeout + ) + + with pytest.raises(requests.exceptions.ConnectTimeout): + client.create_template(mock_request_payload) From ab7925a83a43e2e0e37dc420f989421a9b8c1b89 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 13:10:58 +0530 Subject: [PATCH 02/42] feat: Add update_template method and related assets Implements SDK support for updating templates, including: - Core logic in TemplatesManager and SirenClient. - Unit tests, example script, and README documentation. - Client docstring cleanup. --- .gitignore | 3 ++ README.md | 27 +++++++++++++ coverage.xml | 28 +++++++++++--- examples/templates.py | 31 ++++++++++++++- siren/client.py | 20 ++++++++-- siren/templates.py | 35 +++++++++++++++++ tests/test_templates.py | 86 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index ddd918d..b173c41 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ Thumbs.db secrets.yaml *.pem *.key + +# windsurf rules +.windsurfrules diff --git a/README.md b/README.md index 9684eed..875a3ba 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is the official Python SDK for the Siren notification platform. - [Features](#features) - [`get_templates()`](#get_templates) - [`create_template()`](#create_template) + - [`update_template()`](#update_template) - [Getting Started for Package Developers](#getting-started-for-package-developers) ## Installation @@ -71,6 +72,32 @@ created_template_response = client.create_template(new_template_payload) print(created_template_response) ``` +### `update_template()` + +Updates an existing notification template. + +**Parameters:** +* `template_id` (str): The ID of the template to update. +* `template_data` (Dict[str, Any]): A dictionary containing the template fields to update. For the detailed payload structure, please refer to the official Siren API documentation. + +**Example:** +```python +# Assume 'existing_template_id' is the ID of a template you want to update +existing_template_id = "YOUR_EXISTING_TEMPLATE_ID" +update_payload = { + "name": "Updated SDK Template Name", + "description": "This template was updated via the SDK.", + "tagNames": ["sdk-updated"] +} + +if existing_template_id != "YOUR_EXISTING_TEMPLATE_ID": # Basic check before running + updated_template_response = client.update_template(existing_template_id, update_payload) + print(updated_template_response) +else: + print(f"Please replace 'YOUR_EXISTING_TEMPLATE_ID' with an actual ID to run the update_template example.") + +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index e9eac94..620fce4 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -27,16 +27,18 @@ - - - + + + + +
- + @@ -84,6 +86,20 @@ + + + + + + + + + + + + + + diff --git a/examples/templates.py b/examples/templates.py index a88ca3a..ea857bf 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -52,6 +52,34 @@ def run_create_template_example(client: SirenClient): print(f"Error creating template: {e}") +def run_update_template_example(client: SirenClient): + """Runs the example for updating a template.""" + print("\n--- Updating a Template ---") + # IMPORTANT: Replace with an actual template ID from your account to test. + template_id_to_update = "dc58f20d-bad1-4ffd-8f92-34682397100f" + update_payload = { + "name": "Updated_SDK_Example_Template", + "description": "This template was updated by the examples/templates.py script.", + "tagNames": ["sdk-example", "update-op"], + "variables": [{"name": "customer_name", "defaultValue": "Valued Customer"}], + "configurations": { + "EMAIL": { + "subject": "Updated Subject for {{customer_name}}", + "body": "

Hello {{customer_name}}, your template has been updated!

", + } + }, + } + + try: + updated_template_response = client.update_template( + template_id_to_update, update_payload + ) + print(f"Successfully updated template '{template_id_to_update}':") + print(json.dumps(updated_template_response, indent=2)) + except Exception as e: + print(f"Error updating template '{template_id_to_update}': {e}") + + if __name__ == "__main__": load_dotenv() @@ -65,5 +93,6 @@ def run_create_template_example(client: SirenClient): siren_client = SirenClient(api_key=api_key) - run_get_templates_example(siren_client) + # run_get_templates_example(siren_client) # run_create_template_example(siren_client) + run_update_template_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 6989650..3d5da7a 100644 --- a/siren/client.py +++ b/siren/client.py @@ -32,8 +32,6 @@ def get_templates( ) -> Dict[str, Any]: """Fetch templates. - Delegates to TemplatesManager.get_templates. - Args: tag_names: Filter by tag names. search: Search by field. @@ -55,8 +53,6 @@ def get_templates( def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: """Create a new template. - Delegates to TemplatesManager.create_template. - Args: template_data: A dictionary containing the template details. @@ -64,3 +60,19 @@ def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: A dictionary containing the API response. """ return self._templates.create_template(template_data=template_data) + + def update_template( + self, template_id: str, template_data: Dict[str, Any] + ) -> Dict[str, Any]: + """Update an existing template. + + Args: + template_id: The ID of the template to update. + template_data: A dictionary containing the template details to update. + + Returns: + A dictionary containing the API response. + """ + return self._templates.update_template( + template_id=template_id, template_data=template_data + ) diff --git a/siren/templates.py b/siren/templates.py index e312904..dea99c3 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -97,3 +97,38 @@ def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: raise http_err except requests.exceptions.RequestException as req_err: raise req_err + + def update_template( + self, template_id: str, template_data: Dict[str, Any] + ) -> Dict[str, Any]: + """Update an existing template. + + Args: + template_id: The ID of the template to update. + template_data: A dictionary containing the template details to update. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/template/{template_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + try: + response = requests.put( + endpoint, headers=headers, json=template_data, timeout=10 + ) + response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes + return response.json() + except requests.exceptions.HTTPError as http_err: + # Try to return the JSON error response from the API if available + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + # If the error response isn't JSON, re-raise the original HTTPError + raise http_err + except requests.exceptions.RequestException as req_err: + # For other network issues (e.g., connection error) + raise req_err diff --git a/tests/test_templates.py b/tests/test_templates.py index e3e15ba..d7e4c9d 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -139,3 +139,89 @@ def test_create_template_network_error(client, requests_mock): with pytest.raises(requests.exceptions.ConnectTimeout): client.create_template(mock_request_payload) + + +def test_update_template_success(client, requests_mock): + """Test successful update of a template.""" + template_id = "tpl_xyz789" + mock_request_payload = { + "name": "Updated_Test_Template", + "description": "An updated test template", + "tagNames": ["updated", "test"], + "variables": [{"name": "user_name", "defaultValue": "Updated Guest"}], + "configurations": { + "EMAIL": { + "subject": "Updated Welcome {{user_name}}!", + "channel": "EMAIL", + "body": "

Hello {{user_name}}, your details are updated!

", + "isRawHTML": True, + "isPlainText": False, + } + }, + } + mock_response_data = { + "data": { + "templateId": template_id, + "templateName": "Updated_Test_Template", + "draftVersionId": "ver_jkl012", + "channelTemplateList": [ + { + "id": "ct_email_345", + "channel": "EMAIL", + "configuration": {"channel": "EMAIL"}, + "templateVersionId": "ver_jkl012", + } + ], + }, + "error": None, + "errors": None, + "meta": None, + } + requests_mock.put( + f"{client._templates.base_url}/template/{template_id}", + json=mock_response_data, + status_code=200, + ) + + response = client.update_template(template_id, mock_request_payload) + assert response == mock_response_data + assert requests_mock.last_request.json() == mock_request_payload + assert requests_mock.last_request.method == "PUT" + + +def test_update_template_http_error(client, requests_mock): + """Test handling of HTTP error when updating a template.""" + template_id = "tpl_error400" + mock_request_payload = {"name": "Invalid Update"} + error_response_data = { + "data": None, + "error": {"errorCode": "BAD_REQUEST", "message": "Invalid data for update"}, + "errors": [ + { + "errorCode": "BAD_REQUEST", + "message": "Name is too short or some fields are invalid", + } + ], + "meta": None, + } + requests_mock.put( + f"{client._templates.base_url}/template/{template_id}", + json=error_response_data, + status_code=400, + ) + + response = client.update_template(template_id, mock_request_payload) + assert response == error_response_data + + +def test_update_template_network_error(client, requests_mock): + """Test handling of a network error when updating a template.""" + template_id = "tpl_network_err" + mock_request_payload = {"name": "Network Error Update"} + requests_mock.put( + f"{client._templates.base_url}/template/{template_id}", + exc=requests.exceptions.ConnectTimeout, + ) + + with pytest.raises(requests.exceptions.ConnectTimeout): + client.update_template(template_id, mock_request_payload) From d45857b369363483545fb416684cd7ec2c0927aa Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 13:18:44 +0530 Subject: [PATCH 03/42] feat: Implement delete_template method - Adds delete_template method to SDK. - Includes tests, example, and README update. --- README.md | 22 +++++++++++++ coverage.xml | 50 +++++++++++++++++++---------- examples/templates.py | 18 ++++++++++- siren/client.py | 11 +++++++ siren/templates.py | 32 +++++++++++++++++++ tests/test_templates.py | 70 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 875a3ba..17561df 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This is the official Python SDK for the Siren notification platform. - [`get_templates()`](#get_templates) - [`create_template()`](#create_template) - [`update_template()`](#update_template) + - [`delete_template()`](#delete_template) - [Getting Started for Package Developers](#getting-started-for-package-developers) ## Installation @@ -98,6 +99,27 @@ else: ``` +### `delete_template()` + +Deletes an existing notification template. + +**Parameters:** +* `template_id` (str): The ID of the template to delete. + +**Example:** +```python +# Assume 'template_id_to_delete' is the ID of a template you want to permanently delete +template_id_to_delete = "YOUR_TEMPLATE_ID_TO_DELETE" + +if template_id_to_delete != "YOUR_TEMPLATE_ID_TO_DELETE": # Basic check before running + # Consider adding a confirmation step in real applications before deleting + delete_response = client.delete_template(template_id_to_delete) + print(delete_response) +else: + print(f"Please replace 'YOUR_TEMPLATE_ID_TO_DELETE' with an actual ID to run the delete_template example.") + +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 620fce4..203b277 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -27,18 +27,20 @@ - - - - + + + + + + - + @@ -87,19 +89,35 @@ - - - - - - + + + + + - - + + - + + + + + + + + + + + + + + + + + + diff --git a/examples/templates.py b/examples/templates.py index ea857bf..b789f8c 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -80,6 +80,21 @@ def run_update_template_example(client: SirenClient): print(f"Error updating template '{template_id_to_update}': {e}") +def run_delete_template_example(client: SirenClient): + """Runs the example for deleting a template.""" + print("\n--- Deleting a Template ---") + template_id_to_delete = "b5d4cdf8-a46a-4867-aa02-c7551d3fe747" + + try: + delete_response = client.delete_template(template_id_to_delete) + print( + f"Successfully sent delete request for template '{template_id_to_delete}':" + ) + print(json.dumps(delete_response, indent=2)) + except Exception as e: + print(f"Error deleting template '{template_id_to_delete}': {e}") + + if __name__ == "__main__": load_dotenv() @@ -95,4 +110,5 @@ def run_update_template_example(client: SirenClient): # run_get_templates_example(siren_client) # run_create_template_example(siren_client) - run_update_template_example(siren_client) + # run_update_template_example(siren_client) + run_delete_template_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 3d5da7a..7b01b2a 100644 --- a/siren/client.py +++ b/siren/client.py @@ -76,3 +76,14 @@ def update_template( return self._templates.update_template( template_id=template_id, template_data=template_data ) + + def delete_template(self, template_id: str) -> Dict[str, Any]: + """Delete an existing template. + + Args: + template_id: The ID of the template to delete. + + Returns: + A dictionary containing the API response. + """ + return self._templates.delete_template(template_id=template_id) diff --git a/siren/templates.py b/siren/templates.py index dea99c3..2034cbf 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -132,3 +132,35 @@ def update_template( except requests.exceptions.RequestException as req_err: # For other network issues (e.g., connection error) raise req_err + + def delete_template(self, template_id: str) -> Dict[str, Any]: + """Delete an existing template. + + Args: + template_id: The ID of the template to delete. + + Returns: + A dictionary containing the API response (e.g., a confirmation message). + """ + endpoint = f"{self.base_url}/template/{template_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + try: + response = requests.delete(endpoint, headers=headers, timeout=10) + response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes + if response.status_code == 204: + return { + "status": "success", + "message": f"Template {template_id} deleted successfully.", + } + # For other successful responses (e.g., 200 OK with a body, though not expected for DELETE here) + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/tests/test_templates.py b/tests/test_templates.py index d7e4c9d..55eb359 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -225,3 +225,73 @@ def test_update_template_network_error(client, requests_mock): with pytest.raises(requests.exceptions.ConnectTimeout): client.update_template(template_id, mock_request_payload) + + +def test_delete_template_success(client, requests_mock): + """Test successful deletion of a template (204 No Content).""" + template_id = "tpl_todelete123" + expected_response_data = { + "status": "success", + "message": f"Template {template_id} deleted successfully.", + } + requests_mock.delete( + f"{client._templates.base_url}/template/{template_id}", + text="", # Empty body for 204 + status_code=204, + ) + + response = client.delete_template(template_id) + assert response == expected_response_data + assert requests_mock.last_request.method == "DELETE" + + +def test_delete_template_not_found_error(client, requests_mock): + """Test handling of a 404 Not Found error when deleting a template.""" + template_id = "tpl_notfound404" + error_response_data = { + "data": None, + "error": {"errorCode": "NOT_FOUND", "message": "Template not found"}, + "errors": [ + { + "errorCode": "NOT_FOUND", + "message": f"Template with id {template_id} not found", + } + ], + "meta": None, + } + requests_mock.delete( + f"{client._templates.base_url}/template/{template_id}", + json=error_response_data, + status_code=404, + ) + + response = client.delete_template(template_id) + assert response == error_response_data + + +def test_delete_template_unauthorized_error(client, requests_mock): + """Test handling of a 401 Unauthorized error when deleting a template.""" + template_id = "tpl_unauth401" + error_response_data = { + "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} + } + requests_mock.delete( + f"{client._templates.base_url}/template/{template_id}", + json=error_response_data, + status_code=401, + ) + + response = client.delete_template(template_id) + assert response == error_response_data + + +def test_delete_template_network_error(client, requests_mock): + """Test handling of a network error when deleting a template.""" + template_id = "tpl_network_error" + requests_mock.delete( + f"{client._templates.base_url}/template/{template_id}", + exc=requests.exceptions.ConnectTimeout, + ) + + with pytest.raises(requests.exceptions.ConnectTimeout): + client.delete_template(template_id) From 1033ddce7d385ed75d1984ebdd6803510e590a43 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 16:03:57 +0530 Subject: [PATCH 04/42] feat: Add publish_template method and update docs - Add method (SDK, tests, example). - Update README with and "Future Enhancements". - Refine example scripts for clarity and improved output. --- README.md | 37 +++++++++------ examples/templates.py | 32 +++++++++---- siren/client.py | 11 +++++ siren/templates.py | 26 +++++++++++ tests/test_templates.py | 99 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 17561df..cc7a84f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This is the official Python SDK for the Siren notification platform. - [`create_template()`](#create_template) - [`update_template()`](#update_template) - [`delete_template()`](#delete_template) + - [`publish_template()`](#publish_template) - [Getting Started for Package Developers](#getting-started-for-package-developers) ## Installation @@ -83,7 +84,6 @@ Updates an existing notification template. **Example:** ```python -# Assume 'existing_template_id' is the ID of a template you want to update existing_template_id = "YOUR_EXISTING_TEMPLATE_ID" update_payload = { "name": "Updated SDK Template Name", @@ -91,11 +91,8 @@ update_payload = { "tagNames": ["sdk-updated"] } -if existing_template_id != "YOUR_EXISTING_TEMPLATE_ID": # Basic check before running - updated_template_response = client.update_template(existing_template_id, update_payload) - print(updated_template_response) -else: - print(f"Please replace 'YOUR_EXISTING_TEMPLATE_ID' with an actual ID to run the update_template example.") +updated_template_response = client.update_template(existing_template_id, update_payload) +print(updated_template_response) ``` @@ -108,18 +105,28 @@ Deletes an existing notification template. **Example:** ```python -# Assume 'template_id_to_delete' is the ID of a template you want to permanently delete template_id_to_delete = "YOUR_TEMPLATE_ID_TO_DELETE" -if template_id_to_delete != "YOUR_TEMPLATE_ID_TO_DELETE": # Basic check before running - # Consider adding a confirmation step in real applications before deleting - delete_response = client.delete_template(template_id_to_delete) - print(delete_response) -else: - print(f"Please replace 'YOUR_TEMPLATE_ID_TO_DELETE' with an actual ID to run the delete_template example.") +delete_response = client.delete_template(template_id_to_delete) +print(delete_response) ``` +### `publish_template()` + +Publishes an existing notification template, making its latest draft version live. + +**Parameters:** +* `template_id` (str): The ID of the template to publish. + +**Example:** +```python +template_id_to_publish = "YOUR_TEMPLATE_ID_TO_PUBLISH" + +publish_response = client.publish_template(template_id_to_publish) +print(publish_response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. @@ -186,3 +193,7 @@ This guide will help you set up your environment to contribute to the `siren-ai` * Create a feature branch for your changes. * Commit your changes (pre-commit hooks will run). * Push your branch and open a Pull Request against the main repository branch. + +## Future Enhancements + +* **Response Models:** Introduce Pydantic models for API responses to provide a more object-oriented interface and stronger type guarantees. diff --git a/examples/templates.py b/examples/templates.py index b789f8c..dd9dc8b 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -20,7 +20,8 @@ def run_get_templates_example(client: SirenClient): page=0, size=2, # Get first 2 templates for brevity ) - print("Successfully fetched templates:") + if templates_response and templates_response.get("error") is None: + print("Successfully fetched templates:") print(json.dumps(templates_response, indent=2)) except Exception as e: print(f"Error fetching templates: {e}") @@ -46,7 +47,8 @@ def run_create_template_example(client: SirenClient): } try: created_template_response = client.create_template(new_template_payload) - print("Successfully created template:") + if created_template_response and created_template_response.get("error") is None: + print("Successfully created template:") print(json.dumps(created_template_response, indent=2)) except Exception as e: print(f"Error creating template: {e}") @@ -55,7 +57,6 @@ def run_create_template_example(client: SirenClient): def run_update_template_example(client: SirenClient): """Runs the example for updating a template.""" print("\n--- Updating a Template ---") - # IMPORTANT: Replace with an actual template ID from your account to test. template_id_to_update = "dc58f20d-bad1-4ffd-8f92-34682397100f" update_payload = { "name": "Updated_SDK_Example_Template", @@ -74,7 +75,8 @@ def run_update_template_example(client: SirenClient): updated_template_response = client.update_template( template_id_to_update, update_payload ) - print(f"Successfully updated template '{template_id_to_update}':") + if updated_template_response and updated_template_response.get("error") is None: + print(f"Successfully updated template '{template_id_to_update}':") print(json.dumps(updated_template_response, indent=2)) except Exception as e: print(f"Error updating template '{template_id_to_update}': {e}") @@ -87,14 +89,27 @@ def run_delete_template_example(client: SirenClient): try: delete_response = client.delete_template(template_id_to_delete) - print( - f"Successfully sent delete request for template '{template_id_to_delete}':" - ) + if delete_response and delete_response.get("status") == "success": + print(f"Successfully deleted template '{template_id_to_delete}':") print(json.dumps(delete_response, indent=2)) except Exception as e: print(f"Error deleting template '{template_id_to_delete}': {e}") +def run_publish_template_example(client: SirenClient): + """Runs the example for publishing a template.""" + print("\n--- Publishing a Template ---") + template_id_to_publish = "11921404-4517-48b7-82ee-fcdcf8f9c03b" + + try: + publish_response = client.publish_template(template_id_to_publish) + if publish_response and publish_response.get("error") is None: + print(f"Successfully published template '{template_id_to_publish}':") + print(json.dumps(publish_response, indent=2)) + except Exception as e: + print(f"Error publishing template '{template_id_to_publish}': {e}") + + if __name__ == "__main__": load_dotenv() @@ -111,4 +126,5 @@ def run_delete_template_example(client: SirenClient): # run_get_templates_example(siren_client) # run_create_template_example(siren_client) # run_update_template_example(siren_client) - run_delete_template_example(siren_client) + # run_delete_template_example(siren_client) + # run_publish_template_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 7b01b2a..7cd0a0e 100644 --- a/siren/client.py +++ b/siren/client.py @@ -87,3 +87,14 @@ def delete_template(self, template_id: str) -> Dict[str, Any]: A dictionary containing the API response. """ return self._templates.delete_template(template_id=template_id) + + def publish_template(self, template_id: str) -> Dict[str, Any]: + """Publish an existing template. + + Args: + template_id: The ID of the template to publish. + + Returns: + A dictionary containing the API response. + """ + return self._templates.publish_template(template_id=template_id) diff --git a/siren/templates.py b/siren/templates.py index 2034cbf..9b351b5 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -164,3 +164,29 @@ def delete_template(self, template_id: str) -> Dict[str, Any]: raise http_err except requests.exceptions.RequestException as req_err: raise req_err + + def publish_template(self, template_id: str) -> Dict[str, Any]: + """Publish an existing template. + + Args: + template_id: The ID of the template to publish. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/template/{template_id}/publish" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + try: + response = requests.patch(endpoint, headers=headers, timeout=10) + response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/tests/test_templates.py b/tests/test_templates.py index 55eb359..2987d38 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -295,3 +295,102 @@ def test_delete_template_network_error(client, requests_mock): with pytest.raises(requests.exceptions.ConnectTimeout): client.delete_template(template_id) + + +def test_publish_template_success(client, requests_mock): + """Test successful publishing of a template.""" + template_id = "tpl_pub_success" + mock_response_data = { + "data": { + "id": template_id, + "name": "Published Template", + "publishedVersion": {"status": "PUBLISHED_LATEST"}, + }, + "error": None, + } + requests_mock.patch( + f"{client._templates.base_url}/template/{template_id}/publish", + json=mock_response_data, + status_code=200, + ) + + response = client.publish_template(template_id) + assert response == mock_response_data + assert requests_mock.last_request.method == "PATCH" + + +def test_publish_template_not_found_error(client, requests_mock): + """Test handling of a 404 Not Found error when publishing a template.""" + template_id = "tpl_pub_notfound" + error_response_data = { + "data": None, + "error": {"errorCode": "NOT_FOUND", "message": "Template not found"}, + "errors": [{"errorCode": "NOT_FOUND", "message": "Template not found"}], + "meta": None, + } + requests_mock.patch( + f"{client._templates.base_url}/template/{template_id}/publish", + json=error_response_data, + status_code=404, + ) + + response = client.publish_template(template_id) + assert response == error_response_data + + +def test_publish_template_unauthorized_error(client, requests_mock): + """Test handling of a 401 Unauthorized error when publishing a template.""" + template_id = "tpl_pub_unauth" + error_response_data = { + "data": None, + "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"}, + "errors": [{"errorCode": "UNAUTHORISED", "message": "Invalid API Key"}], + "meta": None, + } + requests_mock.patch( + f"{client._templates.base_url}/template/{template_id}/publish", + json=error_response_data, + status_code=401, + ) + + response = client.publish_template(template_id) + assert response == error_response_data + + +def test_publish_template_bad_request_error(client, requests_mock): + """Test handling of a 400 Bad Request error when publishing a template.""" + template_id = "tpl_pub_badreq" + error_response_data = { + "data": None, + "error": { + "errorCode": "BAD_REQUEST", + "message": "Template has no versions to publish", + }, + "errors": [ + { + "errorCode": "BAD_REQUEST", + "message": "Template has no versions to publish", + } + ], + "meta": None, + } + requests_mock.patch( + f"{client._templates.base_url}/template/{template_id}/publish", + json=error_response_data, + status_code=400, + ) + + response = client.publish_template(template_id) + assert response == error_response_data + + +def test_publish_template_network_error(client, requests_mock): + """Test handling of a network error when publishing a template.""" + template_id = "tpl_pub_network_err" + requests_mock.patch( + f"{client._templates.base_url}/template/{template_id}/publish", + exc=requests.exceptions.ConnectTimeout, + ) + + with pytest.raises(requests.exceptions.ConnectTimeout): + client.publish_template(template_id) From af958e7486d3324c7473b65f2ef37756cb86a2ec Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 16:11:11 +0530 Subject: [PATCH 05/42] feat: Implement create_channel_configurations API method - Add create_channel_configurations to TemplatesManager and SireClient. - Include comprehensive pytest tests, covering success, errors, and non-JSON responses. - Add usage example in examples/templates.py. - Update README.md with documentation for the new method. - Improve test coverage for non-JSON HTTP error handling. --- README.md | 29 ++++++++ coverage.xml | 52 +++++++++++--- examples/templates.py | 39 +++++++++++ siren/client.py | 16 +++++ siren/templates.py | 38 ++++++++++ tests/test_templates.py | 150 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 314 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index cc7a84f..e2164e1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This is the official Python SDK for the Siren notification platform. - [`update_template()`](#update_template) - [`delete_template()`](#delete_template) - [`publish_template()`](#publish_template) + - [`create_channel_configurations()`](#create_channel_configurations) - [Getting Started for Package Developers](#getting-started-for-package-developers) ## Installation @@ -127,6 +128,34 @@ publish_response = client.publish_template(template_id_to_publish) print(publish_response) ``` +### `create_channel_configurations()` + +Creates or updates the channel-specific configurations (e.g., for EMAIL, SMS) for a given template. + +**Parameters:** +* `template_id` (str): The ID of the template for which to create/update channel configurations. +* `configurations` (Dict[str, Any]): A dictionary where keys are channel types (e.g., "EMAIL", "SMS") and values are the respective configuration objects. For the detailed payload structure for each channel, please refer to the official Siren API documentation. + +**Example:** +```python +template_id = "YOUR_TEMPLATE_ID" +channel_configs_payload = { + "SMS": { + "body": "New SMS content via SDK for {{variable_name}}", + "channel": "SMS", # Ensure channel is specified within its config + "isFlash": False + }, + "EMAIL": { + "subject": "Channel Config Update for {{variable_name}}", + "body": "

Updated email body for channel config.

", + "channel": "EMAIL" # Ensure channel is specified + } +} + +response = client.create_channel_configurations(template_id, channel_configs_payload) +print(response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 203b277..eccc738 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -34,13 +34,17 @@ + + + + - + @@ -110,14 +114,42 @@ - - - - - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/templates.py b/examples/templates.py index dd9dc8b..ab15664 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -110,6 +110,44 @@ def run_publish_template_example(client: SirenClient): print(f"Error publishing template '{template_id_to_publish}': {e}") +def run_create_channel_configurations_example(client: SirenClient): + """Runs the example for creating channel configurations for a template.""" + print("\n--- Creating Channel Configurations for Template ---") + template_id_for_channel_config = ( + "11921404-4517-48b7-82ee-fcdcf8f9c03b" # Replace with an actual template ID + ) + channel_configurations_payload = { + "SMS": { + "body": "Hello from Siren SDK! This is an SMS channel config.", + "channel": "SMS", + "isFlash": False, + "isUnicode": False, + }, + "EMAIL": { + "subject": "Siren SDK Test - Email Channel Config", + "channel": "EMAIL", + "body": "

Hello from Siren SDK! This is an email channel configuration.

", + "attachments": [], + "isRawHTML": True, + "isPlainText": False, + }, + } + + try: + response = client.create_channel_configurations( + template_id_for_channel_config, channel_configurations_payload + ) + if response and response.get("error") is None: + print( + f"Successfully created/updated channel configurations for template '{template_id_for_channel_config}':" + ) + print(json.dumps(response, indent=2)) + except Exception as e: + print( + f"Error creating/updating channel configurations for template '{template_id_for_channel_config}': {e}" + ) + + if __name__ == "__main__": load_dotenv() @@ -128,3 +166,4 @@ def run_publish_template_example(client: SirenClient): # run_update_template_example(siren_client) # run_delete_template_example(siren_client) # run_publish_template_example(siren_client) + # run_create_channel_configurations_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 7cd0a0e..1fb8533 100644 --- a/siren/client.py +++ b/siren/client.py @@ -98,3 +98,19 @@ def publish_template(self, template_id: str) -> Dict[str, Any]: A dictionary containing the API response. """ return self._templates.publish_template(template_id=template_id) + + def create_channel_configurations( + self, template_id: str, configurations: Dict[str, Any] + ) -> Dict[str, Any]: + """Create or update channel configurations for a template. + + Args: + template_id: The ID of the template. + configurations: A dictionary containing the channel configurations. + + Returns: + A dictionary containing the API response. + """ + return self._templates.create_channel_configurations( + template_id=template_id, configurations=configurations + ) diff --git a/siren/templates.py b/siren/templates.py index 9b351b5..87291f6 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -190,3 +190,41 @@ def publish_template(self, template_id: str) -> Dict[str, Any]: raise http_err except requests.exceptions.RequestException as req_err: raise req_err + + def create_channel_configurations( + self, template_id: str, configurations: Dict[str, Any] + ) -> Dict[str, Any]: + """Create or update channel configurations for a template. + + Args: + template_id: The ID of the template. + configurations: A dictionary containing the channel configurations. + + Example: + { + "SMS": {"body": "...", "channel": "SMS", ...}, + "EMAIL": {"subject": "...", "body": "...", ...} + } + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/template/{template_id}/channel-templates" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + try: + response = requests.post( + endpoint, headers=headers, json=configurations, timeout=10 + ) + response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/tests/test_templates.py b/tests/test_templates.py index 2987d38..6f5e4da 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -394,3 +394,153 @@ def test_publish_template_network_error(client, requests_mock): with pytest.raises(requests.exceptions.ConnectTimeout): client.publish_template(template_id) + + +def test_create_channel_configurations_success(client, requests_mock): + """Test successful creation of channel configurations.""" + template_id = "tpl_test123" + mock_request_payload = { + "SMS": { + "body": "Test SMS body for channel config", + "channel": "SMS", + "isFlash": False, + "isUnicode": False, + }, + "EMAIL": { + "subject": "Test Email Subject for channel config", + "channel": "EMAIL", + "body": "

Test Email Body for channel config

", + "attachments": [], + "isRawHTML": True, + "isPlainText": False, + }, + } + mock_response_data = { + "data": mock_request_payload, # Assuming API returns the created configs in 'data' + "error": None, + "errors": None, + "meta": None, + } + requests_mock.post( + f"{client._templates.base_url}/template/{template_id}/channel-templates", + json=mock_response_data, + status_code=200, + ) + + response = client.create_channel_configurations(template_id, mock_request_payload) + assert response == mock_response_data + assert requests_mock.last_request.json() == mock_request_payload + assert requests_mock.last_request.method == "POST" + + +def test_create_channel_configurations_bad_request(client, requests_mock): + """Test handling of a 400 Bad Request error for channel configurations.""" + template_id = "tpl_badreq400" + mock_request_payload = {"INVALID_CHANNEL": {"body": "invalid"}} + error_response_data = { + "data": None, + "error": { + "errorCode": "BAD_REQUEST", + "message": "Invalid channel configuration provided.", + }, + "errors": [ + { + "errorCode": "BAD_REQUEST", + "message": "Channel type INVALID_CHANNEL not supported.", + } + ], + "meta": None, + } + requests_mock.post( + f"{client._templates.base_url}/template/{template_id}/channel-templates", + json=error_response_data, + status_code=400, + ) + + response = client.create_channel_configurations(template_id, mock_request_payload) + assert response == error_response_data + + +def test_create_channel_configurations_unauthorized(client, requests_mock): + """Test handling of a 401 Unauthorized error for channel configurations.""" + template_id = "tpl_unauth401" + mock_request_payload = {"SMS": {"body": "test"}} + error_response_data = { + "data": None, + "error": {"errorCode": "UNAUTHORISED", "message": "Authentication required."}, + "errors": [ + { + "errorCode": "UNAUTHORISED", + "message": "Valid API key is missing or invalid.", + } + ], + "meta": None, + } + requests_mock.post( + f"{client._templates.base_url}/template/{template_id}/channel-templates", + json=error_response_data, + status_code=401, + ) + + response = client.create_channel_configurations(template_id, mock_request_payload) + assert response == error_response_data + + +def test_create_channel_configurations_not_found(client, requests_mock): + """Test handling of a 404 Not Found error (template_id) for channel configurations.""" + template_id = "tpl_notfound404" + mock_request_payload = {"SMS": {"body": "test"}} + error_response_data = { + "data": None, + "error": { + "errorCode": "NOT_FOUND", + "message": f"Template with id {template_id} not found.", + }, + "errors": [ + { + "errorCode": "NOT_FOUND", + "message": "The requested template does not exist.", + } + ], + "meta": None, + } + requests_mock.post( + f"{client._templates.base_url}/template/{template_id}/channel-templates", + json=error_response_data, + status_code=404, + ) + + response = client.create_channel_configurations(template_id, mock_request_payload) + assert response == error_response_data + + +def test_create_channel_configurations_network_error(client, requests_mock): + """Test handling of a network error for channel configurations.""" + template_id = "tpl_network_error" + mock_request_payload = {"SMS": {"body": "test"}} + requests_mock.post( + f"{client._templates.base_url}/template/{template_id}/channel-templates", + exc=requests.exceptions.ConnectTimeout, + ) + + with pytest.raises(requests.exceptions.ConnectTimeout): + client.create_channel_configurations(template_id, mock_request_payload) + + +def test_create_channel_configurations_http_error_non_json_response( + client, requests_mock +): + """Test HTTP error with non-JSON response for channel configurations.""" + template_id = "tpl_http_non_json" + mock_request_payload = {"SMS": {"body": "test"}} + requests_mock.post( + f"{client._templates.base_url}/template/{template_id}/channel-templates", + text="Internal Server Error - Not JSON", # Non-JSON response + status_code=500, + ) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client.create_channel_configurations(template_id, mock_request_payload) + + assert excinfo.value.response.status_code == 500 + assert "Internal Server Error - Not JSON" in excinfo.value.response.text From 46d83b9d7fcaa00d15a77f7f3a1399e3104af7ff Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 16:51:31 +0530 Subject: [PATCH 06/42] feat: Implement get_channel_templates method - Add get_channel_templates to TemplatesManager and SirenClient - Include comprehensive pytest tests for success and error scenarios - Add usage example in examples/templates.py - Update README.md with documentation for the new method - Refine docstrings for clarity (template version) --- README.md | 33 ++++++++++ coverage.xml | 51 +++++++++------ examples/templates.py | 23 +++++++ siren/client.py | 31 +++++++++ siren/formatter.py | 1 - siren/templates.py | 55 ++++++++++++++++ siren/utils.py | 1 - siren/webhooks.py | 1 - tests/test_templates.py | 139 ++++++++++++++++++++++++++++++++++++++-- 9 files changed, 309 insertions(+), 26 deletions(-) delete mode 100644 siren/formatter.py delete mode 100644 siren/utils.py delete mode 100644 siren/webhooks.py diff --git a/README.md b/README.md index e2164e1..20c701a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This is the official Python SDK for the Siren notification platform. - [`delete_template()`](#delete_template) - [`publish_template()`](#publish_template) - [`create_channel_configurations()`](#create_channel_configurations) + - [`get_channel_templates()`](#get_channel_templates) - [Getting Started for Package Developers](#getting-started-for-package-developers) ## Installation @@ -156,6 +157,38 @@ response = client.create_channel_configurations(template_id, channel_configs_pay print(response) ``` +### `get_channel_templates()` + +Retrieves channel-specific configurations for a specific version of a notification template. + +**Parameters:** +* `version_id` (str): The ID of the template version for which to fetch channel templates. +* Optional query parameters: + * `channel` (str): Filter by a specific channel (e.g., "EMAIL", "SMS"). + * `search` (str): Search term to filter channel templates. + * `sort` (str): Sort order (e.g., "channel,asc"). + * `page` (int): Page number for pagination. + * `size` (int): Number of items per page. + +**Example:** +```python +# Replace with an actual template version ID +template_version_id = "YOUR_TEMPLATE_VERSION_ID" + +# Get all channel templates for a version +channel_templates_response = client.get_channel_templates(version_id=template_version_id) +print(channel_templates_response) + +# Get SMS channel templates for a version, first page, 5 items +sms_channel_templates = client.get_channel_templates( + version_id=template_version_id, + channel="SMS", + page=0, + size=5 +) +print(sms_channel_templates) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index eccc738..179ebd6 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -38,13 +38,11 @@ + + - - - - - + @@ -137,11 +135,10 @@ - - + - + @@ -150,16 +147,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - diff --git a/examples/templates.py b/examples/templates.py index ab15664..45b68fc 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -148,6 +148,28 @@ def run_create_channel_configurations_example(client: SirenClient): ) +def run_get_channel_templates_example(client: SirenClient): + """Runs the example for fetching channel templates for a template version.""" + print("\n--- Fetching Channel Templates for a Version ---") + version_id_to_fetch = "9138125c-d242-4b17-ae0e-16ade9d06568" + + try: + response = client.get_channel_templates( + version_id=version_id_to_fetch, + page=0, # Optional: get the first page + size=5, # Optional: get up to 5 channel templates + ) + if response and response.get("error") is None: + print( + f"Successfully fetched channel templates for version '{version_id_to_fetch}':" + ) + print(json.dumps(response, indent=2)) + except Exception as e: + print( + f"Error fetching channel templates for version '{version_id_to_fetch}': {e}" + ) + + if __name__ == "__main__": load_dotenv() @@ -167,3 +189,4 @@ def run_create_channel_configurations_example(client: SirenClient): # run_delete_template_example(siren_client) # run_publish_template_example(siren_client) # run_create_channel_configurations_example(siren_client) + run_get_channel_templates_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 1fb8533..9641d46 100644 --- a/siren/client.py +++ b/siren/client.py @@ -114,3 +114,34 @@ def create_channel_configurations( return self._templates.create_channel_configurations( template_id=template_id, configurations=configurations ) + + def get_channel_templates( + self, + version_id: str, + channel: Optional[str] = None, + search: Optional[str] = None, + sort: Optional[str] = None, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> Dict[str, Any]: + """Fetch channel templates for a specific template version. + + Args: + version_id: The ID of the template version. + channel: Filter by channel type (e.g., "EMAIL", "SMS"). + search: Search by field. + sort: Sort by field. + page: Page number. + size: Page size. + + Returns: + A dictionary containing the API response. + """ + return self._templates.get_channel_templates( + version_id=version_id, + channel=channel, + search=search, + sort=sort, + page=page, + size=size, + ) diff --git a/siren/formatter.py b/siren/formatter.py deleted file mode 100644 index c1a3fbf..0000000 --- a/siren/formatter.py +++ /dev/null @@ -1 +0,0 @@ -"""Data formatting utilities for Siren SDK.""" diff --git a/siren/templates.py b/siren/templates.py index 87291f6..01f03a7 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -228,3 +228,58 @@ def create_channel_configurations( raise http_err except requests.exceptions.RequestException as req_err: raise req_err + + def get_channel_templates( + self, + version_id: str, + channel: Optional[str] = None, + search: Optional[str] = None, + sort: Optional[str] = None, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> Dict[str, Any]: + """Fetch channel templates for a specific template version. + + Args: + version_id: The ID of the template version. + channel: Filter by channel type (e.g., "EMAIL", "SMS"). + search: Search by field. + sort: Sort by field. + page: Page number. + size: Page size. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/template/versions/{version_id}/channel-templates" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + params: Dict[str, Any] = {} + if channel is not None: + params["channel"] = channel + if search is not None: + params["search"] = search + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if size is not None: + params["size"] = size + + try: + response = requests.get( + endpoint, headers=headers, params=params, timeout=10 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + # If the error response isn't JSON, re-raise the original HTTPError + raise http_err + except requests.exceptions.RequestException as req_err: + # For other network issues (e.g., connection error) + raise req_err diff --git a/siren/utils.py b/siren/utils.py deleted file mode 100644 index 3a8cb6e..0000000 --- a/siren/utils.py +++ /dev/null @@ -1 +0,0 @@ -"""Utility functions for the Siren SDK.""" diff --git a/siren/webhooks.py b/siren/webhooks.py deleted file mode 100644 index 882e8f8..0000000 --- a/siren/webhooks.py +++ /dev/null @@ -1 +0,0 @@ -"""Webhook handling for Siren SDK.""" diff --git a/tests/test_templates.py b/tests/test_templates.py index 6f5e4da..690446a 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -531,16 +531,145 @@ def test_create_channel_configurations_http_error_non_json_response( client, requests_mock ): """Test HTTP error with non-JSON response for channel configurations.""" - template_id = "tpl_http_non_json" - mock_request_payload = {"SMS": {"body": "test"}} + template_id = "tpl_non_json_error" + mock_request_payload = { + "EMAIL": {"subject": "Test", "body": "Body", "channel": "EMAIL"} + } requests_mock.post( f"{client._templates.base_url}/template/{template_id}/channel-templates", - text="Internal Server Error - Not JSON", # Non-JSON response + text="Internal Server Error", status_code=500, ) with pytest.raises(requests.exceptions.HTTPError) as excinfo: client.create_channel_configurations(template_id, mock_request_payload) + assert "500 Server Error" in str(excinfo.value) + assert ( + "Internal Server Error" in excinfo.value.response.text + ) + + +def test_get_channel_templates_success(client, requests_mock): + """Test successful retrieval of channel templates for a version.""" + version_id = "ver_123xyz" + mock_response_data = { + "data": { + "content": [ + {"channel": "SMS", "configuration": {"channel": "SMS"}}, + {"channel": "EMAIL", "configuration": {"channel": "EMAIL"}}, + ], + "totalElements": 2, + } + } + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + json=mock_response_data, + status_code=200, + ) + + response = client.get_channel_templates(version_id=version_id) + assert response == mock_response_data + assert len(response["data"]["content"]) == 2 + assert requests_mock.last_request.qs == {} + + +def test_get_channel_templates_success_with_params(client, requests_mock): + """Test successful retrieval of channel templates with query parameters.""" + version_id = "ver_456abc" + mock_response_data = { + "data": {"content": [{"channel": "PUSH", "configuration": {"channel": "PUSH"}}]} + } + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + json=mock_response_data, + status_code=200, + ) + + response = client.get_channel_templates( + version_id=version_id, + channel="PUSH", + search="config_detail", + sort="channel,asc", + page=1, + size=5, + ) + assert response == mock_response_data + assert requests_mock.last_request.qs == { + "channel": ["push"], # Changed to lowercase + "search": ["config_detail"], + "sort": ["channel,asc"], + "page": ["1"], + "size": ["5"], + } + + +def test_get_channel_templates_bad_request_error(client, requests_mock): + """Test 400 Bad Request error for get_channel_templates.""" + version_id = "ver_invalid_format" + error_response_data = { + "error": {"errorCode": "BAD_REQUEST", "message": "Invalid version ID format"} + } + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + json=error_response_data, + status_code=400, + ) + response = client.get_channel_templates(version_id=version_id) + assert response == error_response_data + + +def test_get_channel_templates_unauthorized_error(client, requests_mock): + """Test 401 Unauthorized error for get_channel_templates.""" + version_id = "ver_789def" + error_response_data = { + "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} + } + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + json=error_response_data, + status_code=401, + ) + response = client.get_channel_templates(version_id=version_id) + assert response == error_response_data - assert excinfo.value.response.status_code == 500 - assert "Internal Server Error - Not JSON" in excinfo.value.response.text + +def test_get_channel_templates_not_found_error(client, requests_mock): + """Test 404 Not Found error for get_channel_templates.""" + version_id = "ver_not_exists" + error_response_data = { + "error": {"errorCode": "NOT_FOUND", "message": "Version not found"} + } + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + json=error_response_data, + status_code=404, + ) + response = client.get_channel_templates(version_id=version_id) + assert response == error_response_data + + +def test_get_channel_templates_network_error(client, requests_mock): + """Test network error for get_channel_templates.""" + version_id = "ver_network_issue" + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + exc=requests.exceptions.ConnectionError, + ) + with pytest.raises(requests.exceptions.ConnectionError): + client.get_channel_templates(version_id=version_id) + + +def test_get_channel_templates_http_error_non_json_response(client, requests_mock): + """Test HTTP error with non-JSON response for get_channel_templates.""" + version_id = "ver_html_error" + requests_mock.get( + f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", + text="Internal Server Error", + status_code=500, + ) + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client.get_channel_templates(version_id=version_id) + assert "500 Server Error" in str(excinfo.value) + assert ( + "Internal Server Error" in excinfo.value.response.text + ) From 06a76463eba3ff91ac6f115b788d10d87eece062 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 17:06:07 +0530 Subject: [PATCH 07/42] feat: Add trigger_workflow API method - Implement Trigger Workflow API endpoint (/api/v2/workflows/trigger) - Add WorkflowsManager and integrate into SirenClient - Refactor base URL handling for API versioning - Include comprehensive tests and usage example - Update README with documentation for the new method --- README.md | 64 ++++++++++++++--- coverage.xml | 83 ++++++++++++++------- examples/workflows.py | 55 ++++++++++++++ siren/client.py | 27 ++++++- siren/templates.py | 4 +- siren/workflows.py | 70 ++++++++++++++++++ tests/test_client.py | 4 +- tests/test_workflows.py | 155 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 420 insertions(+), 42 deletions(-) create mode 100644 examples/workflows.py create mode 100644 tests/test_workflows.py diff --git a/README.md b/README.md index 20c701a..f6b6b47 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,25 @@ This is the official Python SDK for the Siren notification platform. ## Table of Contents -- [Installation](#installation) -- [Basic Usage](#basic-usage) -- [Features](#features) - - [`get_templates()`](#get_templates) - - [`create_template()`](#create_template) - - [`update_template()`](#update_template) - - [`delete_template()`](#delete_template) - - [`publish_template()`](#publish_template) - - [`create_channel_configurations()`](#create_channel_configurations) - - [`get_channel_templates()`](#get_channel_templates) -- [Getting Started for Package Developers](#getting-started-for-package-developers) +- [Siren AI Python SDK (`siren-ai`)](#siren-ai-python-sdk-siren-ai) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Basic Usage](#basic-usage) + - [Features](#features) + - [`get_templates()`](#get_templates) + - [`create_template()`](#create_template) + - [`update_template()`](#update_template) + - [`delete_template()`](#delete_template) + - [`publish_template()`](#publish_template) + - [`create_channel_configurations()`](#create_channel_configurations) + - [`get_channel_templates()`](#get_channel_templates) + - [`trigger_workflow()`](#trigger_workflow) + - [Getting Started for Package Developers](#getting-started-for-package-developers) + - [Prerequisites](#prerequisites) + - [Setup Steps](#setup-steps) + - [Code Style \& Linting](#code-style--linting) + - [Submitting Changes](#submitting-changes) + - [Future Enhancements](#future-enhancements) ## Installation @@ -189,6 +197,40 @@ sms_channel_templates = client.get_channel_templates( print(sms_channel_templates) ``` +### `trigger_workflow()` + +Triggers a specified workflow with the given data and notification payloads. + +**Parameters:** +* `workflow_name` (str): The name of the workflow to be executed. +* `data` (Optional[Dict[str, Any]]): Common data that will be used across all workflow executions. Defaults to `None`. +* `notify` (Optional[Dict[str, Any]]): Specific data for this particular workflow execution. Defaults to `None`. + +**Example:** +```python +workflow_to_trigger = "otp_workflow" +data_payload = { + "subject": "Your One-Time Password", + "user_id": "user_12345" +} +notify_payload = { + "notificationType": "email", + "recipient": "customer@example.com", + "name": "John Doe" +} + +trigger_response = client.trigger_workflow( + workflow_name=workflow_to_trigger, + data=data_payload, + notify=notify_payload +) +print(trigger_response) + +# Example: Triggering a workflow with only the name +minimal_trigger_response = client.trigger_workflow(workflow_name="simple_workflow") +print(minimal_trigger_response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 179ebd6..0c0d342 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -21,25 +21,29 @@ - - - - + + + + + - - - - - + + + + + - - - - - - - + + + + + + + + + + @@ -148,9 +152,12 @@ - + + - + + + @@ -159,25 +166,49 @@ - - + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/workflows.py b/examples/workflows.py new file mode 100644 index 0000000..62bd566 --- /dev/null +++ b/examples/workflows.py @@ -0,0 +1,55 @@ +# examples/workflows.py +"""Example script for demonstrating Siren SDK workflow operations.""" + +import os +import sys + +from siren import SirenClient + +# This allows running the script directly from the examples directory +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +def run_trigger_workflow_example(client: SirenClient): + """Demonstrates triggering a workflow using the Siren SDK.""" + print("Attempting to trigger a workflow with data and notify parameters...") + workflow_name = "sampleWorkflow" + try: + data_payload = {"subject": "SDK Test OTP", "user_id": "12345"} + notify_payload = { + "notificationType": "email", + "recipient": "test_user@example.com", + "name": "Test User", + } + + response = client.trigger_workflow( + workflow_name=workflow_name, data=data_payload, notify=notify_payload + ) + print("Trigger Workflow Response (with data/notify):") + print(response) + + except Exception as e: + print(f"An error occurred while triggering workflow '{workflow_name}': {e}") + + print("\nAttempting to trigger a workflow with only the workflow name...") + minimal_workflow_name = "another_workflow_name" + try: + response_minimal = client.trigger_workflow(workflow_name=minimal_workflow_name) + print(f"Trigger Workflow Response (minimal for '{minimal_workflow_name}'):") + print(response_minimal) + + except Exception as e: + print( + f"An error occurred while triggering workflow '{minimal_workflow_name}': {e}" + ) + + +if __name__ == "__main__": + api_key = os.environ.get("SIREN_API_KEY") + if not api_key: + print("Error: SIREN_API_KEY environment variable not set.") + print("Please set it before running the example.") + sys.exit(1) + + siren_client = SirenClient(api_key=api_key) + run_trigger_workflow_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 9641d46..dc9f514 100644 --- a/siren/client.py +++ b/siren/client.py @@ -3,13 +3,14 @@ from typing import Any, Dict, Optional from .templates import TemplatesManager +from .workflows import WorkflowsManager class SirenClient: """Client for interacting with the Siren API.""" # TODO: Implement logic to select API URL based on API key type (dev/prod) or environment variable - BASE_API_URL = "https://api.dev.trysiren.io/api/v1/public" + BASE_API_URL = "https://api.dev.trysiren.io" # General base URL def __init__(self, api_key: str): """Initialize the SirenClient. @@ -21,6 +22,10 @@ def __init__(self, api_key: str): self._templates = TemplatesManager( api_key=self.api_key, base_url=self.BASE_API_URL ) + self._workflows = WorkflowsManager( + api_key=self.api_key, + base_url=self.BASE_API_URL, # Note: WorkflowsManager uses /api/v2 internally + ) def get_templates( self, @@ -145,3 +150,23 @@ def get_channel_templates( page=page, size=size, ) + + def trigger_workflow( + self, + workflow_name: str, + data: Optional[Dict[str, Any]] = None, + notify: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Triggers a workflow with the given name and payload. + + Args: + workflow_name: The name of the workflow to execute. + data: Common data for all workflow executions. + notify: Specific data for this workflow execution. + + Returns: + A dictionary containing the API response. + """ + return self._workflows.trigger_workflow( + workflow_name=workflow_name, data=data, notify=notify + ) diff --git a/siren/templates.py b/siren/templates.py index 01f03a7..b8651bc 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -13,10 +13,10 @@ def __init__(self, api_key: str, base_url: str): Args: api_key: The API key for authentication. - base_url: The base URL for the Siren API. + base_url: The general base URL for the Siren API (e.g., 'https://api.trysiren.io'). """ self.api_key = api_key - self.base_url = base_url + self.base_url = f"{base_url}/api/v1/public" def get_templates( self, diff --git a/siren/workflows.py b/siren/workflows.py index 715ec94..68165da 100644 --- a/siren/workflows.py +++ b/siren/workflows.py @@ -1 +1,71 @@ """Workflow management for Siren SDK.""" + +from typing import Any, Dict, Optional + +import requests + + +class WorkflowsManager: + """Manages workflow-related operations for the Siren API.""" + + def __init__(self, base_url: str, api_key: str): + """Initializes the WorkflowsManager. + + Args: + base_url: The general base URL for the Siren API (e.g., 'https://api.trysiren.io'). + api_key: The API key for authentication. + """ + self.base_url = f"{base_url}/api/v2" + self.api_key = api_key + + def trigger_workflow( + self, + workflow_name: str, + data: Optional[Dict[str, Any]] = None, + notify: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Triggers a workflow with the given name and payload. + + Args: + workflow_name: The name of the workflow to execute. + data: Common data for all workflow executions. + notify: Specific data for this workflow execution. + + Returns: + A dictionary containing the API response. + """ + endpoint = ( + f"{self.base_url}/workflows/trigger" # self.base_url now includes /api/v2 + ) + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + payload: Dict[str, Any] = {"workflowName": workflow_name} + if data is not None: + payload["data"] = data + if notify is not None: + payload["notify"] = notify + + try: + response = requests.post( + endpoint, headers=headers, json=payload, timeout=10 + ) + response.raise_for_status() # Raises HTTPError for 4XX/5XX + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + # If the error response is not JSON, re-raise the HTTPError + # with the response text for better debugging. + new_err = requests.exceptions.HTTPError( + f"{http_err}\nResponse text: {http_err.response.text}", + response=http_err.response, + ) + raise new_err from http_err + except requests.exceptions.RequestException as req_err: + # For other request errors (e.g., connection issues) + raise req_err diff --git a/tests/test_client.py b/tests/test_client.py index 62d1ec1..1d36e83 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,5 +23,5 @@ def test_siren_client_initialization(client): client._templates.api_key == "test_api_key" ), "Templates manager should have API key" assert ( - client._templates.base_url == client.BASE_API_URL - ), "Templates manager should have base URL" + client._templates.base_url == f"{client.BASE_API_URL}/api/v1/public" + ), "Templates manager should construct its specific v1 base URL" diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..5e2e9ea --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,155 @@ +# tests/test_workflows.py +"""Tests for Siren SDK Workflow operations.""" + +import pytest +import requests +from requests_mock import Mocker as RequestsMocker + +from siren import SirenClient + +# Constants for testing +API_KEY = "test_api_key_workflow" # Use a distinct key for clarity if needed +WORKFLOW_NAME = "test_otp_workflow" +# Access BASE_API_URL as a class attribute of SirenClient +MOCK_V2_BASE = f"{SirenClient.BASE_API_URL}/api/v2" # Construct the v2 base for mocking + + +@pytest.fixture +def client() -> SirenClient: + """Provides a SirenClient instance for testing.""" + return SirenClient(api_key=API_KEY) + + +def test_trigger_workflow_success_with_all_params( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow with all parameters successfully.""" + request_data = {"subject": "otp verification"} + request_notify = {"notificationType": "email", "recipient": "example@example.com"} + expected_response = { + "data": { + "requestId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "workflowExecutionId": "b2c3d4e5-f6a7-8901-2345-67890abcdef0", + }, + "error": None, + "errors": None, + "meta": None, + } + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + + requests_mock.post(mock_url, json=expected_response, status_code=200) + + response = client.trigger_workflow( + workflow_name=WORKFLOW_NAME, data=request_data, notify=request_notify + ) + + assert response == expected_response + history = requests_mock.request_history + assert len(history) == 1 + assert history[0].method == "POST" + assert history[0].url == mock_url + assert history[0].json() == { + "workflowName": WORKFLOW_NAME, + "data": request_data, + "notify": request_notify, + } + assert history[0].headers["Authorization"] == f"Bearer {API_KEY}" + + +def test_trigger_workflow_success_minimal_params( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow with only workflow_name successfully.""" + expected_response = { + "data": {"requestId": "uuid1", "workflowExecutionId": "uuid2"}, + "error": None, + "errors": None, + "meta": None, + } + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + requests_mock.post(mock_url, json=expected_response, status_code=200) + + response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) + + assert response == expected_response + history = requests_mock.request_history + assert len(history) == 1 + assert history[0].json() == { + "workflowName": WORKFLOW_NAME + } # data and notify are optional + + +# Error handling tests (similar to test_templates.py) + + +def test_trigger_workflow_http_400_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow handles HTTP 400 Bad Request error.""" + error_response = { + "data": None, + "error": {"errorCode": "BAD_REQUEST", "message": "Bad request"}, + "errors": [{"errorCode": "BAD_REQUEST", "message": "Bad request"}], + "meta": None, + } + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + requests_mock.post(mock_url, json=error_response, status_code=400) + + response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) + assert response == error_response + + +def test_trigger_workflow_http_401_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow handles HTTP 401 Unauthorized error.""" + error_response = {"detail": "Authentication credentials were not provided."} + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + requests_mock.post(mock_url, json=error_response, status_code=401) + + response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) + assert response == error_response + + +def test_trigger_workflow_http_404_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow handles HTTP 404 Not Found error.""" + error_response = {"detail": "Not found."} + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + requests_mock.post(mock_url, json=error_response, status_code=404) + + response = client.trigger_workflow(workflow_name="non_existent_workflow") + assert response == error_response + + +def test_trigger_workflow_network_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow handles a network error.""" + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + requests_mock.post( + mock_url, exc=requests.exceptions.ConnectionError("Connection failed") + ) + + with pytest.raises( + requests.exceptions.RequestException + ): # More general than ConnectionError + client.trigger_workflow(workflow_name=WORKFLOW_NAME) + + +def test_trigger_workflow_http_error_non_json_response( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_workflow handles HTTP error with non-JSON response.""" + mock_url = f"{MOCK_V2_BASE}/workflows/trigger" + non_json_error_text = "Service Unavailable" + requests_mock.post(mock_url, text=non_json_error_text, status_code=503) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client.trigger_workflow(workflow_name=WORKFLOW_NAME) + + # Check if the original error text is part of the raised exception's message + assert non_json_error_text in str(excinfo.value) + assert excinfo.value.response is not None + assert excinfo.value.response.status_code == 503 From 4f5767b7a5b276c932b1e2a13dd9d92a716daabf Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 17:39:52 +0530 Subject: [PATCH 08/42] feat: Improve test coverage and update README - Add tests for non-JSON HTTP error responses in templates - Enhance README with test instructions and refined ToC/Future Enhancements --- README.md | 84 ++++++++++++++++---- coverage.xml | 112 +++++++++++++++----------- examples/workflows.py | 44 ++++++++++- siren/client.py | 23 +++++- siren/workflows.py | 57 +++++++++++++- tests/test_templates.py | 170 +++++++++++++++++++++++++++++++++------- tests/test_workflows.py | 165 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 562 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index f6b6b47..36d7858 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Siren AI Python SDK (`siren-ai`) -This is the official Python SDK for the Siren notification platform. +This is the official Python SDK for the [Siren notification platform](https://docs.trysiren.io). ## Table of Contents @@ -17,10 +17,12 @@ This is the official Python SDK for the Siren notification platform. - [`create_channel_configurations()`](#create_channel_configurations) - [`get_channel_templates()`](#get_channel_templates) - [`trigger_workflow()`](#trigger_workflow) + - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [Getting Started for Package Developers](#getting-started-for-package-developers) - [Prerequisites](#prerequisites) - [Setup Steps](#setup-steps) - [Code Style \& Linting](#code-style--linting) + - [Running Tests](#running-tests) - [Submitting Changes](#submitting-changes) - [Future Enhancements](#future-enhancements) @@ -231,6 +233,59 @@ minimal_trigger_response = client.trigger_workflow(workflow_name="simple_workflo print(minimal_trigger_response) ``` +### `trigger_bulk_workflow()` + +Triggers a specified workflow in bulk for multiple recipients/notifications, with common data applied to all and specific data for each notification. + +**Parameters:** +* `workflow_name` (str): The name of the workflow to be executed. +* `notify` (List[Dict[str, Any]]): A list of notification objects. Your workflow will be executed for each object in this list. Each object contains specific data for that particular workflow execution. +* `data` (Optional[Dict[str, Any]]): Common data that will be used across all workflow executions. Defaults to `None`. + +**Example:** +```python +workflow_to_trigger_bulk = "onboarding_sequence" +common_payload = { + "campaign_source": "webinar_signup_2024" +} +individual_notifications = [ + { + "notificationType": "email", + "recipient": "user_a@example.com", + "name": "Alex", + "join_date": "2024-06-01" + }, + { + "notificationType": "sms", + "recipient": "+15550001111", + "segment": "trial_user" + }, + { + "notificationType": "email", + "recipient": "user_b@example.com", + "name": "Beth", + "join_date": "2024-06-02" + } +] + +bulk_response = client.trigger_bulk_workflow( + workflow_name=workflow_to_trigger_bulk, + notify=individual_notifications, + data=common_payload +) +print(bulk_response) + +# Example: Bulk triggering with only notify list (no common data) +minimal_bulk_response = client.trigger_bulk_workflow( + workflow_name="simple_bulk_actions", + notify=[ + {"action": "activate_feature_x", "user_id": "user_c@example.com"}, + {"action": "send_survey_y", "user_id": "user_d@example.com"} + ] +) +print(minimal_bulk_response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. @@ -273,25 +328,24 @@ This guide will help you set up your environment to contribute to the `siren-ai` ```bash uv run pre-commit install ``` - You can also run all hooks manually: - ```bash - uv run pre-commit run --all-files - ``` - -6. **Run tests:** - (Verify the setup and SDK functionality) - ```bash - uv run pytest - ``` -7. **Start developing!** - You are now ready to contribute to the `siren-ai` SDK. + You are now ready to contribute to the `siren-ai` SDK! ### Code Style & Linting * Code style is enforced by `ruff` (linting, formatting, import sorting) and `pyright` (type checking). * These tools are automatically run via pre-commit hooks. +### Running Tests + +To run the test suite, use the following command from the project root directory: + +```bash +uv run pytest +``` + +This will execute all tests defined in the `tests/` directory. + ### Submitting Changes * Create a feature branch for your changes. @@ -300,4 +354,6 @@ This guide will help you set up your environment to contribute to the `siren-ai` ## Future Enhancements -* **Response Models:** Introduce Pydantic models for API responses to provide a more object-oriented interface and stronger type guarantees. +- Expand SDK for full Siren API endpoint coverage. +- Implement typed response models (e.g., Pydantic) for robust data handling. +- Introduce custom SDK exceptions for improved error diagnostics. diff --git a/coverage.xml b/coverage.xml index 0c0d342..5b55148 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -28,25 +28,27 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + @@ -76,8 +78,8 @@ - - + + @@ -90,8 +92,8 @@ - - + + @@ -104,8 +106,8 @@ - - + + @@ -120,8 +122,8 @@ - - + + @@ -134,8 +136,8 @@ - - + + @@ -182,32 +184,50 @@ - - - - - + + + + - - - - - - + + + + - - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/workflows.py b/examples/workflows.py index 62bd566..8c40469 100644 --- a/examples/workflows.py +++ b/examples/workflows.py @@ -44,6 +44,47 @@ def run_trigger_workflow_example(client: SirenClient): ) +def run_trigger_bulk_workflow_example(client: SirenClient): + """Demonstrates triggering workflows in bulk using the Siren SDK.""" + print("\n--- Running Bulk Trigger Workflow Example ---") + + # Example 1: Bulk trigger with common data and multiple notify objects + print( + "\nAttempting to trigger a bulk workflow with common data and multiple notifications..." + ) + bulk_workflow_name_1 = "sampleWorkflow" + common_data_payload = {"campaign_id": "summer_promo_2024"} + notify_list_1 = [ + { + "notificationType": "email", + "recipient": "user1@example.com", + "name": "User One", + "discount_code": "SUMMER10", + }, + { + "notificationType": "sms", + "recipient": "+15551234567", + "product_name": "New Gadget", + "tracking_link": "http://example.com/track/xyz123", + }, + ] + + try: + response_1 = client.trigger_bulk_workflow( + workflow_name=bulk_workflow_name_1, + notify=notify_list_1, + data=common_data_payload, + ) + print( + f"Bulk Trigger Workflow Response (with data for '{bulk_workflow_name_1}'):" + ) + print(response_1) + except Exception as e: + print( + f"An error occurred while triggering bulk workflow '{bulk_workflow_name_1}': {e}" + ) + + if __name__ == "__main__": api_key = os.environ.get("SIREN_API_KEY") if not api_key: @@ -52,4 +93,5 @@ def run_trigger_workflow_example(client: SirenClient): sys.exit(1) siren_client = SirenClient(api_key=api_key) - run_trigger_workflow_example(siren_client) + # run_trigger_workflow_example(siren_client) + run_trigger_bulk_workflow_example(siren_client) diff --git a/siren/client.py b/siren/client.py index dc9f514..a931b20 100644 --- a/siren/client.py +++ b/siren/client.py @@ -1,6 +1,6 @@ """Siren API client implementation.""" -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from .templates import TemplatesManager from .workflows import WorkflowsManager @@ -170,3 +170,24 @@ def trigger_workflow( return self._workflows.trigger_workflow( workflow_name=workflow_name, data=data, notify=notify ) + + def trigger_bulk_workflow( + self, + workflow_name: str, + notify: List[Dict[str, Any]], + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Triggers a workflow in bulk for multiple recipients/notifications. + + Args: + workflow_name: The name of the workflow to execute. + notify: A list of notification objects, each representing specific data + for a workflow execution. + data: Common data that will be used across all workflow executions. + + Returns: + A dictionary containing the API response. + """ + return self._workflows.trigger_bulk_workflow( + workflow_name=workflow_name, notify=notify, data=data + ) diff --git a/siren/workflows.py b/siren/workflows.py index 68165da..cb765d9 100644 --- a/siren/workflows.py +++ b/siren/workflows.py @@ -1,6 +1,6 @@ """Workflow management for Siren SDK.""" -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import requests @@ -69,3 +69,58 @@ def trigger_workflow( except requests.exceptions.RequestException as req_err: # For other request errors (e.g., connection issues) raise req_err + + def trigger_bulk_workflow( + self, + workflow_name: str, + notify: List[ + Dict[str, Any] + ], # notify is a list of dicts and is required for bulk + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Triggers a workflow in bulk for multiple recipients/notifications. + + Args: + workflow_name: The name of the workflow to execute. + notify: A list of notification objects, each representing specific data + for a workflow execution. The workflow will be executed for + each element in this list. + data: Common data that will be used across all workflow executions. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/workflows/trigger/bulk" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + payload: Dict[str, Any] = { + "workflowName": workflow_name, + "notify": notify, # notify is now a list + } + if data is not None: + payload["data"] = data + + try: + response = requests.post( + endpoint, + headers=headers, + json=payload, + timeout=20, # Increased timeout for bulk + ) + response.raise_for_status() # Raises HTTPError for 4XX/5XX + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + new_err = requests.exceptions.HTTPError( + f"{http_err}\nResponse text: {http_err.response.text}", + response=http_err.response, + ) + raise new_err from http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/tests/test_templates.py b/tests/test_templates.py index 690446a..bd29b3d 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -5,6 +5,7 @@ import pytest import requests +from requests_mock import Mocker as RequestsMocker # Ensure the 'siren' package in the parent directory can be imported: sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -12,7 +13,7 @@ # The 'client' fixture is automatically available from conftest.py -def test_get_templates_success(client, requests_mock): +def test_get_templates_success(client, requests_mock: RequestsMocker): """Test successful retrieval of templates.""" mock_response_data = { "data": { @@ -35,7 +36,7 @@ def test_get_templates_success(client, requests_mock): assert response["data"]["content"][0]["name"] == "Test Template 1" -def test_get_templates_http_error(client, requests_mock): +def test_get_templates_http_error(client, requests_mock: RequestsMocker): """Test handling of HTTP error when getting templates.""" error_response_data = { "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} @@ -50,7 +51,7 @@ def test_get_templates_http_error(client, requests_mock): assert response == error_response_data -def test_get_templates_network_error(client, requests_mock): +def test_get_templates_network_error(client, requests_mock: RequestsMocker): """Test handling of a network error when getting templates.""" requests_mock.get( f"{client._templates.base_url}/template", exc=requests.exceptions.ConnectTimeout @@ -60,7 +61,24 @@ def test_get_templates_network_error(client, requests_mock): client.get_templates() -def test_create_template_success(client, requests_mock): +def test_get_templates_http_error_non_json_response( + client, requests_mock: RequestsMocker +): + """Test HTTP error with non-JSON response for get_templates.""" + requests_mock.get( + f"{client._templates.base_url}/template", + text="Service Unavailable - Not JSON", + status_code=503, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + client.get_templates() + + assert exc_info.value.response.status_code == 503 + assert exc_info.value.response.text == "Service Unavailable - Not JSON" + + +def test_create_template_success(client, requests_mock: RequestsMocker): """Test successful creation of a template.""" mock_request_payload = { "name": "Test_Create_Template", @@ -103,10 +121,11 @@ def test_create_template_success(client, requests_mock): response = client.create_template(mock_request_payload) assert response == mock_response_data + assert requests_mock.last_request is not None assert requests_mock.last_request.json() == mock_request_payload -def test_create_template_http_error(client, requests_mock): +def test_create_template_http_error(client, requests_mock: RequestsMocker): """Test handling of HTTP error when creating a template.""" mock_request_payload = {"name": "Invalid Template"} error_response_data = { @@ -130,7 +149,7 @@ def test_create_template_http_error(client, requests_mock): assert response == error_response_data -def test_create_template_network_error(client, requests_mock): +def test_create_template_network_error(client, requests_mock: RequestsMocker): """Test handling of a network error when creating a template.""" mock_request_payload = {"name": "Network Error Template"} requests_mock.post( @@ -141,7 +160,25 @@ def test_create_template_network_error(client, requests_mock): client.create_template(mock_request_payload) -def test_update_template_success(client, requests_mock): +def test_create_template_http_error_non_json_response( + client, requests_mock: RequestsMocker +): + """Test HTTP error with non-JSON response for create_template.""" + mock_request_payload = {"name": "Test Non-JSON Error"} + requests_mock.post( + f"{client._templates.base_url}/template", + text="Server Error - Not JSON", + status_code=500, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + client.create_template(mock_request_payload) + + assert exc_info.value.response.status_code == 500 + assert exc_info.value.response.text == "Server Error - Not JSON" + + +def test_update_template_success(client, requests_mock: RequestsMocker): """Test successful update of a template.""" template_id = "tpl_xyz789" mock_request_payload = { @@ -185,11 +222,12 @@ def test_update_template_success(client, requests_mock): response = client.update_template(template_id, mock_request_payload) assert response == mock_response_data + assert requests_mock.last_request is not None assert requests_mock.last_request.json() == mock_request_payload assert requests_mock.last_request.method == "PUT" -def test_update_template_http_error(client, requests_mock): +def test_update_template_http_error(client, requests_mock: RequestsMocker): """Test handling of HTTP error when updating a template.""" template_id = "tpl_error400" mock_request_payload = {"name": "Invalid Update"} @@ -214,7 +252,7 @@ def test_update_template_http_error(client, requests_mock): assert response == error_response_data -def test_update_template_network_error(client, requests_mock): +def test_update_template_network_error(client, requests_mock: RequestsMocker): """Test handling of a network error when updating a template.""" template_id = "tpl_network_err" mock_request_payload = {"name": "Network Error Update"} @@ -227,7 +265,26 @@ def test_update_template_network_error(client, requests_mock): client.update_template(template_id, mock_request_payload) -def test_delete_template_success(client, requests_mock): +def test_update_template_http_error_non_json_response( + client, requests_mock: RequestsMocker +): + """Test HTTP error with non-JSON response for update_template.""" + template_id = "tpl_non_json_error" + mock_request_payload = {"name": "Test Update Non-JSON Error"} + requests_mock.put( + f"{client._templates.base_url}/template/{template_id}", + text="Internal Server Error - Not JSON", + status_code=500, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + client.update_template(template_id, mock_request_payload) + + assert exc_info.value.response.status_code == 500 + assert exc_info.value.response.text == "Internal Server Error - Not JSON" + + +def test_delete_template_success(client, requests_mock: RequestsMocker): """Test successful deletion of a template (204 No Content).""" template_id = "tpl_todelete123" expected_response_data = { @@ -242,10 +299,11 @@ def test_delete_template_success(client, requests_mock): response = client.delete_template(template_id) assert response == expected_response_data + assert requests_mock.last_request is not None assert requests_mock.last_request.method == "DELETE" -def test_delete_template_not_found_error(client, requests_mock): +def test_delete_template_not_found_error(client, requests_mock: RequestsMocker): """Test handling of a 404 Not Found error when deleting a template.""" template_id = "tpl_notfound404" error_response_data = { @@ -269,7 +327,7 @@ def test_delete_template_not_found_error(client, requests_mock): assert response == error_response_data -def test_delete_template_unauthorized_error(client, requests_mock): +def test_delete_template_unauthorized_error(client, requests_mock: RequestsMocker): """Test handling of a 401 Unauthorized error when deleting a template.""" template_id = "tpl_unauth401" error_response_data = { @@ -285,7 +343,7 @@ def test_delete_template_unauthorized_error(client, requests_mock): assert response == error_response_data -def test_delete_template_network_error(client, requests_mock): +def test_delete_template_network_error(client, requests_mock: RequestsMocker): """Test handling of a network error when deleting a template.""" template_id = "tpl_network_error" requests_mock.delete( @@ -297,7 +355,25 @@ def test_delete_template_network_error(client, requests_mock): client.delete_template(template_id) -def test_publish_template_success(client, requests_mock): +def test_delete_template_http_error_non_json_response( + client, requests_mock: RequestsMocker +): + """Test HTTP error with non-JSON response for delete_template.""" + template_id = "tpl_non_json_delete_error" + requests_mock.delete( + f"{client._templates.base_url}/template/{template_id}", + text="Gateway Timeout - Not JSON", + status_code=504, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + client.delete_template(template_id) + + assert exc_info.value.response.status_code == 504 + assert exc_info.value.response.text == "Gateway Timeout - Not JSON" + + +def test_publish_template_success(client, requests_mock: RequestsMocker): """Test successful publishing of a template.""" template_id = "tpl_pub_success" mock_response_data = { @@ -316,10 +392,11 @@ def test_publish_template_success(client, requests_mock): response = client.publish_template(template_id) assert response == mock_response_data + assert requests_mock.last_request is not None assert requests_mock.last_request.method == "PATCH" -def test_publish_template_not_found_error(client, requests_mock): +def test_publish_template_not_found_error(client, requests_mock: RequestsMocker): """Test handling of a 404 Not Found error when publishing a template.""" template_id = "tpl_pub_notfound" error_response_data = { @@ -338,7 +415,7 @@ def test_publish_template_not_found_error(client, requests_mock): assert response == error_response_data -def test_publish_template_unauthorized_error(client, requests_mock): +def test_publish_template_unauthorized_error(client, requests_mock: RequestsMocker): """Test handling of a 401 Unauthorized error when publishing a template.""" template_id = "tpl_pub_unauth" error_response_data = { @@ -357,7 +434,7 @@ def test_publish_template_unauthorized_error(client, requests_mock): assert response == error_response_data -def test_publish_template_bad_request_error(client, requests_mock): +def test_publish_template_bad_request_error(client, requests_mock: RequestsMocker): """Test handling of a 400 Bad Request error when publishing a template.""" template_id = "tpl_pub_badreq" error_response_data = { @@ -384,7 +461,7 @@ def test_publish_template_bad_request_error(client, requests_mock): assert response == error_response_data -def test_publish_template_network_error(client, requests_mock): +def test_publish_template_network_error(client, requests_mock: RequestsMocker): """Test handling of a network error when publishing a template.""" template_id = "tpl_pub_network_err" requests_mock.patch( @@ -396,7 +473,25 @@ def test_publish_template_network_error(client, requests_mock): client.publish_template(template_id) -def test_create_channel_configurations_success(client, requests_mock): +def test_publish_template_http_error_non_json_response( + client, requests_mock: RequestsMocker +): + """Test HTTP error with non-JSON response for publish_template.""" + template_id = "tpl_non_json_publish_error" + requests_mock.patch( + f"{client._templates.base_url}/template/{template_id}/publish", + text="Bad Gateway - Not JSON", + status_code=502, + ) + + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + client.publish_template(template_id) + + assert exc_info.value.response.status_code == 502 + assert exc_info.value.response.text == "Bad Gateway - Not JSON" + + +def test_create_channel_configurations_success(client, requests_mock: RequestsMocker): """Test successful creation of channel configurations.""" template_id = "tpl_test123" mock_request_payload = { @@ -429,11 +524,14 @@ def test_create_channel_configurations_success(client, requests_mock): response = client.create_channel_configurations(template_id, mock_request_payload) assert response == mock_response_data + assert requests_mock.last_request is not None assert requests_mock.last_request.json() == mock_request_payload assert requests_mock.last_request.method == "POST" -def test_create_channel_configurations_bad_request(client, requests_mock): +def test_create_channel_configurations_bad_request( + client, requests_mock: RequestsMocker +): """Test handling of a 400 Bad Request error for channel configurations.""" template_id = "tpl_badreq400" mock_request_payload = {"INVALID_CHANNEL": {"body": "invalid"}} @@ -461,7 +559,9 @@ def test_create_channel_configurations_bad_request(client, requests_mock): assert response == error_response_data -def test_create_channel_configurations_unauthorized(client, requests_mock): +def test_create_channel_configurations_unauthorized( + client, requests_mock: RequestsMocker +): """Test handling of a 401 Unauthorized error for channel configurations.""" template_id = "tpl_unauth401" mock_request_payload = {"SMS": {"body": "test"}} @@ -486,7 +586,7 @@ def test_create_channel_configurations_unauthorized(client, requests_mock): assert response == error_response_data -def test_create_channel_configurations_not_found(client, requests_mock): +def test_create_channel_configurations_not_found(client, requests_mock: RequestsMocker): """Test handling of a 404 Not Found error (template_id) for channel configurations.""" template_id = "tpl_notfound404" mock_request_payload = {"SMS": {"body": "test"}} @@ -514,7 +614,9 @@ def test_create_channel_configurations_not_found(client, requests_mock): assert response == error_response_data -def test_create_channel_configurations_network_error(client, requests_mock): +def test_create_channel_configurations_network_error( + client, requests_mock: RequestsMocker +): """Test handling of a network error for channel configurations.""" template_id = "tpl_network_error" mock_request_payload = {"SMS": {"body": "test"}} @@ -549,7 +651,7 @@ def test_create_channel_configurations_http_error_non_json_response( ) -def test_get_channel_templates_success(client, requests_mock): +def test_get_channel_templates_success(client, requests_mock: RequestsMocker): """Test successful retrieval of channel templates for a version.""" version_id = "ver_123xyz" mock_response_data = { @@ -570,10 +672,13 @@ def test_get_channel_templates_success(client, requests_mock): response = client.get_channel_templates(version_id=version_id) assert response == mock_response_data assert len(response["data"]["content"]) == 2 + assert requests_mock.last_request is not None assert requests_mock.last_request.qs == {} -def test_get_channel_templates_success_with_params(client, requests_mock): +def test_get_channel_templates_success_with_params( + client, requests_mock: RequestsMocker +): """Test successful retrieval of channel templates with query parameters.""" version_id = "ver_456abc" mock_response_data = { @@ -594,6 +699,7 @@ def test_get_channel_templates_success_with_params(client, requests_mock): size=5, ) assert response == mock_response_data + assert requests_mock.last_request is not None assert requests_mock.last_request.qs == { "channel": ["push"], # Changed to lowercase "search": ["config_detail"], @@ -603,7 +709,7 @@ def test_get_channel_templates_success_with_params(client, requests_mock): } -def test_get_channel_templates_bad_request_error(client, requests_mock): +def test_get_channel_templates_bad_request_error(client, requests_mock: RequestsMocker): """Test 400 Bad Request error for get_channel_templates.""" version_id = "ver_invalid_format" error_response_data = { @@ -618,7 +724,9 @@ def test_get_channel_templates_bad_request_error(client, requests_mock): assert response == error_response_data -def test_get_channel_templates_unauthorized_error(client, requests_mock): +def test_get_channel_templates_unauthorized_error( + client, requests_mock: RequestsMocker +): """Test 401 Unauthorized error for get_channel_templates.""" version_id = "ver_789def" error_response_data = { @@ -633,7 +741,7 @@ def test_get_channel_templates_unauthorized_error(client, requests_mock): assert response == error_response_data -def test_get_channel_templates_not_found_error(client, requests_mock): +def test_get_channel_templates_not_found_error(client, requests_mock: RequestsMocker): """Test 404 Not Found error for get_channel_templates.""" version_id = "ver_not_exists" error_response_data = { @@ -648,7 +756,7 @@ def test_get_channel_templates_not_found_error(client, requests_mock): assert response == error_response_data -def test_get_channel_templates_network_error(client, requests_mock): +def test_get_channel_templates_network_error(client, requests_mock: RequestsMocker): """Test network error for get_channel_templates.""" version_id = "ver_network_issue" requests_mock.get( @@ -659,7 +767,9 @@ def test_get_channel_templates_network_error(client, requests_mock): client.get_channel_templates(version_id=version_id) -def test_get_channel_templates_http_error_non_json_response(client, requests_mock): +def test_get_channel_templates_http_error_non_json_response( + client, requests_mock: RequestsMocker +): """Test HTTP error with non-JSON response for get_channel_templates.""" version_id = "ver_html_error" requests_mock.get( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 5e2e9ea..7e196f7 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -153,3 +153,168 @@ def test_trigger_workflow_http_error_non_json_response( assert non_json_error_text in str(excinfo.value) assert excinfo.value.response is not None assert excinfo.value.response.status_code == 503 + + +# --- Tests for trigger_bulk_workflow --- # + +BULK_WORKFLOW_NAME = "test_bulk_otp_workflow" + + +def test_trigger_bulk_workflow_success_with_all_params( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow with all parameters successfully.""" + request_notify_list = [ + {"notificationType": "email", "recipient": "bulk1@example.com"}, + {"notificationType": "sms", "recipient": "+12345678901"}, + ] + request_data = {"common_field": "common_value"} + expected_response = { + "data": { + "requestId": "d4e5f6a7-b8c9-d0e1-f2a3-b4c5d6e7f8a9", + "workflowExecutionIds": [ + "e5f6a7b8-c9d0-e1f2-a3b4-c5d6e7f8a9b0", + "f6a7b8c9-d0e1-f2a3-b4c5-d6e7f8a9b0c1", + ], + }, + "error": None, + "errors": None, + "meta": None, + } + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + + requests_mock.post(mock_url, json=expected_response, status_code=200) + + response = client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, + notify=request_notify_list, + data=request_data, + ) + + assert response == expected_response + history = requests_mock.request_history + assert len(history) == 1 + assert history[0].method == "POST" + assert history[0].url == mock_url + assert history[0].json() == { + "workflowName": BULK_WORKFLOW_NAME, + "notify": request_notify_list, + "data": request_data, + } + assert history[0].headers["Authorization"] == f"Bearer {API_KEY}" + + +def test_trigger_bulk_workflow_success_minimal_params( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow with minimal parameters (workflow_name, notify) successfully.""" + request_notify_list = [ + {"notificationType": "email", "recipient": "minimal_bulk@example.com"} + ] + expected_response = { + "data": { + "requestId": "uuid_bulk_req", + "workflowExecutionIds": ["uuid_bulk_exec1"], + }, + "error": None, + "errors": None, + "meta": None, + } + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + requests_mock.post(mock_url, json=expected_response, status_code=200) + + response = client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, notify=request_notify_list + ) + + assert response == expected_response + history = requests_mock.request_history + assert len(history) == 1 + assert history[0].json() == { + "workflowName": BULK_WORKFLOW_NAME, + "notify": request_notify_list, + } + + +def test_trigger_bulk_workflow_http_400_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow handles HTTP 400 Bad Request error.""" + error_response = { + "data": None, + "error": {"errorCode": "BAD_REQUEST", "message": "Invalid notify payload"}, + "errors": [{"errorCode": "BAD_REQUEST", "message": "Invalid notify payload"}], + "meta": None, + } + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + requests_mock.post(mock_url, json=error_response, status_code=400) + + response = client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, + notify=[{}], # Example invalid notify + ) + assert response == error_response + + +def test_trigger_bulk_workflow_http_401_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow handles HTTP 401 Unauthorized error.""" + error_response = {"detail": "Authentication credentials were not provided."} + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + requests_mock.post(mock_url, json=error_response, status_code=401) + + response = client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, notify=[{"recipient": "test@example.com"}] + ) + assert response == error_response + + +def test_trigger_bulk_workflow_http_404_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow handles HTTP 404 Not Found error for the workflow.""" + error_response = {"detail": "Workflow not found."} + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + requests_mock.post(mock_url, json=error_response, status_code=404) + + response = client.trigger_bulk_workflow( + workflow_name="non_existent_bulk_workflow", + notify=[{"recipient": "test@example.com"}], + ) + assert response == error_response + + +def test_trigger_bulk_workflow_network_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow handles a network error.""" + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + requests_mock.post( + mock_url, exc=requests.exceptions.ConnectionError("Bulk connection failed") + ) + + with pytest.raises(requests.exceptions.RequestException): + client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, + notify=[{"recipient": "test@example.com"}], + ) + + +def test_trigger_bulk_workflow_http_error_non_json_response( + client: SirenClient, requests_mock: RequestsMocker +): + """Test trigger_bulk_workflow handles HTTP error with non-JSON response.""" + mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" + non_json_error_text = "Bulk Service Unavailable" + requests_mock.post(mock_url, text=non_json_error_text, status_code=503) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, + notify=[{"recipient": "test@example.com"}], + ) + + assert non_json_error_text in str(excinfo.value) + assert excinfo.value.response is not None + assert excinfo.value.response.status_code == 503 From 23475b2535f1784133eb6274e6e63b47491a8c9b Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 5 Jun 2025 17:46:03 +0530 Subject: [PATCH 09/42] chore: Configure license and PyPI release details - Add MIT LICENSE file with correct copyright year and holder - Update pyproject.toml to reference LICENSE file - Set project URLs in pyproject.toml - Add build and twine as dev dependencies --- LICENSE | 22 ++++++++++++++++++++++ pyproject.toml | 15 +++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e649f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 KeyValue Software Systems + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml index 49d2dba..697f3e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "Python SDK for the Siren Notification Platform." readme = "README.md" requires-python = ">=3.8" -license = { text = "MIT License" } # TODO: Confirm license and consider adding a LICENSE file +license = { file = "LICENSE" } classifiers = [ "Development Status :: 3 - Alpha", @@ -35,10 +35,10 @@ dependencies = [ ] [project.urls] -"Homepage" = "https://github.com/your-username/siren-ai" # TODO: Update URL -"Documentation" = "https://github.com/your-username/siren-ai#readme" # TODO: Update URL (or point to actual docs) -"Repository" = "https://github.com/your-username/siren-ai" # TODO: Update URL -"Bug Tracker" = "https://github.com/your-username/siren-ai/issues" # TODO: Update URL +"Homepage" = "https://github.com/jithu-keyvalue/siren-ai" +"Documentation" = "https://github.com/jithu-keyvalue/siren-ai#readme" +"Repository" = "https://github.com/jithu-keyvalue/siren-ai" +"Bug Tracker" = "https://github.com/jithu-keyvalue/siren-ai/issues" [project.optional-dependencies] dev = [ @@ -50,9 +50,8 @@ dev = [ "pre-commit", # For managing pre-commit hooks "uv", # Explicitly list if desired, often installed globally "python-dotenv", # For loading .env files - # Consider adding 'build' and 'twine' for release management - # "build", - # "twine", + "build>=0.10.0", + "twine>=3.8.0", ] # Specifies that the 'siren' package is in the project root. From 3011e329dcbab950cf49eb38089b1b47b2b13e31 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 10:58:13 +0530 Subject: [PATCH 10/42] refactor: refer to channel templates correctly --- README.md | 20 +- coverage.xml | 73 +++--- examples/templates.py | 26 +- siren/client.py | 21 +- siren/templates.py | 19 +- tests/test_templates.py | 44 ++-- uv.lock | 524 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 625 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 36d7858..9374f5d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`update_template()`](#update_template) - [`delete_template()`](#delete_template) - [`publish_template()`](#publish_template) - - [`create_channel_configurations()`](#create_channel_configurations) + - [`create_channel_templates()`](#create_channel_templates) - [`get_channel_templates()`](#get_channel_templates) - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) @@ -139,37 +139,37 @@ publish_response = client.publish_template(template_id_to_publish) print(publish_response) ``` -### `create_channel_configurations()` +### `create_channel_templates()` -Creates or updates the channel-specific configurations (e.g., for EMAIL, SMS) for a given template. +Creates or updates channel-specific templates for a given template ID. This method allows you to define different content and settings for various notification channels (e.g., EMAIL, SMS) associated with a single parent template. **Parameters:** -* `template_id` (str): The ID of the template for which to create/update channel configurations. -* `configurations` (Dict[str, Any]): A dictionary where keys are channel types (e.g., "EMAIL", "SMS") and values are the respective configuration objects. For the detailed payload structure for each channel, please refer to the official Siren API documentation. +* `template_id` (str): The ID of the template for which to create/update channel templates. +* `channel_templates` (Dict[str, Any]): A dictionary where keys are channel names (e.g., "EMAIL", "SMS") and values are the channel-specific template objects. Each object should conform to the structure expected by the Siren API for that channel. **Example:** ```python template_id = "YOUR_TEMPLATE_ID" -channel_configs_payload = { +channel_templates_payload = { "SMS": { "body": "New SMS content via SDK for {{variable_name}}", - "channel": "SMS", # Ensure channel is specified within its config + "channel": "SMS", "isFlash": False }, "EMAIL": { "subject": "Channel Config Update for {{variable_name}}", "body": "

Updated email body for channel config.

", - "channel": "EMAIL" # Ensure channel is specified + "channel": "EMAIL" } } -response = client.create_channel_configurations(template_id, channel_configs_payload) +response = client.create_channel_templates(template_id, channel_templates_payload) print(response) ``` ### `get_channel_templates()` -Retrieves channel-specific configurations for a specific version of a notification template. +Retrieves the channel templates associated with a specific template version ID. **Parameters:** * `version_id` (str): The ID of the template version for which to fetch channel templates. diff --git a/coverage.xml b/coverage.xml index 5b55148..60dd9d7 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -39,16 +39,16 @@ - - - - - - - + + + + + + + - + @@ -141,26 +141,23 @@ - - - - + + + - - + - - + + + + - - - @@ -168,17 +165,21 @@ + + - - + - + + + + @@ -211,23 +212,23 @@ - - - - - + + + + - - - - - - + + + + + - + + + diff --git a/examples/templates.py b/examples/templates.py index 45b68fc..e0a8dc8 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -110,21 +110,21 @@ def run_publish_template_example(client: SirenClient): print(f"Error publishing template '{template_id_to_publish}': {e}") -def run_create_channel_configurations_example(client: SirenClient): - """Runs the example for creating channel configurations for a template.""" - print("\n--- Creating Channel Configurations for Template ---") - template_id_for_channel_config = ( - "11921404-4517-48b7-82ee-fcdcf8f9c03b" # Replace with an actual template ID +def run_create_channel_templates_example(client: SirenClient): + """Runs the example for creating channel templates for a template.""" + print("\n--- Creating Channel Templates for a Template ---") + template_id_for_channel_templates = ( + "cacf1503-8283-42a8-b5fd-27d85054fb99" # Replace with an actual template ID ) - channel_configurations_payload = { + channel_templates_payload = { "SMS": { - "body": "Hello from Siren SDK! This is an SMS channel config.", + "body": "Exciting discounts are ON", "channel": "SMS", "isFlash": False, "isUnicode": False, }, "EMAIL": { - "subject": "Siren SDK Test - Email Channel Config", + "subject": "Exciting discount at our store online", "channel": "EMAIL", "body": "

Hello from Siren SDK! This is an email channel configuration.

", "attachments": [], @@ -134,17 +134,17 @@ def run_create_channel_configurations_example(client: SirenClient): } try: - response = client.create_channel_configurations( - template_id_for_channel_config, channel_configurations_payload + response = client.create_channel_templates( + template_id_for_channel_templates, channel_templates_payload ) if response and response.get("error") is None: print( - f"Successfully created/updated channel configurations for template '{template_id_for_channel_config}':" + f"Successfully created/updated channel templates for template '{template_id_for_channel_templates}':" ) print(json.dumps(response, indent=2)) except Exception as e: print( - f"Error creating/updating channel configurations for template '{template_id_for_channel_config}': {e}" + f"Error creating/updating channel templates for template '{template_id_for_channel_templates}': {e}" ) @@ -188,5 +188,5 @@ def run_get_channel_templates_example(client: SirenClient): # run_update_template_example(siren_client) # run_delete_template_example(siren_client) # run_publish_template_example(siren_client) - # run_create_channel_configurations_example(siren_client) + # run_create_channel_templates_example(siren_client) run_get_channel_templates_example(siren_client) diff --git a/siren/client.py b/siren/client.py index a931b20..fb362be 100644 --- a/siren/client.py +++ b/siren/client.py @@ -104,20 +104,23 @@ def publish_template(self, template_id: str) -> Dict[str, Any]: """ return self._templates.publish_template(template_id=template_id) - def create_channel_configurations( - self, template_id: str, configurations: Dict[str, Any] + def create_channel_templates( + self, + template_id: str, + channel_templates: Dict[str, Any], ) -> Dict[str, Any]: - """Create or update channel configurations for a template. + """Create or update channel templates for a specific template. Args: - template_id: The ID of the template. - configurations: A dictionary containing the channel configurations. + template_id: The ID of the template for which to create channel templates. + channel_templates: A dictionary where keys are channel names (e.g., "EMAIL", "SMS") + and values are the channel-specific template objects. Returns: A dictionary containing the API response. """ - return self._templates.create_channel_configurations( - template_id=template_id, configurations=configurations + return self._templates.create_channel_templates( + template_id=template_id, channel_templates=channel_templates ) def get_channel_templates( @@ -129,10 +132,10 @@ def get_channel_templates( page: Optional[int] = None, size: Optional[int] = None, ) -> Dict[str, Any]: - """Fetch channel templates for a specific template version. + """Get channel templates for a specific template version. Args: - version_id: The ID of the template version. + version_id: The ID of the template version for which to fetch channel templates. channel: Filter by channel type (e.g., "EMAIL", "SMS"). search: Search by field. sort: Sort by field. diff --git a/siren/templates.py b/siren/templates.py index b8651bc..39be5b2 100644 --- a/siren/templates.py +++ b/siren/templates.py @@ -191,14 +191,17 @@ def publish_template(self, template_id: str) -> Dict[str, Any]: except requests.exceptions.RequestException as req_err: raise req_err - def create_channel_configurations( - self, template_id: str, configurations: Dict[str, Any] + def create_channel_templates( + self, + template_id: str, + channel_templates: Dict[str, Any], ) -> Dict[str, Any]: - """Create or update channel configurations for a template. + """Create or update channel templates for a specific template. Args: - template_id: The ID of the template. - configurations: A dictionary containing the channel configurations. + template_id: The ID of the template for which to create channel templates. + channel_templates: A dictionary where keys are channel names (e.g., "EMAIL", "SMS") + and values are the channel-specific template objects. Example: { @@ -217,7 +220,7 @@ def create_channel_configurations( } try: response = requests.post( - endpoint, headers=headers, json=configurations, timeout=10 + endpoint, headers=headers, json=channel_templates, timeout=10 ) response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes return response.json() @@ -241,9 +244,9 @@ def get_channel_templates( """Fetch channel templates for a specific template version. Args: - version_id: The ID of the template version. + version_id: The ID of the template version for which to fetch channel templates. channel: Filter by channel type (e.g., "EMAIL", "SMS"). - search: Search by field. + search: Search term to filter channel templates. sort: Sort by field. page: Page number. size: Page size. diff --git a/tests/test_templates.py b/tests/test_templates.py index bd29b3d..e3a5cc8 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -491,8 +491,8 @@ def test_publish_template_http_error_non_json_response( assert exc_info.value.response.text == "Bad Gateway - Not JSON" -def test_create_channel_configurations_success(client, requests_mock: RequestsMocker): - """Test successful creation of channel configurations.""" +def test_create_channel_templates_success(client, requests_mock: RequestsMocker): + """Test successful creation of channel templates.""" template_id = "tpl_test123" mock_request_payload = { "SMS": { @@ -522,17 +522,15 @@ def test_create_channel_configurations_success(client, requests_mock: RequestsMo status_code=200, ) - response = client.create_channel_configurations(template_id, mock_request_payload) + response = client.create_channel_templates(template_id, mock_request_payload) assert response == mock_response_data assert requests_mock.last_request is not None assert requests_mock.last_request.json() == mock_request_payload assert requests_mock.last_request.method == "POST" -def test_create_channel_configurations_bad_request( - client, requests_mock: RequestsMocker -): - """Test handling of a 400 Bad Request error for channel configurations.""" +def test_create_channel_templates_bad_request(client, requests_mock: RequestsMocker): + """Test handling of a 400 Bad Request error for channel templates.""" template_id = "tpl_badreq400" mock_request_payload = {"INVALID_CHANNEL": {"body": "invalid"}} error_response_data = { @@ -555,14 +553,12 @@ def test_create_channel_configurations_bad_request( status_code=400, ) - response = client.create_channel_configurations(template_id, mock_request_payload) + response = client.create_channel_templates(template_id, mock_request_payload) assert response == error_response_data -def test_create_channel_configurations_unauthorized( - client, requests_mock: RequestsMocker -): - """Test handling of a 401 Unauthorized error for channel configurations.""" +def test_create_channel_templates_unauthorized(client, requests_mock: RequestsMocker): + """Test handling of a 401 Unauthorized error for channel templates.""" template_id = "tpl_unauth401" mock_request_payload = {"SMS": {"body": "test"}} error_response_data = { @@ -582,12 +578,12 @@ def test_create_channel_configurations_unauthorized( status_code=401, ) - response = client.create_channel_configurations(template_id, mock_request_payload) + response = client.create_channel_templates(template_id, mock_request_payload) assert response == error_response_data -def test_create_channel_configurations_not_found(client, requests_mock: RequestsMocker): - """Test handling of a 404 Not Found error (template_id) for channel configurations.""" +def test_create_channel_templates_not_found(client, requests_mock: RequestsMocker): + """Test handling of a 404 Not Found error (template_id) for channel templates.""" template_id = "tpl_notfound404" mock_request_payload = {"SMS": {"body": "test"}} error_response_data = { @@ -610,14 +606,12 @@ def test_create_channel_configurations_not_found(client, requests_mock: Requests status_code=404, ) - response = client.create_channel_configurations(template_id, mock_request_payload) + response = client.create_channel_templates(template_id, mock_request_payload) assert response == error_response_data -def test_create_channel_configurations_network_error( - client, requests_mock: RequestsMocker -): - """Test handling of a network error for channel configurations.""" +def test_create_channel_templates_network_error(client, requests_mock: RequestsMocker): + """Test handling of a network error for channel templates.""" template_id = "tpl_network_error" mock_request_payload = {"SMS": {"body": "test"}} requests_mock.post( @@ -626,13 +620,11 @@ def test_create_channel_configurations_network_error( ) with pytest.raises(requests.exceptions.ConnectTimeout): - client.create_channel_configurations(template_id, mock_request_payload) + client.create_channel_templates(template_id, mock_request_payload) -def test_create_channel_configurations_http_error_non_json_response( - client, requests_mock -): - """Test HTTP error with non-JSON response for channel configurations.""" +def test_create_channel_templates_http_error_non_json_response(client, requests_mock): + """Test HTTP error with non-JSON response for channel templates.""" template_id = "tpl_non_json_error" mock_request_payload = { "EMAIL": {"subject": "Test", "body": "Body", "channel": "EMAIL"} @@ -644,7 +636,7 @@ def test_create_channel_configurations_http_error_non_json_response( ) with pytest.raises(requests.exceptions.HTTPError) as excinfo: - client.create_channel_configurations(template_id, mock_request_payload) + client.create_channel_templates(template_id, mock_request_payload) assert "500 Server Error" in str(excinfo.value) assert ( "Internal Server Error" in excinfo.value.response.text diff --git a/uv.lock b/uv.lock index db43a21..0d88d96 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,32 @@ resolution-markers = [ "python_full_version < '3.9'", ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "build" +version = "1.2.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701, upload-time = "2024-10-06T17:22:25.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950, upload-time = "2024-10-06T17:22:23.299Z" }, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -15,6 +41,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5b/f1523dd545f92f7df468e5f653ffa4df30ac222f3c884e51e139878f1cb5/cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", size = 425932, upload-time = "2024-09-04T20:44:49.491Z" }, + { url = "https://files.pythonhosted.org/packages/53/93/7e547ab4105969cc8c93b38a667b82a835dd2cc78f3a7dad6130cfd41e1d/cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", size = 448585, upload-time = "2024-09-04T20:44:51.671Z" }, + { url = "https://files.pythonhosted.org/packages/56/c4/a308f2c332006206bb511de219efeff090e9d63529ba0a77aae72e82248b/cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", size = 456268, upload-time = "2024-09-04T20:44:53.51Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5b/b63681518265f2f4060d2b60755c1c77ec89e5e045fc3773b72735ddaad5/cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", size = 436592, upload-time = "2024-09-04T20:44:55.085Z" }, + { url = "https://files.pythonhosted.org/packages/bb/19/b51af9f4a4faa4a8ac5a0e5d5c2522dcd9703d07fac69da34a36c4d960d3/cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", size = 446512, upload-time = "2024-09-04T20:44:57.135Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -289,6 +369,43 @@ toml = [ { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "45.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" }, + { url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" }, + { url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/87556d3337f5e93c37fda0a0b5d3e7b4f23670777ce8820fce7962a7ed22/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9", size = 4142867, upload-time = "2025-05-25T14:16:58.459Z" }, + { url = "https://files.pythonhosted.org/packages/72/ba/21356dd0bcb922b820211336e735989fe2cf0d8eaac206335a0906a5a38c/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc", size = 4385000, upload-time = "2025-05-25T14:17:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2b/71c78d18b804c317b66283be55e20329de5cd7e1aec28e4c5fbbe21fd046/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1", size = 4144195, upload-time = "2025-05-25T14:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/55/3e/9f9b468ea779b4dbfef6af224804abd93fbcb2c48605d7443b44aea77979/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e", size = 4384540, upload-time = "2025-05-25T14:17:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/751ebea58c87b5be533c429f01996050a72c7283b59eee250275746632ea/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8", size = 4146964, upload-time = "2025-05-25T14:17:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/28c90601b199964de383da0b740b5156f5d71a1da25e7194fdf793d373ef/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4", size = 4388103, upload-time = "2025-05-25T14:17:11.978Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ec/cd892180b9e42897446ef35c62442f5b8b039c3d63a05f618aa87ec9ebb5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972", size = 4150031, upload-time = "2025-05-25T14:17:14.131Z" }, + { url = "https://files.pythonhosted.org/packages/db/d4/22628c2dedd99289960a682439c6d3aa248dff5215123ead94ac2d82f3f5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c", size = 4387389, upload-time = "2025-05-25T14:17:17.303Z" }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -298,6 +415,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "docutils" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -335,6 +476,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, +] + [[package]] name = "identify" version = "2.6.1" @@ -368,6 +521,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "zipp", version = "3.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/be/f3e8c6081b684f176b761e6a2fef02a0be939740ed6f54109a2951d806f3/importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065", size = 43372, upload-time = "2024-09-09T17:03:14.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/6a/4604f9ae2fa62ef47b9de2fa5ad599589d28c9fd1d335f32759813dfa91e/importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717", size = 36115, upload-time = "2024-09-09T17:03:13.39Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -377,6 +572,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools", version = "10.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "more-itertools", version = "10.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools", version = "10.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "more-itertools", version = "10.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159, upload-time = "2024-09-27T19:47:09.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187, upload-time = "2024-09-27T19:47:07.14Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-resources", marker = "python_full_version < '3.9'" }, + { name = "jaraco-classes", marker = "python_full_version < '3.9'" }, + { name = "jaraco-context", marker = "python_full_version < '3.9'" }, + { name = "jaraco-functools", marker = "python_full_version < '3.9'" }, + { name = "jeepney", marker = "python_full_version < '3.9' and sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, + { name = "secretstorage", marker = "python_full_version < '3.9' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/24/64447b13df6a0e2797b586dad715766d756c932ce8ace7f67bd384d76ae0/keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6", size = 62675, upload-time = "2024-10-26T15:40:12.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/c9/353c156fa2f057e669106e5d6bcdecf85ef8d3536ce68ca96f18dc7b6d6f/keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741", size = 39096, upload-time = "2024-10-26T15:40:10.296Z" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.12'" }, + { name = "jaraco-classes", marker = "python_full_version >= '3.9'" }, + { name = "jaraco-context", marker = "python_full_version >= '3.9'" }, + { name = "jaraco-functools", marker = "python_full_version >= '3.9'" }, + { name = "jeepney", marker = "python_full_version >= '3.9' and sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, + { name = "secretstorage", marker = "python_full_version >= '3.9' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/78/65922308c4248e0eb08ebcbe67c95d48615cc6f27854b6f2e57143e9178f/more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6", size = 121020, upload-time = "2024-09-05T15:28:22.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7e/3a64597054a70f7c86eb0a7d4fc315b8c1ab932f64883a297bdffeb5f967/more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef", size = 60952, upload-time = "2024-09-05T15:28:20.141Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, +] + +[[package]] +name = "nh3" +version = "0.2.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581, upload-time = "2025-02-25T13:38:44.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678, upload-time = "2025-02-25T13:37:56.063Z" }, + { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774, upload-time = "2025-02-25T13:37:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012, upload-time = "2025-02-25T13:38:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619, upload-time = "2025-02-25T13:38:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384, upload-time = "2025-02-25T13:38:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908, upload-time = "2025-02-25T13:38:06.693Z" }, + { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180, upload-time = "2025-02-25T13:38:10.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747, upload-time = "2025-02-25T13:38:12.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908, upload-time = "2025-02-25T13:38:14.059Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133, upload-time = "2025-02-25T13:38:16.601Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328, upload-time = "2025-02-25T13:38:18.972Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020, upload-time = "2025-02-25T13:38:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878, upload-time = "2025-02-25T13:38:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460, upload-time = "2025-02-25T13:38:25.951Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369, upload-time = "2025-02-25T13:38:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036, upload-time = "2025-02-25T13:38:30.539Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712, upload-time = "2025-02-25T13:38:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559, upload-time = "2025-02-25T13:38:35.204Z" }, + { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591, upload-time = "2025-02-25T13:38:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670, upload-time = "2025-02-25T13:38:38.696Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093, upload-time = "2025-02-25T13:38:40.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623, upload-time = "2025-02-25T13:38:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283, upload-time = "2025-02-25T13:38:43.355Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -481,6 +842,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -490,6 +860,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + [[package]] name = "pyright" version = "1.1.401" @@ -601,6 +980,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -661,6 +1049,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, ] +[[package]] +name = "readme-renderer" +version = "43.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "docutils", version = "0.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "nh3", marker = "python_full_version < '3.9'" }, + { name = "pygments", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/b5/536c775084d239df6345dccf9b043419c7e3308bc31be4c7882196abc62e/readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311", size = 31768, upload-time = "2024-02-26T16:10:59.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/be/3ea20dc38b9db08387cf97997a85a7d51527ea2057d71118feb0aa8afa55/readme_renderer-43.0-py3-none-any.whl", hash = "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9", size = 13301, upload-time = "2024-02-26T16:10:57.945Z" }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "nh3", marker = "python_full_version >= '3.9'" }, + { name = "pygments", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, +] + [[package]] name = "requests" version = "2.32.3" @@ -689,6 +1111,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + [[package]] name = "ruff" version = "0.11.12" @@ -714,6 +1172,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" }, ] +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + [[package]] name = "siren-ai" version = "0.1.0" @@ -724,6 +1195,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "build" }, { name = "pre-commit", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pre-commit", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pyright" }, @@ -735,11 +1207,13 @@ dev = [ { name = "python-dotenv", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "requests-mock" }, { name = "ruff" }, + { name = "twine" }, { name = "uv" }, ] [package.metadata] requires-dist = [ + { name = "build", marker = "extra == 'dev'", specifier = ">=0.10.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, @@ -748,6 +1222,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.25.0" }, { name = "requests-mock", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "twine", marker = "extra == 'dev'", specifier = ">=3.8.0" }, { name = "uv", marker = "extra == 'dev'" }, ] provides-extras = ["dev"] @@ -791,6 +1266,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "twine" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "keyring", version = "25.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "keyring", version = "25.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer", version = "43.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "readme-renderer", version = "44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "urllib3", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404, upload-time = "2025-01-21T18:45:26.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2" @@ -879,3 +1379,27 @@ sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c wheels = [ { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, ] + +[[package]] +name = "zipp" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199, upload-time = "2024-09-13T13:44:16.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200, upload-time = "2024-09-13T13:44:14.38Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From d3f897a0dc3b3c9c926b6b0c84822864e7e1bbd2 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 17:23:49 +0530 Subject: [PATCH 11/42] feat: Add send_message API method --- README.md | 41 ++++++++++ coverage.xml | 93 ++++++++++++++------- examples/messages.py | 69 ++++++++++++++++ pyproject.toml | 1 + siren/client.py | 32 ++++++++ siren/messaging.py | 72 ++++++++++++++++ tests/test_messaging.py | 177 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 15 ++++ 8 files changed, 469 insertions(+), 31 deletions(-) create mode 100644 examples/messages.py create mode 100644 siren/messaging.py create mode 100644 tests/test_messaging.py diff --git a/README.md b/README.md index 9374f5d..b1d8fdb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`publish_template()`](#publish_template) - [`create_channel_templates()`](#create_channel_templates) - [`get_channel_templates()`](#get_channel_templates) + - [`send_message()`](#send_message) - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [Getting Started for Package Developers](#getting-started-for-package-developers) @@ -199,6 +200,46 @@ sms_channel_templates = client.get_channel_templates( print(sms_channel_templates) ``` +### `send_message()` + +Sends a message using a specified template to a recipient via a chosen channel. + +**Parameters:** +* `template_name` (str): The name of the template to use. +* `channel` (str): The channel through which to send the message (e.g., "EMAIL", "SLACK", "SMS"). +* `recipient_type` (str): The type of recipient identifier (e.g., "direct", "user_id"). +* `recipient_value` (str): The actual value of the recipient identifier (e.g., "recipient@example.com", "U123XYZ", "+15551234567"). +* `template_variables` (Optional[Dict[str, Any]]): A dictionary of variables to be interpolated into the template. Defaults to `None`. + +**Example:** +```python +# IMPORTANT: Replace with your actual template name, channel, and recipient details. +template_name = "your_template_name_here" +channel = "EMAIL" +recipient_type = "direct" +recipient_value = "recipient@example.com" + +# Optional: Provide template variables if your template requires them +template_variables = { + "user_name": "Alex Doe", + "order_id": "ORD98765" +} + +response = client.send_message( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + template_variables=template_variables # Pass None or omit if no variables +) +print(response) + +if response and response.get("data") and response.get("data", {}).get("notificationId"): + print(f"Message sent successfully! Notification ID: {response['data']['notificationId']}") +else: + print(f"Failed to send message. Error: {response.get('error', 'Unknown error')}") +``` + ### `trigger_workflow()` Triggers a specified workflow with the given data and notification payloads. diff --git a/coverage.xml b/coverage.xml index 60dd9d7..1c1b5bc 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -22,33 +22,65 @@ - - - - + + + + - + + - - - - - + + + + + - - - - - + + + + + - - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -145,7 +177,7 @@ - + @@ -154,10 +186,10 @@ - - + + - + @@ -168,18 +200,17 @@ - + - + - + - - + diff --git a/examples/messages.py b/examples/messages.py new file mode 100644 index 0000000..e1b1163 --- /dev/null +++ b/examples/messages.py @@ -0,0 +1,69 @@ +"""Example script to demonstrate sending messages using the Siren SDK.""" + +import os +import sys + +from dotenv import load_dotenv + +# Add the project root to the Python path +# This allows us to import the 'siren' package +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, project_root) + +from siren import SirenClient # noqa: E402 + + +def send_message_example(client: SirenClient): + """Demonstrates sending a message using a template.""" + print("Attempting to send a message...") + try: + template_name = "sampleTemplate" + channel = "SLACK" + recipient_type = "direct" + recipient_value = "U01UBCD06BB" + + # Optional: Provide template variables if your template requires them + template_variables = { + "user_name": "Jithu", + } + + response = client.send_message( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + template_variables=template_variables, + ) + print("Send message API response:") + print(response) + + if ( + response + and response.get("data") + and response.get("data", {}).get("notificationId") + ): + print( + f"Message sent successfully! Notification ID: {response['data']['notificationId']}" + ) + elif response and response.get("error"): + print(f"Failed to send message. Error: {response['error']}") + else: + print( + "Failed to send message. Unknown error or unexpected response format." + ) + + except Exception as e: + print(f"An error occurred while sending the message: {e}") + print("-" * 30) + + +if __name__ == "__main__": + load_dotenv() + api_key = os.getenv("SIREN_API_KEY") + if not api_key: + print("Error: SIREN_API_KEY not found in environment variables or .env file.") + print("Please set it to run the example.") + sys.exit(1) + + siren_client = SirenClient(api_key=api_key) + send_message_example(siren_client) diff --git a/pyproject.toml b/pyproject.toml index 697f3e9..be4e7ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ dev = [ "pytest>=7.0", "pytest-cov", # For test coverage reports + "pytest-mock", # For mocking objects in tests "requests-mock", # For mocking HTTP requests in tests "ruff", # Linter, formatter, import sorter "pyright", # Static type checker diff --git a/siren/client.py b/siren/client.py index fb362be..6b241d4 100644 --- a/siren/client.py +++ b/siren/client.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional +from .messaging import MessagingManager from .templates import TemplatesManager from .workflows import WorkflowsManager @@ -26,6 +27,9 @@ def __init__(self, api_key: str): api_key=self.api_key, base_url=self.BASE_API_URL, # Note: WorkflowsManager uses /api/v2 internally ) + self._messaging = MessagingManager( + api_key=self.api_key, base_url=self.BASE_API_URL + ) def get_templates( self, @@ -194,3 +198,31 @@ def trigger_bulk_workflow( return self._workflows.trigger_bulk_workflow( workflow_name=workflow_name, notify=notify, data=data ) + + def send_message( + self, + template_name: str, + channel: str, + recipient_type: str, + recipient_value: str, + template_variables: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Send a message using a specific template. + + Args: + template_name: The name of the template to use. + channel: The channel to send the message through (e.g., "SLACK", "EMAIL"). + recipient_type: The type of recipient (e.g., "direct"). + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address). + template_variables: A dictionary of variables to populate the template. + + Returns: + A dictionary containing the API response. + """ + return self._messaging.send_message( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + template_variables=template_variables, + ) diff --git a/siren/messaging.py b/siren/messaging.py new file mode 100644 index 0000000..aa7ce29 --- /dev/null +++ b/siren/messaging.py @@ -0,0 +1,72 @@ +"""Manages messaging-related API interactions for the Siren SDK.""" + +from typing import Any, Dict, Optional + +import requests + + +class MessagingManager: + """Manages direct message sending operations.""" + + def __init__(self, api_key: str, base_url: str): + """ + Initialize the MessagingManager. + + Args: + api_key: The API key for authentication. + base_url: The base URL of the Siren API. + """ + self.api_key = api_key + self.base_url = f"{base_url}/api/v1/public" + + def send_message( + self, + template_name: str, + channel: str, + recipient_type: str, + recipient_value: str, + template_variables: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Send a message using a specific template. + + Args: + template_name: The name of the template to use. + channel: The channel to send the message through (e.g., "SLACK", "EMAIL"). + recipient_type: The type of recipient (e.g., "direct"). + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address). + template_variables: A dictionary of variables to populate the template. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/send-messages" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + payload: Dict[str, Any] = { + "template": {"name": template_name}, + "recipient": {"type": recipient_type, "value": recipient_value}, + "channel": channel, + } + if template_variables is not None: + payload["templateVariables"] = template_variables + + try: + response = requests.post( + endpoint, headers=headers, json=payload, timeout=10 + ) + response.raise_for_status() # Raise an exception for HTTP errors + return response.json() + except requests.exceptions.HTTPError as http_err: + # Try to return JSON error response from API if available + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + # If response is not JSON, re-raise the original HTTPError + raise http_err + except requests.exceptions.RequestException as req_err: + # For other network errors (timeout, connection error, etc.) + raise req_err diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..484de77 --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,177 @@ +"""Unit tests for the messaging module of the Siren SDK.""" + +import pytest +import requests +from requests_mock import Mocker as RequestsMocker + +from siren.client import SirenClient +from siren.messaging import MessagingManager + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" + + +class TestMessagingManager: + """Tests for the MessagingManager class.""" + + def test_send_message_success(self, requests_mock: RequestsMocker): + """Test successful message sending.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + template_name = "test_template" + channel = "SLACK" + recipient_type = "direct" + recipient_value = "U123ABC" + template_variables = {"name": "John Doe"} + + expected_payload = { + "template": {"name": template_name}, + "recipient": {"type": recipient_type, "value": recipient_value}, + "channel": channel, + "templateVariables": template_variables, + } + mock_response_data = {"data": {"notificationId": "notif_123"}, "error": None} + + requests_mock.post( + f"{BASE_URL}/send-messages", + json=mock_response_data, + status_code=200, + ) + + response = manager.send_message( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + template_variables=template_variables, + ) + + assert response == mock_response_data + assert requests_mock.called_once + last_request = requests_mock.last_request + assert last_request is not None + assert last_request.json() == expected_payload + assert last_request.headers["Authorization"] == f"Bearer {API_KEY}" + assert last_request.headers["Content-Type"] == "application/json" + assert last_request.headers["Accept"] == "application/json" + + def test_send_message_success_no_variables(self, requests_mock: RequestsMocker): + """Test successful message sending without template variables.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + template_name = "test_template_no_vars" + channel = "EMAIL" + recipient_type = "direct" + recipient_value = "test@example.com" + + expected_payload = { + "template": {"name": template_name}, + "recipient": {"type": recipient_type, "value": recipient_value}, + "channel": channel, + } + mock_response_data = {"data": {"notificationId": "notif_456"}, "error": None} + + requests_mock.post( + f"{BASE_URL}/send-messages", + json=mock_response_data, + status_code=200, + ) + + response = manager.send_message( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + ) + + assert response == mock_response_data + assert requests_mock.called_once + last_request = requests_mock.last_request + assert last_request is not None + assert last_request.json() == expected_payload + + def test_send_message_http_error_with_json_response( + self, requests_mock: RequestsMocker + ): + """Test HTTP error with JSON response during message sending.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + error_response = {"error": "Bad Request", "message": "Invalid template name"} + requests_mock.post( + f"{BASE_URL}/send-messages", + json=error_response, + status_code=400, + ) + + response = manager.send_message( + template_name="invalid_template", + channel="SLACK", + recipient_type="direct", + recipient_value="U123", + ) + assert response == error_response + + def test_send_message_http_error_no_json_response( + self, requests_mock: RequestsMocker + ): + """Test HTTP error without JSON response during message sending.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + requests_mock.post( + f"{BASE_URL}/send-messages", + text="Internal Server Error", + status_code=500, + ) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + manager.send_message( + template_name="any_template", + channel="SLACK", + recipient_type="direct", + recipient_value="U123", + ) + assert excinfo.value.response.status_code == 500 + assert excinfo.value.response.text == "Internal Server Error" + + def test_send_message_request_exception(self, requests_mock: RequestsMocker): + """Test requests.exceptions.RequestException during message sending.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + requests_mock.post( + f"{BASE_URL}/send-messages", + exc=requests.exceptions.ConnectTimeout, + ) + + with pytest.raises(requests.exceptions.RequestException): + manager.send_message( + template_name="any_template", + channel="SLACK", + recipient_type="direct", + recipient_value="U123", + ) + + +def test_siren_client_send_message(mocker): + """Test SirenClient.send_message calls MessagingManager correctly.""" + client = SirenClient(api_key=API_KEY) + mock_messaging_manager_send = mocker.patch.object(client._messaging, "send_message") + mock_response = {"data": "success"} + mock_messaging_manager_send.return_value = mock_response + + template_name = "client_test_template" + channel = "EMAIL" + recipient_type = "direct_client" + recipient_value = "client@example.com" + template_variables = {"client_var": "client_val"} + + response = client.send_message( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + template_variables=template_variables, + ) + + assert response == mock_response + mock_messaging_manager_send.assert_called_once_with( + template_name=template_name, + channel=channel, + recipient_type=recipient_type, + recipient_value=recipient_value, + template_variables=template_variables, + ) diff --git a/uv.lock b/uv.lock index 0d88d96..5c58473 100644 --- a/uv.lock +++ b/uv.lock @@ -956,6 +956,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -1203,6 +1216,7 @@ dev = [ { name = "pytest", version = "8.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-cov", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pytest-mock" }, { name = "python-dotenv", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "python-dotenv", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "requests-mock" }, @@ -1218,6 +1232,7 @@ requires-dist = [ { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "python-dotenv", marker = "extra == 'dev'" }, { name = "requests", specifier = ">=2.25.0" }, { name = "requests-mock", marker = "extra == 'dev'" }, From be7d9b6c0d896011f23aa4767738b2742b4b14aa Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 17:45:43 +0530 Subject: [PATCH 12/42] feat: add get_replies API --- README.md | 17 +++++++ coverage.xml | 50 +++++++++++++------- examples/messages.py | 39 ++++++++++++++-- siren/client.py | 14 ++++++ siren/messaging.py | 29 ++++++++++++ tests/test_messaging.py | 100 ++++++++++++++++++++++++++++++++++++++-- 6 files changed, 223 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b1d8fdb..eabc4b8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`create_channel_templates()`](#create_channel_templates) - [`get_channel_templates()`](#get_channel_templates) - [`send_message()`](#send_message) + - [`get_replies()`](#get_replies) - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [Getting Started for Package Developers](#getting-started-for-package-developers) @@ -240,6 +241,22 @@ else: print(f"Failed to send message. Error: {response.get('error', 'Unknown error')}") ``` +### `get_replies()` + +Retrieves the replies for a specific message ID. + +**Parameters:** +* `message_id` (str): The ID of the message for which to retrieve replies. + +**Example:** +```python +# IMPORTANT: Replace with an actual message ID +message_id_to_check = "YOUR_MESSAGE_ID" + +replies_response = client.get_replies(message_id=message_id_to_check) +print(replies_response) +``` + ### `trigger_workflow()` Triggers a specified workflow with the given data and notification payloads. diff --git a/coverage.xml b/coverage.xml index 1c1b5bc..8418ec6 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -50,34 +50,50 @@ + + - - - - - + + + + - - - - - + + + + + - - - + + + - + - + + + + + + + + + + + + + + + + diff --git a/examples/messages.py b/examples/messages.py index e1b1163..2ed538e 100644 --- a/examples/messages.py +++ b/examples/messages.py @@ -48,13 +48,43 @@ def send_message_example(client: SirenClient): elif response and response.get("error"): print(f"Failed to send message. Error: {response['error']}") else: - print( - "Failed to send message. Unknown error or unexpected response format." - ) + print("Received an unexpected response structure for send_message.") except Exception as e: print(f"An error occurred while sending the message: {e}") - print("-" * 30) + + +def get_replies_example(client: SirenClient, message_id: str): + """Demonstrates retrieving replies for a message.""" + print("\nAttempting to get message replies...") + # IMPORTANT: Replace with an actual message ID that has replies + message_id_with_replies = "9004b6b0-3e77-4add-9541-56ba28c37f27" + + try: + print(f"Fetching replies for message ID: {message_id_with_replies}") + response = client.get_replies(message_id=message_id_with_replies) + print("Get message replies API response:") + print(response) + + if response and response.get("data") is not None: # Check if 'data' key exists + replies = response["data"] + if isinstance(replies, list) and replies: + print(f"Found {len(replies)} replies:") + for i, reply in enumerate(replies): + print( + f" Reply {i+1}: {reply.get('text', 'N/A')} (User: {reply.get('user', 'N/A')}, Timestamp: {reply.get('ts', 'N/A')})" + ) + elif isinstance(replies, list) and not replies: + print("No replies found for this message.") + else: + print("Received 'data' but it's not a list of replies as expected.") + elif response and response.get("error"): + print(f"Failed to get replies. Error: {response['error']}") + else: + print("Received an unexpected response structure for get_message_replies.") + + except Exception as e: + print(f"An error occurred while getting message replies: {e}") if __name__ == "__main__": @@ -67,3 +97,4 @@ def send_message_example(client: SirenClient): siren_client = SirenClient(api_key=api_key) send_message_example(siren_client) + get_replies_example(siren_client, "EXAMPLE_MESSAGE_ID") diff --git a/siren/client.py b/siren/client.py index 6b241d4..34a9fac 100644 --- a/siren/client.py +++ b/siren/client.py @@ -226,3 +226,17 @@ def send_message( recipient_value=recipient_value, template_variables=template_variables, ) + + def get_replies(self, message_id: str) -> Dict[str, Any]: + """ + Retrieve replies for a specific message ID. + + Delegates to MessagingManager.get_replies. + + Args: + message_id: The ID of the message for which to retrieve replies. + + Returns: + A dictionary containing the API response with replies. + """ + return self._messaging.get_replies(message_id=message_id) diff --git a/siren/messaging.py b/siren/messaging.py index aa7ce29..618955b 100644 --- a/siren/messaging.py +++ b/siren/messaging.py @@ -70,3 +70,32 @@ def send_message( except requests.exceptions.RequestException as req_err: # For other network errors (timeout, connection error, etc.) raise req_err + + def get_replies(self, message_id: str) -> Dict[str, Any]: + """ + Retrieve replies for a specific message. + + Args: + message_id: The ID of the message for which to retrieve replies. + + Returns: + A dictionary containing the API response with replies. + """ + endpoint = f"{self.base_url}/get-reply/{message_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + try: + response = requests.get(endpoint, headers=headers, timeout=10) + response.raise_for_status() # Raise an exception for HTTP errors + return response.json() + except requests.exceptions.HTTPError as http_err: + # Try to return JSON error response if available, otherwise re-raise + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + # For network errors or other request issues + raise req_err diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 484de77..31b9734 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -32,7 +32,7 @@ def test_send_message_success(self, requests_mock: RequestsMocker): mock_response_data = {"data": {"notificationId": "notif_123"}, "error": None} requests_mock.post( - f"{BASE_URL}/send-messages", + f"{BASE_URL}/api/v1/public/send-messages", json=mock_response_data, status_code=200, ) @@ -70,7 +70,7 @@ def test_send_message_success_no_variables(self, requests_mock: RequestsMocker): mock_response_data = {"data": {"notificationId": "notif_456"}, "error": None} requests_mock.post( - f"{BASE_URL}/send-messages", + f"{BASE_URL}/api/v1/public/send-messages", json=mock_response_data, status_code=200, ) @@ -95,7 +95,7 @@ def test_send_message_http_error_with_json_response( manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) error_response = {"error": "Bad Request", "message": "Invalid template name"} requests_mock.post( - f"{BASE_URL}/send-messages", + f"{BASE_URL}/api/v1/public/send-messages", json=error_response, status_code=400, ) @@ -114,7 +114,7 @@ def test_send_message_http_error_no_json_response( """Test HTTP error without JSON response during message sending.""" manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) requests_mock.post( - f"{BASE_URL}/send-messages", + f"{BASE_URL}/api/v1/public/send-messages", text="Internal Server Error", status_code=500, ) @@ -133,7 +133,7 @@ def test_send_message_request_exception(self, requests_mock: RequestsMocker): """Test requests.exceptions.RequestException during message sending.""" manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) requests_mock.post( - f"{BASE_URL}/send-messages", + f"{BASE_URL}/api/v1/public/send-messages", exc=requests.exceptions.ConnectTimeout, ) @@ -145,6 +145,80 @@ def test_send_message_request_exception(self, requests_mock: RequestsMocker): recipient_value="U123", ) + def test_get_replies_success(self, requests_mock: RequestsMocker): + """Test successful retrieval of message replies.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_123" + expected_replies = [ + {"text": "Reply 1", "user": "U123", "ts": "12345.6789"}, + {"text": "Reply 2", "user": "U456", "ts": "12346.7890"}, + ] + mock_response_data = {"data": expected_replies, "error": None} + + requests_mock.get( + f"{BASE_URL}/api/v1/public/get-reply/{message_id}", + json=mock_response_data, + status_code=200, + ) + response = manager.get_replies(message_id=message_id) + assert response == mock_response_data + assert response["data"] == expected_replies + + def test_get_replies_no_replies(self, requests_mock: RequestsMocker): + """Test retrieval when a message has no replies.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_no_replies" + mock_response_data = {"data": [], "error": None} + + requests_mock.get( + f"{BASE_URL}/api/v1/public/get-reply/{message_id}", + json=mock_response_data, + status_code=200, + ) + response = manager.get_replies(message_id=message_id) + assert response == mock_response_data + assert response["data"] == [] + + def test_get_replies_http_error_json_response(self, requests_mock: RequestsMocker): + """Test HTTP error with JSON response during get_replies.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_error_json" + error_response = {"error": "Not Found", "message": "Message ID does not exist."} + requests_mock.get( + f"{BASE_URL}/api/v1/public/get-reply/{message_id}", + json=error_response, + status_code=404, + ) + response = manager.get_replies(message_id=message_id) + assert response == error_response + + def test_get_replies_http_error_no_json_response( + self, requests_mock: RequestsMocker + ): + """Test HTTP error without JSON response during get_replies.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_error_no_json" + requests_mock.get( + f"{BASE_URL}/api/v1/public/get-reply/{message_id}", + text="Internal Server Error", + status_code=500, + ) + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + manager.get_replies(message_id=message_id) + assert excinfo.value.response.status_code == 500 + assert excinfo.value.response.text == "Internal Server Error" + + def test_get_replies_request_exception(self, requests_mock: RequestsMocker): + """Test requests.exceptions.RequestException during get_replies.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_req_exception" + requests_mock.get( + f"{BASE_URL}/api/v1/public/get-reply/{message_id}", + exc=requests.exceptions.Timeout("Connection timed out"), + ) + with pytest.raises(requests.exceptions.RequestException): + manager.get_replies(message_id=message_id) + def test_siren_client_send_message(mocker): """Test SirenClient.send_message calls MessagingManager correctly.""" @@ -175,3 +249,19 @@ def test_siren_client_send_message(mocker): recipient_value=recipient_value, template_variables=template_variables, ) + + +def test_siren_client_get_replies(mocker): + """Test SirenClient.get_replies calls MessagingManager correctly.""" + client = SirenClient(api_key=API_KEY) + mock_messaging_manager_get_replies = mocker.patch.object( + client._messaging, "get_replies" + ) + mock_response = {"data": [{"text": "A reply"}]} + mock_messaging_manager_get_replies.return_value = mock_response + message_id = "msg_client_test" + + response = client.get_replies(message_id=message_id) + + mock_messaging_manager_get_replies.assert_called_once_with(message_id=message_id) + assert response == mock_response From b81b1d2d6c68315812dd58c34639030106e78fcc Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 17:55:12 +0530 Subject: [PATCH 13/42] feat: Add get_message_status endpoint --- README.md | 17 +++++++++ coverage.xml | 24 ++++++++++--- examples/messages.py | 31 +++++++++++++++-- siren/client.py | 14 ++++++-- siren/messaging.py | 30 ++++++++++++++++ tests/test_messaging.py | 76 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index eabc4b8..1aeab47 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`get_channel_templates()`](#get_channel_templates) - [`send_message()`](#send_message) - [`get_replies()`](#get_replies) + - [`get_message_status()`](#get_message_status) - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [Getting Started for Package Developers](#getting-started-for-package-developers) @@ -257,6 +258,22 @@ replies_response = client.get_replies(message_id=message_id_to_check) print(replies_response) ``` +### `get_message_status()` + +Retrieves the status of a specific message ID (e.g., "SENT", "DELIVERED", "FAILED"). + +**Parameters:** +* `message_id` (str): The ID of the message for which to retrieve the status. + +**Example:** +```python +# IMPORTANT: Replace with an actual message ID +message_id_to_check = "YOUR_MESSAGE_ID" + +status_response = client.get_message_status(message_id=message_id_to_check) +print(status_response) +``` + ### `trigger_workflow()` Triggers a specified workflow with the given data and notification payloads. diff --git a/coverage.xml b/coverage.xml index 8418ec6..8b2bcd8 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -52,6 +52,8 @@ + + @@ -91,9 +93,23 @@ - - + + + + + + + + + + + + + + + + diff --git a/examples/messages.py b/examples/messages.py index 2ed538e..5f4db84 100644 --- a/examples/messages.py +++ b/examples/messages.py @@ -87,6 +87,30 @@ def get_replies_example(client: SirenClient, message_id: str): print(f"An error occurred while getting message replies: {e}") +def get_message_status_example(client: SirenClient, message_id: str): + """Demonstrates retrieving the status for a message.""" + print("\nAttempting to get message status...") + # IMPORTANT: Replace with an actual message ID + # Using an ID from a previous successful run for demonstration + message_id_to_check_status = message_id + + try: + print(f"Fetching status for message ID: {message_id_to_check_status}") + response = client.get_message_status(message_id=message_id_to_check_status) + print("Get message status API response:") + print(response) + + if response and response.get("data") and response["data"].get("status"): + print(f"Message Status: {response['data']['status']}") + elif response and response.get("error"): + print(f"Failed to get message status. Error: {response['error']}") + else: + print("Received an unexpected response structure for get_message_status.") + + except Exception as e: + print(f"An error occurred while getting the message status: {e}") + + if __name__ == "__main__": load_dotenv() api_key = os.getenv("SIREN_API_KEY") @@ -96,5 +120,8 @@ def get_replies_example(client: SirenClient, message_id: str): sys.exit(1) siren_client = SirenClient(api_key=api_key) - send_message_example(siren_client) - get_replies_example(siren_client, "EXAMPLE_MESSAGE_ID") + # send_message_example(siren_client) + # get_replies_example( + # siren_client, "9004b6b0-3e77-4add-9541-56ba28c37f27" + # ) + get_message_status_example(siren_client, "c53539ce-2d74-4071-b671-ead6c8465b5b") diff --git a/siren/client.py b/siren/client.py index 34a9fac..b5ba669 100644 --- a/siren/client.py +++ b/siren/client.py @@ -231,8 +231,6 @@ def get_replies(self, message_id: str) -> Dict[str, Any]: """ Retrieve replies for a specific message ID. - Delegates to MessagingManager.get_replies. - Args: message_id: The ID of the message for which to retrieve replies. @@ -240,3 +238,15 @@ def get_replies(self, message_id: str) -> Dict[str, Any]: A dictionary containing the API response with replies. """ return self._messaging.get_replies(message_id=message_id) + + def get_message_status(self, message_id: str) -> Dict[str, Any]: + """ + Retrieve the status of a specific message. + + Args: + message_id: The ID of the message for which to retrieve the status. + + Returns: + A dictionary containing the API response with the message status. + """ + return self._messaging.get_message_status(message_id=message_id) diff --git a/siren/messaging.py b/siren/messaging.py index 618955b..8b97465 100644 --- a/siren/messaging.py +++ b/siren/messaging.py @@ -71,6 +71,36 @@ def send_message( # For other network errors (timeout, connection error, etc.) raise req_err + def get_message_status(self, message_id: str) -> Dict[str, Any]: + """ + Retrieve the status of a specific message. + + Args: + message_id: The ID of the message for which to retrieve the status. + + Returns: + A dictionary containing the API response with the message status. + """ + endpoint = f"{self.base_url}/message-status/{message_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Accept": "application/json", + } + try: + response = requests.get(endpoint, headers=headers, timeout=10) + response.raise_for_status() # Raise an exception for HTTP errors + return response.json() + except requests.exceptions.HTTPError as http_err: + # Try to return JSON error response from API if available + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + # If response is not JSON, re-raise the original HTTPError + raise http_err + except requests.exceptions.RequestException as req_err: + # For other network errors (timeout, connection error, etc.) + raise req_err + def get_replies(self, message_id: str) -> Dict[str, Any]: """ Retrieve replies for a specific message. diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 31b9734..06f301e 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -14,6 +14,66 @@ class TestMessagingManager: """Tests for the MessagingManager class.""" + def test_get_message_status_success(self, requests_mock: RequestsMocker): + """Test successful retrieval of message status.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_status_123" + expected_status = {"status": "DELIVERED"} + mock_response_data = {"data": expected_status, "error": None} + + requests_mock.get( + f"{BASE_URL}/api/v1/public/message-status/{message_id}", + json=mock_response_data, + status_code=200, + ) + response = manager.get_message_status(message_id=message_id) + assert response == mock_response_data + assert response["data"] == expected_status + + def test_get_message_status_http_error_json_response( + self, requests_mock: RequestsMocker + ): + """Test HTTP error with JSON response during get_message_status.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_status_error_json" + error_response = {"error": "Not Found", "message": "Message ID does not exist."} + requests_mock.get( + f"{BASE_URL}/api/v1/public/message-status/{message_id}", + json=error_response, + status_code=404, + ) + response = manager.get_message_status(message_id=message_id) + assert response == error_response + + def test_get_message_status_http_error_no_json_response( + self, requests_mock: RequestsMocker + ): + """Test HTTP error without JSON response during get_message_status.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_status_error_no_json" + requests_mock.get( + f"{BASE_URL}/api/v1/public/message-status/{message_id}", + text="Server Error", + status_code=500, + ) + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + manager.get_message_status(message_id=message_id) + assert excinfo.value.response.status_code == 500 + assert excinfo.value.response.text == "Server Error" + + def test_get_message_status_request_exception(self, requests_mock: RequestsMocker): + """Test requests.exceptions.RequestException during get_message_status.""" + manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + message_id = "msg_status_req_exception" + requests_mock.get( + f"{BASE_URL}/api/v1/public/message-status/{message_id}", + exc=requests.exceptions.Timeout("Connection timed out"), + ) + with pytest.raises(requests.exceptions.RequestException): + manager.get_message_status(message_id=message_id) + + """Tests for the MessagingManager class.""" + def test_send_message_success(self, requests_mock: RequestsMocker): """Test successful message sending.""" manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) @@ -265,3 +325,19 @@ def test_siren_client_get_replies(mocker): mock_messaging_manager_get_replies.assert_called_once_with(message_id=message_id) assert response == mock_response + + +def test_siren_client_get_message_status(mocker): + """Test SirenClient.get_message_status calls MessagingManager correctly.""" + client = SirenClient(api_key=API_KEY) + mock_messaging_manager_get_status = mocker.patch.object( + client._messaging, "get_message_status" + ) + mock_response = {"data": {"status": "SENT"}} + mock_messaging_manager_get_status.return_value = mock_response + message_id = "msg_client_status_test" + + response = client.get_message_status(message_id=message_id) + + mock_messaging_manager_get_status.assert_called_once_with(message_id=message_id) + assert response == mock_response From e5a182d69f911870a6a9e069f3c44925035d9116 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 18:26:30 +0530 Subject: [PATCH 14/42] feat: add webhook config API --- README.md | 32 +++++++++- coverage.xml | 88 +++++++++++++++++-------- examples/webhooks.py | 85 ++++++++++++++++++++++++ siren/client.py | 30 +++++++++ siren/webhooks.py | 69 ++++++++++++++++++++ tests/test_webhooks.py | 142 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 26 deletions(-) create mode 100644 examples/webhooks.py create mode 100644 siren/webhooks.py create mode 100644 tests/test_webhooks.py diff --git a/README.md b/README.md index 1aeab47..10b763a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`send_message()`](#send_message) - [`get_replies()`](#get_replies) - [`get_message_status()`](#get_message_status) + - [`configure_notifications_webhook()`](#configure_notifications_webhook) + - [`configure_inbound_message_webhook()`](#configure_inbound_message_webhook) - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [Getting Started for Package Developers](#getting-started-for-package-developers) @@ -271,7 +273,35 @@ Retrieves the status of a specific message ID (e.g., "SENT", "DELIVERED", "FAILE message_id_to_check = "YOUR_MESSAGE_ID" status_response = client.get_message_status(message_id=message_id_to_check) -print(status_response) +print(f"Message status: {status_response['data']['status']}") +``` + +### `configure_notifications_webhook()` + +Configures the webhook URL for receiving status updates and other notifications from Siren. + +**Parameters:** +* `url` (str): The URL that Siren will send POST requests to for notifications. + +**Example:** +```python +webhook_url = "https://your-service.com/siren/notifications" +response = client.configure_notifications_webhook(url=webhook_url) +print(response) +``` + +### `configure_inbound_message_webhook()` + +Configures the webhook URL for receiving inbound messages forwarded by Siren. + +**Parameters:** +* `url` (str): The URL that Siren will send POST requests to for inbound messages. + +**Example:** +```python +webhook_url = "https://your-service.com/siren/inbound" +response = client.configure_inbound_message_webhook(url=webhook_url) +print(response) ``` ### `trigger_workflow()` diff --git a/coverage.xml b/coverage.xml index 8b2bcd8..55a5891 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -23,37 +23,43 @@ - - - - + + + + - - + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - + + + + + + + + @@ -245,6 +251,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/webhooks.py b/examples/webhooks.py new file mode 100644 index 0000000..f4658f5 --- /dev/null +++ b/examples/webhooks.py @@ -0,0 +1,85 @@ +"""Examples for configuring webhooks using the Siren SDK.""" + +import os +import sys + +from dotenv import load_dotenv + +from siren import SirenClient + + +def configure_notifications_webhook_example(client: SirenClient, webhook_url: str): + """Demonstrates configuring the notifications webhook.""" + print("\nAttempting to configure notifications webhook...") + try: + print(f"Configuring notifications webhook URL to: {webhook_url}") + response = client.configure_notifications_webhook(url=webhook_url) + print("Configure notifications webhook API response:") + print(response) + + if response and response.get("data") and response["data"].get("id"): + print( + f"Successfully configured notifications webhook. ID: {response['data']['id']}" + ) + if response["data"].get("webhookConfig"): + print(f"Configured URL: {response['data']['webhookConfig'].get('url')}") + print( + f"Verification Key: {response['data']['webhookConfig'].get('verificationKey')}" + ) + elif response and response.get("error"): + print( + f"Failed to configure notifications webhook. Error: {response['error']}" + ) + else: + print("Received an unexpected response structure.") + + except Exception as e: + print(f"An error occurred: {e}") + + +def configure_inbound_message_webhook_example(client: SirenClient, webhook_url: str): + """Demonstrates configuring the inbound message webhook.""" + print("\nAttempting to configure inbound message webhook...") + try: + print(f"Configuring inbound message webhook URL to: {webhook_url}") + response = client.configure_inbound_message_webhook(url=webhook_url) + print("Configure inbound message webhook API response:") + print(response) + + if response and response.get("data") and response["data"].get("id"): + print( + f"Successfully configured inbound message webhook. ID: {response['data']['id']}" + ) + if response["data"].get("inboundWebhookConfig"): + print( + f"Configured URL: {response['data']['inboundWebhookConfig'].get('url')}" + ) + print( + f"Verification Key: {response['data']['inboundWebhookConfig'].get('verificationKey')}" + ) + elif response and response.get("error"): + print( + f"Failed to configure inbound message webhook. Error: {response['error']}" + ) + else: + print("Received an unexpected response structure.") + + except Exception as e: + print(f"An error occurred: {e}") + + +if __name__ == "__main__": + load_dotenv() + api_key = os.getenv("SIREN_API_KEY") + if not api_key: + print("Error: SIREN_API_KEY not found in environment variables or .env file.") + print("Please set it to run the example.") + sys.exit(1) + + siren_client = SirenClient(api_key=api_key) + + # IMPORTANT: Replace with your desired webhook URL + example_webhook_url = "https://siren-ai-test.example.com/siren" + + configure_notifications_webhook_example(siren_client, example_webhook_url) + configure_inbound_message_webhook_example(siren_client, example_webhook_url) diff --git a/siren/client.py b/siren/client.py index b5ba669..2c63a21 100644 --- a/siren/client.py +++ b/siren/client.py @@ -4,6 +4,7 @@ from .messaging import MessagingManager from .templates import TemplatesManager +from .webhooks import WebhookManager from .workflows import WorkflowsManager @@ -27,6 +28,9 @@ def __init__(self, api_key: str): api_key=self.api_key, base_url=self.BASE_API_URL, # Note: WorkflowsManager uses /api/v2 internally ) + self._webhooks = WebhookManager( + api_key=self.api_key, base_url=self.BASE_API_URL + ) self._messaging = MessagingManager( api_key=self.api_key, base_url=self.BASE_API_URL ) @@ -250,3 +254,29 @@ def get_message_status(self, message_id: str) -> Dict[str, Any]: A dictionary containing the API response with the message status. """ return self._messaging.get_message_status(message_id=message_id) + + # Webhook Management + + def configure_notifications_webhook(self, url: str) -> Dict[str, Any]: + """ + Configure the webhook for outgoing notifications. + + Args: + url: The URL to be configured for the notifications webhook. + + Returns: + A dictionary containing the API response. + """ + return self._webhooks.configure_notifications_webhook(url=url) + + def configure_inbound_message_webhook(self, url: str) -> Dict[str, Any]: + """ + Configure the webhook for inbound messages. + + Args: + url: The URL to be configured for the inbound message webhook. + + Returns: + A dictionary containing the API response. + """ + return self._webhooks.configure_inbound_message_webhook(url=url) diff --git a/siren/webhooks.py b/siren/webhooks.py new file mode 100644 index 0000000..5798690 --- /dev/null +++ b/siren/webhooks.py @@ -0,0 +1,69 @@ +"""Manages webhook-related API interactions for the Siren SDK.""" + +from typing import Any, Dict + +import requests + + +class WebhookManager: + """Manages webhook configuration operations.""" + + def __init__(self, api_key: str, base_url: str): + """ + Initialize the WebhookManager. + + Args: + api_key: The API key for authentication. + base_url: The base URL of the Siren API. + """ + self.api_key = api_key + self.base_url = f"{base_url}/api/v1/public" + + def _make_put_request( + self, endpoint: str, payload: Dict[str, Any] + ) -> Dict[str, Any]: + """Helper function to make PUT requests and handle common logic.""" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + try: + response = requests.put(endpoint, headers=headers, json=payload, timeout=10) + response.raise_for_status() # Raise an exception for HTTP errors + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err + + def configure_notifications_webhook(self, url: str) -> Dict[str, Any]: + """ + Configure the webhook for notifications. + + Args: + url: The URL to be configured for the notifications webhook. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/webhooks" + payload = {"webhookConfig": {"url": url}} + return self._make_put_request(endpoint, payload) + + def configure_inbound_message_webhook(self, url: str) -> Dict[str, Any]: + """ + Configure the webhook for inbound messages. + + Args: + url: The URL to be configured for the inbound message webhook. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.base_url}/webhooks" + payload = {"inboundWebhookConfig": {"url": url}} + return self._make_put_request(endpoint, payload) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..0eb9a51 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,142 @@ +"""Unit tests for the webhook module of the Siren SDK.""" + +import pytest +import requests +from requests_mock import Mocker as RequestsMocker + +from siren.client import SirenClient +from siren.webhooks import WebhookManager + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" +WEBHOOK_URL = "https://example.com/webhook" + + +class TestWebhookManager: + """Tests for the WebhookManager class.""" + + def test_configure_notifications_webhook_success( + self, requests_mock: RequestsMocker + ): + """Test successful configuration of notifications webhook.""" + manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) + expected_response = { + "data": {"id": "wh_123", "webhookConfig": {"url": WEBHOOK_URL}} + } + requests_mock.put( + f"{BASE_URL}/api/v1/public/webhooks", + json=expected_response, + status_code=200, + ) + response = manager.configure_notifications_webhook(url=WEBHOOK_URL) + assert response == expected_response + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == { + "webhookConfig": {"url": WEBHOOK_URL} + } + + def test_configure_inbound_message_webhook_success( + self, requests_mock: RequestsMocker + ): + """Test successful configuration of inbound message webhook.""" + manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) + expected_response = { + "data": {"id": "wh_456", "inboundWebhookConfig": {"url": WEBHOOK_URL}} + } + requests_mock.put( + f"{BASE_URL}/api/v1/public/webhooks", + json=expected_response, + status_code=200, + ) + response = manager.configure_inbound_message_webhook(url=WEBHOOK_URL) + assert response == expected_response + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == { + "inboundWebhookConfig": {"url": WEBHOOK_URL} + } + + @pytest.mark.parametrize( + "method_name", + ["configure_notifications_webhook", "configure_inbound_message_webhook"], + ) + def test_webhook_http_error_json_response( + self, requests_mock: RequestsMocker, method_name: str + ): + """Test HTTP error with JSON response during webhook configuration.""" + manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) + error_response = {"error": "Bad Request", "message": "Invalid URL"} + requests_mock.put( + f"{BASE_URL}/api/v1/public/webhooks", + json=error_response, + status_code=400, + ) + method_to_call = getattr(manager, method_name) + response = method_to_call(url=WEBHOOK_URL) + assert response == error_response + + @pytest.mark.parametrize( + "method_name", + ["configure_notifications_webhook", "configure_inbound_message_webhook"], + ) + def test_webhook_http_error_no_json_response( + self, requests_mock: RequestsMocker, method_name: str + ): + """Test HTTP error without JSON response during webhook configuration.""" + manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) + requests_mock.put( + f"{BASE_URL}/api/v1/public/webhooks", + text="Server Error", + status_code=500, + ) + method_to_call = getattr(manager, method_name) + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + method_to_call(url=WEBHOOK_URL) + assert excinfo.value.response.status_code == 500 + assert excinfo.value.response.text == "Server Error" + + @pytest.mark.parametrize( + "method_name", + ["configure_notifications_webhook", "configure_inbound_message_webhook"], + ) + def test_webhook_request_exception( + self, requests_mock: RequestsMocker, method_name: str + ): + """Test requests.exceptions.RequestException during webhook configuration.""" + manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) + requests_mock.put( + f"{BASE_URL}/api/v1/public/webhooks", + exc=requests.exceptions.Timeout("Connection timed out"), + ) + method_to_call = getattr(manager, method_name) + with pytest.raises(requests.exceptions.RequestException): + method_to_call(url=WEBHOOK_URL) + + +def test_siren_client_configure_notifications_webhook(mocker): + """Test SirenClient.configure_notifications_webhook calls WebhookManager correctly.""" + client = SirenClient(api_key=API_KEY) + mock_webhook_manager_method = mocker.patch.object( + client._webhooks, "configure_notifications_webhook" + ) + expected_response = {"data": "success"} + mock_webhook_manager_method.return_value = expected_response + + response = client.configure_notifications_webhook(url=WEBHOOK_URL) + + mock_webhook_manager_method.assert_called_once_with(url=WEBHOOK_URL) + assert response == expected_response + + +def test_siren_client_configure_inbound_message_webhook(mocker): + """Test SirenClient.configure_inbound_message_webhook calls WebhookManager correctly.""" + client = SirenClient(api_key=API_KEY) + mock_webhook_manager_method = mocker.patch.object( + client._webhooks, "configure_inbound_message_webhook" + ) + expected_response = {"data": "success_inbound"} + mock_webhook_manager_method.return_value = expected_response + + response = client.configure_inbound_message_webhook(url=WEBHOOK_URL) + + mock_webhook_manager_method.assert_called_once_with(url=WEBHOOK_URL) + assert response == expected_response From 473e5eaa69ef7481ff3c46eca462dc1f436e9001 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 19:06:21 +0530 Subject: [PATCH 15/42] feat: add schedule workflow api --- README.md | 44 ++++++++ coverage.xml | 74 ++++++++----- examples/workflows.py | 42 +++++++- siren/client.py | 38 +++++++ siren/workflows.py | 75 ++++++++++++- tests/test_workflows.py | 228 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 469 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 10b763a..89350fe 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`configure_inbound_message_webhook()`](#configure_inbound_message_webhook) - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) + - [`schedule_workflow()`](#schedule_workflow) - [Getting Started for Package Developers](#getting-started-for-package-developers) - [Prerequisites](#prerequisites) - [Setup Steps](#setup-steps) @@ -391,6 +392,49 @@ minimal_bulk_response = client.trigger_bulk_workflow( print(minimal_bulk_response) ``` +### `schedule_workflow()` + +Schedules a workflow to run at a future time, either once or on a recurring basis. + +**Parameters:** +* `name` (str): A descriptive name for the schedule. +* `schedule_time` (str): The time of day for the schedule to run (e.g., "09:00:00"). +* `timezone_id` (str): The IANA timezone ID for the schedule (e.g., "America/New_York"). +* `start_date` (str): The date when the schedule should start (e.g., "2025-08-01"). +* `workflow_type` (str): The type of recurrence (e.g., "ONCE", "DAILY", "WEEKLY", "MONTHLY"). +* `workflow_id` (str): The ID of the workflow to schedule. +* `input_data` (Dict[str, Any]): The input data for the workflow. +* `end_date` (Optional[str]): The date when the schedule should end (inclusive). Required for recurring schedules other than "ONCE". Defaults to `None`. + +**Example:** +```python +# Schedule a workflow to run daily +daily_schedule_response = client.schedule_workflow( + name="Daily Report Generation", + schedule_time="09:00:00", + timezone_id="America/New_York", + start_date="2025-08-01", + workflow_type="DAILY", + workflow_id="YOUR_WORKFLOW_ID", + input_data={"report_type": "sales_summary"}, + end_date="2025-08-31" +) +print(daily_schedule_response) + +# Schedule a workflow to run once +once_schedule_response = client.schedule_workflow( + name="One-Time Data Processing", + schedule_time="15:30:00", + timezone_id="America/New_York", + start_date="2025-09-15", + workflow_type="ONCE", + workflow_id="YOUR_OTHER_WORKFLOW_ID", + input_data={"task_id": "process_batch_xyz"} + # end_date is not provided for "ONCE" type +) +print(once_schedule_response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 55a5891..86f2168 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -51,15 +51,17 @@ - - + - - - - + + + + + + + @@ -294,42 +296,62 @@ - + + + - - - + + + - - - - + + + - - - - - + + + + + - - + + + - - + + - + + + + + + + + + + + + + + + + + + + diff --git a/examples/workflows.py b/examples/workflows.py index 8c40469..b83ed2e 100644 --- a/examples/workflows.py +++ b/examples/workflows.py @@ -8,6 +8,9 @@ # This allows running the script directly from the examples directory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +import json # For pretty printing the response + +from requests.exceptions import HTTPError, RequestException # For handling API errors def run_trigger_workflow_example(client: SirenClient): @@ -85,6 +88,42 @@ def run_trigger_bulk_workflow_example(client: SirenClient): ) +def run_schedule_workflow_example(client: SirenClient): + """Demonstrates scheduling workflows using the Siren SDK.""" + print("\n--- Running Schedule Workflow Example ---") + timezone_id = "America/New_York" + schedule_name_once = "My One-Time Task via SDK Example" + schedule_time_once = "19:04:00" + start_date_once = "2025-06-09" + workflow_type_once = "ONCE" + workflow_id_once = "bcd59a55-1072-41a7-90d9-5554b21aef1b" + input_data_once = {"task_name": "sdk_once_example_processing", "details": "Urgent"} + + try: + response_once = client.schedule_workflow( + name=schedule_name_once, + schedule_time=schedule_time_once, + timezone_id=timezone_id, # Can use the same or a different one + start_date=start_date_once, + workflow_type=workflow_type_once, + workflow_id=workflow_id_once, + input_data=input_data_once, + # end_date is omitted for ONCE type + ) + print("Successfully scheduled ONCE workflow. Response:") + print(json.dumps(response_once, indent=2)) + except HTTPError as e: + print(f"HTTP Error scheduling ONCE workflow (Status {e.response.status_code}):") + try: + print(f"Error details: {json.dumps(e.response.json(), indent=2)}") + except json.JSONDecodeError: + print(f"Error details (non-JSON): {e.response.text}") + except RequestException as e: + print(f"Request Error scheduling ONCE workflow: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + + if __name__ == "__main__": api_key = os.environ.get("SIREN_API_KEY") if not api_key: @@ -94,4 +133,5 @@ def run_trigger_bulk_workflow_example(client: SirenClient): siren_client = SirenClient(api_key=api_key) # run_trigger_workflow_example(siren_client) - run_trigger_bulk_workflow_example(siren_client) + # run_trigger_bulk_workflow_example(siren_client) + run_schedule_workflow_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 2c63a21..84e5a19 100644 --- a/siren/client.py +++ b/siren/client.py @@ -203,6 +203,44 @@ def trigger_bulk_workflow( workflow_name=workflow_name, notify=notify, data=data ) + def schedule_workflow( + self, + name: str, + schedule_time: str, + timezone_id: str, + start_date: str, + workflow_type: str, + workflow_id: str, + input_data: Dict[str, Any], + end_date: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Schedules a workflow execution. + + Args: + name: Name of the schedule. + schedule_time: Time for the schedule in "HH:MM:SS" format. + timezone_id: Timezone ID (e.g., "Asia/Kolkata"). + start_date: Start date for the schedule in "YYYY-MM-DD" format. + workflow_type: Type of schedule (e.g., "ONCE", "DAILY"). + workflow_id: ID of the workflow to schedule. + input_data: Input data for the workflow. + end_date: Optional end date for the schedule in "YYYY-MM-DD" format. + + Returns: + A dictionary containing the API response. + """ + return self._workflows.schedule_workflow( + name=name, + schedule_time=schedule_time, + timezone_id=timezone_id, + start_date=start_date, + workflow_type=workflow_type, + workflow_id=workflow_id, + input_data=input_data, + end_date=end_date, + ) + def send_message( self, template_name: str, diff --git a/siren/workflows.py b/siren/workflows.py index cb765d9..e74a4f7 100644 --- a/siren/workflows.py +++ b/siren/workflows.py @@ -15,7 +15,7 @@ def __init__(self, base_url: str, api_key: str): base_url: The general base URL for the Siren API (e.g., 'https://api.trysiren.io'). api_key: The API key for authentication. """ - self.base_url = f"{base_url}/api/v2" + self.root_base_url = base_url # Store the root base URL self.api_key = api_key def trigger_workflow( @@ -35,9 +35,7 @@ def trigger_workflow( Returns: A dictionary containing the API response. """ - endpoint = ( - f"{self.base_url}/workflows/trigger" # self.base_url now includes /api/v2 - ) + endpoint = f"{self.root_base_url}/api/v2/workflows/trigger" headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", @@ -91,7 +89,7 @@ def trigger_bulk_workflow( Returns: A dictionary containing the API response. """ - endpoint = f"{self.base_url}/workflows/trigger/bulk" + endpoint = f"{self.root_base_url}/api/v2/workflows/trigger/bulk" headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", @@ -124,3 +122,70 @@ def trigger_bulk_workflow( raise new_err from http_err except requests.exceptions.RequestException as req_err: raise req_err + + def schedule_workflow( + self, + name: str, + schedule_time: str, # Format: "HH:MM:SS" + timezone_id: str, # E.g., "Asia/Kolkata" + start_date: str, # Format: "YYYY-MM-DD" + workflow_type: str, # E.g., "ONCE", "DAILY", "WEEKLY" + workflow_id: str, + input_data: Dict[str, Any], + end_date: Optional[str] = None, # Format: "YYYY-MM-DD" + ) -> Dict[str, Any]: + """ + Schedules a workflow execution. + + Args: + name: Name of the schedule. + schedule_time: Time for the schedule in "HH:MM:SS" format. + timezone_id: Timezone ID (e.g., "Asia/Kolkata"). + start_date: Start date for the schedule in "YYYY-MM-DD" format. + workflow_type: Type of schedule (e.g., "ONCE", "DAILY"). + workflow_id: ID of the workflow to schedule. + input_data: Input data for the workflow. + end_date: Optional end date for the schedule in "YYYY-MM-DD" format. + + Returns: + A dictionary containing the API response. + """ + endpoint = f"{self.root_base_url}/api/v1/public/schedules" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + payload: Dict[str, Any] = { + "name": name, + "scheduleTime": schedule_time, + "timezoneId": timezone_id, + "startDate": start_date, + "type": workflow_type, + "workflowId": workflow_id, + "inputData": input_data, + } + if end_date is not None: + payload["endDate"] = end_date + elif ( + workflow_type == "ONCE" + ): # API expects an empty string if not provided for type ONCE + payload["endDate"] = "" + + try: + response = requests.post( + endpoint, headers=headers, json=payload, timeout=10 + ) + response.raise_for_status() # Raises HTTPError for 4XX/5XX + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + return http_err.response.json() + except requests.exceptions.JSONDecodeError: + new_err = requests.exceptions.HTTPError( + f"{http_err}\nResponse text: {http_err.response.text}", + response=http_err.response, + ) + raise new_err from http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 7e196f7..be75b8b 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -318,3 +318,231 @@ def test_trigger_bulk_workflow_http_error_non_json_response( assert non_json_error_text in str(excinfo.value) assert excinfo.value.response is not None assert excinfo.value.response.status_code == 503 + + +# --- Tests for schedule_workflow --- # + +SCHEDULE_NAME = "Test Schedule" +SCHEDULE_TIME = "10:00:00" +TIMEZONE_ID = "America/New_York" +START_DATE = "2025-12-01" +END_DATE_SPECIFIED = "2025-12-31" +WORKFLOW_TYPE_ONCE = "ONCE" +WORKFLOW_TYPE_DAILY = "DAILY" +SCHEDULE_WORKFLOW_ID = "wf_schedule_abc123" +INPUT_DATA = {"param1": "value1", "param2": 123} + +MOCK_V1_PUBLIC_BASE = f"{SirenClient.BASE_API_URL}/api/v1/public" + + +def test_schedule_workflow_success_all_params( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow with all parameters successfully.""" + expected_api_response = { + "data": { + "id": "sch_12345", + "name": SCHEDULE_NAME, + "type": WORKFLOW_TYPE_DAILY, + "startDate": START_DATE, + "endDate": END_DATE_SPECIFIED, + "scheduleTime": SCHEDULE_TIME, + "timezoneId": TIMEZONE_ID, + "workflowId": SCHEDULE_WORKFLOW_ID, + "inputData": INPUT_DATA, + "status": "ACTIVE", + }, + "error": None, + "errors": None, + "meta": None, + } + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + requests_mock.post(mock_url, json=expected_api_response, status_code=200) + + response = client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_DAILY, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + end_date=END_DATE_SPECIFIED, + ) + + assert response == expected_api_response + history = requests_mock.request_history + assert len(history) == 1 + assert history[0].method == "POST" + assert history[0].url == mock_url + assert history[0].json() == { + "name": SCHEDULE_NAME, + "scheduleTime": SCHEDULE_TIME, + "timezoneId": TIMEZONE_ID, + "startDate": START_DATE, + "type": WORKFLOW_TYPE_DAILY, + "workflowId": SCHEDULE_WORKFLOW_ID, + "inputData": INPUT_DATA, + "endDate": END_DATE_SPECIFIED, + } + assert history[0].headers["Authorization"] == f"Bearer {API_KEY}" + + +def test_schedule_workflow_success_once_no_end_date( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow for ONCE type with no end_date successfully.""" + expected_api_response = { + "data": { + "id": "sch_67890", + "name": SCHEDULE_NAME, + "type": WORKFLOW_TYPE_ONCE, + "startDate": START_DATE, + "endDate": START_DATE, # API might return startDate as endDate for ONCE + "scheduleTime": SCHEDULE_TIME, + "timezoneId": TIMEZONE_ID, + "workflowId": SCHEDULE_WORKFLOW_ID, + "inputData": INPUT_DATA, + "status": "ACTIVE", + }, + "error": None, + "errors": None, + "meta": None, + } + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + requests_mock.post(mock_url, json=expected_api_response, status_code=200) + + response = client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + end_date=None, # Explicitly None + ) + + assert response == expected_api_response + history = requests_mock.request_history + assert len(history) == 1 + assert history[0].json() == { + "name": SCHEDULE_NAME, + "scheduleTime": SCHEDULE_TIME, + "timezoneId": TIMEZONE_ID, + "startDate": START_DATE, + "type": WORKFLOW_TYPE_ONCE, + "workflowId": SCHEDULE_WORKFLOW_ID, + "inputData": INPUT_DATA, + "endDate": "", # Expected to be an empty string for ONCE type + } + + +def test_schedule_workflow_http_400_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow handles HTTP 400 Bad Request error.""" + error_response = { + "data": None, + "error": {"errorCode": "VALIDATION_ERROR", "message": "Invalid input"}, + "errors": [{"field": "scheduleTime", "message": "Invalid time format"}], + "meta": None, + } + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + requests_mock.post(mock_url, json=error_response, status_code=400) + + response = client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time="invalid-time", # Intentionally invalid + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + ) + assert response == error_response + + +def test_schedule_workflow_http_401_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow handles HTTP 401 Unauthorized error.""" + error_response = {"detail": "Authentication credentials were not provided."} + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + requests_mock.post(mock_url, json=error_response, status_code=401) + + response = client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + ) + assert response == error_response + + +def test_schedule_workflow_http_404_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow handles HTTP 404 Not Found error (e.g., bad workflowId).""" + error_response = {"detail": "Workflow not found."} + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + requests_mock.post(mock_url, json=error_response, status_code=404) + + response = client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id="wf_non_existent_xyz789", + input_data=INPUT_DATA, + ) + assert response == error_response + + +def test_schedule_workflow_network_error( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow handles a network error.""" + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + requests_mock.post( + mock_url, exc=requests.exceptions.ConnectionError("Connection failed") + ) + + with pytest.raises(requests.exceptions.RequestException): + client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + ) + + +def test_schedule_workflow_http_error_non_json_response( + client: SirenClient, requests_mock: RequestsMocker +): + """Test schedule_workflow handles HTTP error with non-JSON response.""" + mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" + non_json_error_text = "Gateway Timeout" + requests_mock.post(mock_url, text=non_json_error_text, status_code=504) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + ) + + assert non_json_error_text in str(excinfo.value) + assert excinfo.value.response is not None + assert excinfo.value.response.status_code == 504 From 480546e4dee81af1393f3b8e13a5ccb41ca2b8d2 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 9 Jun 2025 19:36:39 +0530 Subject: [PATCH 16/42] feat: add user API --- README.md | 40 +++++++++ coverage.xml | 132 +++++++++++++++++++--------- examples/users.py | 50 +++++++++++ siren/client.py | 49 ++++++++++- siren/users.py | 103 ++++++++++++++++++++++ tests/test_users.py | 204 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 534 insertions(+), 44 deletions(-) create mode 100644 examples/users.py create mode 100644 siren/users.py create mode 100644 tests/test_users.py diff --git a/README.md b/README.md index 89350fe..c3bc375 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`trigger_workflow()`](#trigger_workflow) - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [`schedule_workflow()`](#schedule_workflow) + - [`add_user()`](#add_user) - [Getting Started for Package Developers](#getting-started-for-package-developers) - [Prerequisites](#prerequisites) - [Setup Steps](#setup-steps) @@ -435,6 +436,45 @@ once_schedule_response = client.schedule_workflow( print(once_schedule_response) ``` +### `add_user()` + +Creates a new user or updates an existing user if a user with the given `unique_id` already exists. + +**Parameters:** +* `unique_id` (str): The unique identifier for the user. This is a required field. +* `first_name` (Optional[str]): The user's first name. +* `last_name` (Optional[str]): The user's last name. +* `reference_id` (Optional[str]): An external reference ID for the user. +* `whatsapp` (Optional[str]): The user's WhatsApp number (e.g., "+14155552671"). +* `active_channels` (Optional[List[str]]): A list of channels the user is active on (e.g., `["EMAIL", "SMS", "WHATSAPP"]`). +* `active` (Optional[bool]): Boolean indicating if the user is active. Defaults to `True` if not specified by the API. +* `email` (Optional[str]): The user's email address. +* `phone` (Optional[str]): The user's phone number (e.g., "+14155552671"). +* `attributes` (Optional[Dict[str, Any]]): A dictionary of additional custom attributes for the user. + +**Example:** +```python +# Add a new user +new_user_payload = { + "unique_id": "sdk_user_123", + "first_name": "SDK", + "last_name": "TestUser", + "email": "sdk.testuser@example.com", + "active_channels": ["EMAIL"], + "attributes": {"source": "python_sdk_example"} +} +response = client.add_user(**new_user_payload) +print(response) + +# Update an existing user (e.g., add a phone number) +update_user_payload = { + "unique_id": "sdk_user_123", # Same unique_id + "phone": "+15551234567" +} +response = client.add_user(**update_user_payload) +print(response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 86f2168..584d7a5 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -24,44 +24,48 @@ - - - - + + + + - - - + + + + - - - + + + - - - + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -253,6 +257,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -339,19 +389,19 @@ - - - - + + - - + + + + diff --git a/examples/users.py b/examples/users.py new file mode 100644 index 0000000..7b53902 --- /dev/null +++ b/examples/users.py @@ -0,0 +1,50 @@ +# examples/users.py +"""Example script demonstrating the usage of the add_user method in the Siren SDK.""" + +import os +import sys + +from siren.client import SirenClient + +# It allows the script to be run directly from the examples directory. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +def run_add_user_example(client: SirenClient): + """Demonstrates adding a new user.""" + print("\n--- Running Add User Example ---") + + user_payload_1 = { + "unique_id": "example_user_sdk_001", + "first_name": "Alan", + "last_name": "Watts", + "email": "user.one.sdk@example.com", + "phone": "+9198727", + "whatsapp": "+9198727", + "active_channels": ["EMAIL", "SMS", "WHATSAPP"], + "active": True, + "reference_id": "ext_ref_001_sdk", + "attributes": { + "department": "SDK Examples", + "preferred_language": "en", + "tags": ["sdk_test", "new_user"], + }, + } + try: + print(f"\nAttempting to add/update user: {user_payload_1['unique_id']}") + response = client.add_user(**user_payload_1) + print("API Response for user 1:") + print(response) + except Exception as e: + print(f"An unexpected error occurred for user 1: {e}") + + +if __name__ == "__main__": + api_key = os.environ.get("SIREN_API_KEY") + if not api_key: + print("Error: SIREN_API_KEY environment variable not set.") + sys.exit(1) + + siren_client = SirenClient(api_key=api_key) + + run_add_user_example(siren_client) diff --git a/siren/client.py b/siren/client.py index 84e5a19..c18b342 100644 --- a/siren/client.py +++ b/siren/client.py @@ -4,6 +4,7 @@ from .messaging import MessagingManager from .templates import TemplatesManager +from .users import UsersManager from .webhooks import WebhookManager from .workflows import WorkflowsManager @@ -34,6 +35,7 @@ def __init__(self, api_key: str): self._messaging = MessagingManager( api_key=self.api_key, base_url=self.BASE_API_URL ) + self._users = UsersManager(api_key=self.api_key, base_url=self.BASE_API_URL) def get_templates( self, @@ -226,9 +228,6 @@ def schedule_workflow( workflow_id: ID of the workflow to schedule. input_data: Input data for the workflow. end_date: Optional end date for the schedule in "YYYY-MM-DD" format. - - Returns: - A dictionary containing the API response. """ return self._workflows.schedule_workflow( name=name, @@ -241,6 +240,50 @@ def schedule_workflow( end_date=end_date, ) + def add_user( + self, + unique_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + reference_id: Optional[str] = None, + whatsapp: Optional[str] = None, + active_channels: Optional[List[str]] = None, + active: Optional[bool] = None, + email: Optional[str] = None, + phone: Optional[str] = None, + attributes: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Creates or updates a user. + + Args: + unique_id: The unique identifier for the user. + first_name: The user's first name. + last_name: The user's last name. + reference_id: An external reference ID for the user. + whatsapp: The user's WhatsApp number. + active_channels: A list of channels the user is active on (e.g., ["SLACK", "EMAIL"]). + active: Boolean indicating if the user is active. + email: The user's email address. + phone: The user's phone number. + attributes: A dictionary of additional custom attributes for the user. + + Returns: + A dictionary containing the API response. + """ + return self._users.add_user( + unique_id=unique_id, + first_name=first_name, + last_name=last_name, + reference_id=reference_id, + whatsapp=whatsapp, + active_channels=active_channels, + active=active, + email=email, + phone=phone, + attributes=attributes, + ) + def send_message( self, template_name: str, diff --git a/siren/users.py b/siren/users.py new file mode 100644 index 0000000..fe3f691 --- /dev/null +++ b/siren/users.py @@ -0,0 +1,103 @@ +"""Manages user-related operations for the Siren API client.""" + +from typing import Any, Dict, List, Optional + +import requests + + +class UsersManager: + """Manages user-related operations for the Siren API.""" + + def __init__(self, api_key: str, base_url: str): + """ + Initializes the UsersManager. + + Args: + api_key: The API key for authentication. + base_url: The base URL for the Siren API. + """ + self.api_key = api_key + self.base_url = base_url + + def add_user( # noqa: C901 + self, + unique_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + reference_id: Optional[str] = None, + whatsapp: Optional[str] = None, + active_channels: Optional[List[str]] = None, + active: Optional[bool] = None, + email: Optional[str] = None, + phone: Optional[str] = None, + attributes: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Creates or updates a user. + + Args: + unique_id: The unique identifier for the user. + first_name: The user's first name. + last_name: The user's last name. + reference_id: An external reference ID for the user. + whatsapp: The user's WhatsApp number. + active_channels: A list of channels the user is active on (e.g., ["SLACK", "EMAIL"]). + active: Boolean indicating if the user is active. + email: The user's email address. + phone: The user's phone number. + attributes: A dictionary of additional custom attributes for the user. + + Returns: + A dictionary containing the API response. + + Raises: + requests.exceptions.HTTPError: If the API returns an HTTP error status. + requests.exceptions.RequestException: For other request-related errors. + """ + url = f"{self.base_url}/api/v1/public/users" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + payload: Dict[str, Any] = {"uniqueId": unique_id} + + if first_name is not None: + payload["firstName"] = first_name + if last_name is not None: + payload["lastName"] = last_name + if reference_id is not None: + payload["referenceId"] = reference_id + if whatsapp is not None: + payload["whatsapp"] = whatsapp + if active_channels is not None: + payload["activeChannels"] = active_channels + if active is not None: + payload["active"] = active + if email is not None: + payload["email"] = email + if phone is not None: + payload["phone"] = phone + if attributes is not None: + payload["attributes"] = attributes + + final_payload = { + k: v for k, v in payload.items() if v is not None or k == "uniqueId" + } + + try: + response = requests.post( + url, headers=headers, json=final_payload, timeout=10 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as http_err: + try: + # Attempt to parse JSON error response from API + error_json = http_err.response.json() + return error_json + except requests.exceptions.JSONDecodeError: + # If error response is not JSON, re-raise the original HTTPError + raise http_err + except requests.exceptions.RequestException as req_err: + raise req_err diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..25b7e8f --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,204 @@ +# tests/test_users.py +"""Unit tests for the user management features of the Siren SDK.""" + +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from siren.client import SirenClient +from siren.users import UsersManager + +# Mock API responses +MOCK_API_KEY = "test_api_key" +MOCK_BASE_URL = "https://api.siren.com" +MOCK_USER_ID = "user_123" + + +@pytest.fixture +def users_manager(): + """Fixture to create a UsersManager instance.""" + return UsersManager(api_key=MOCK_API_KEY, base_url=MOCK_BASE_URL) + + +@pytest.fixture +def siren_client(): + """Fixture to create a SirenClient instance.""" + return SirenClient(api_key=MOCK_API_KEY) + + +def mock_response( + status_code: int, + json_data: Optional[dict] = None, + text_data: str = "", + raise_for_status_exception=None, +): + """Helper function to create a mock HTTP response.""" + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_data if json_data is not None else {} + mock_resp.text = text_data + if raise_for_status_exception: + mock_resp.raise_for_status.side_effect = raise_for_status_exception + return mock_resp + + +class TestUsersManager: + """Tests for the UsersManager class.""" + + @patch("siren.users.requests.post") + def test_add_user_success(self, mock_post, users_manager: UsersManager): + """Test successful user creation/update.""" + expected_response = { + "status": "success", + "data": {"id": MOCK_USER_ID, "uniqueId": MOCK_USER_ID}, + } + mock_post.return_value = mock_response(200, expected_response) + + payload = { + "unique_id": MOCK_USER_ID, + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "active_channels": ["EMAIL"], + "attributes": {"custom_field": "value1"}, + } + response = users_manager.add_user(**payload) + + expected_headers = { + "Authorization": f"Bearer {MOCK_API_KEY}", + "Content-Type": "application/json", + } + mock_post.assert_called_once_with( + f"{MOCK_BASE_URL}/api/v1/public/users", + json={ + "uniqueId": MOCK_USER_ID, + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com", + "activeChannels": ["EMAIL"], + "attributes": {"custom_field": "value1"}, + }, + headers=expected_headers, + timeout=10, + ) + assert response == expected_response + + @patch("siren.users.requests.post") + def test_add_user_minimal_payload_success( + self, mock_post, users_manager: UsersManager + ): + """Test successful user creation/update with minimal payload.""" + expected_response = { + "status": "success", + "data": {"id": MOCK_USER_ID, "uniqueId": MOCK_USER_ID}, + } + mock_post.return_value = mock_response(200, expected_response) + + response = users_manager.add_user(unique_id=MOCK_USER_ID) + + expected_headers = { + "Authorization": f"Bearer {MOCK_API_KEY}", + "Content-Type": "application/json", + } + mock_post.assert_called_once_with( + f"{MOCK_BASE_URL}/api/v1/public/users", + json={"uniqueId": MOCK_USER_ID}, + headers=expected_headers, + timeout=10, + ) + assert response == expected_response + + @patch("siren.users.requests.post") + def test_add_user_api_error_returns_json( + self, mock_post, users_manager: UsersManager + ): + """Test API error (e.g., 400, 422) that returns a JSON body.""" + error_response_json = { + "error": "Validation failed", + "details": {"uniqueId": "is required"}, + } + + # Create a mock response object that will be associated with the HTTPError + err_response_obj = mock_response(422, error_response_json) + http_error = requests.exceptions.HTTPError(response=err_response_obj) + err_response_obj.raise_for_status.side_effect = ( + http_error # Configure raise_for_status to raise the error + ) + + mock_post.return_value = ( + err_response_obj # The session.post call returns this response object + ) + + response = users_manager.add_user(unique_id=MOCK_USER_ID) + + assert response == error_response_json + + @patch("siren.users.requests.post") + def test_add_user_http_error_no_json(self, mock_post, users_manager: UsersManager): + """Test API error (e.g., 500) that does not return a JSON body.""" + # Mock response that raises HTTPError but .json() call on response raises JSONDecodeError + err_response_obj = mock_response(500, text_data="Internal Server Error") + http_error = requests.exceptions.HTTPError(response=err_response_obj) + err_response_obj.raise_for_status.side_effect = http_error + err_response_obj.json.side_effect = requests.exceptions.JSONDecodeError( + "Expecting value", "doc", 0 + ) + + mock_post.return_value = err_response_obj + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + users_manager.add_user(unique_id=MOCK_USER_ID) + + assert excinfo.value.response.status_code == 500 + assert excinfo.value.response.text == "Internal Server Error" + + @patch("siren.users.requests.post") + def test_add_user_request_exception(self, mock_post, users_manager: UsersManager): + """Test handling of requests.exceptions.RequestException.""" + mock_post.side_effect = requests.exceptions.ConnectionError("Connection failed") + + with pytest.raises(requests.exceptions.RequestException): + users_manager.add_user(unique_id=MOCK_USER_ID) + + +class TestSirenClientUsers: + """Tests for user management methods exposed on SirenClient.""" + + @patch.object(UsersManager, "add_user") + def test_client_add_user_delegates_to_manager( + self, mock_manager_add_user, siren_client: SirenClient + ): + """Test that SirenClient.add_user correctly delegates to UsersManager.add_user.""" + payload = { + "unique_id": "client_user_001", + "first_name": "Client", + "last_name": "User", + "email": "client.user@example.com", + "attributes": {"source": "client_test"}, + } + expected_return_value = {"id": "client_user_001", "status": "delegated"} + mock_manager_add_user.return_value = expected_return_value + + # This is the payload passed to the client method + payload_to_client = payload.copy() + + # This is the expected payload for the manager method, including defaults + expected_payload_for_manager = { + "unique_id": "client_user_001", + "first_name": "Client", + "last_name": "User", + "reference_id": None, + "whatsapp": None, + "active_channels": None, + "active": None, + "email": "client.user@example.com", + "phone": None, + "attributes": {"source": "client_test"}, + } + + response = siren_client.add_user(**payload_to_client) + + mock_manager_add_user.assert_called_once_with(**expected_payload_for_manager) + assert response == expected_return_value From 35be94a4e222c0b616745bafdc8240e8dcb35b3a Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Tue, 10 Jun 2025 17:18:49 +0530 Subject: [PATCH 17/42] refactor add_user method by introducing models, custom exceptions --- .cursor/rules/sdk_rules.md | 98 +++++++++++ coverage.xml | 241 ++++++++++++++++++-------- examples/users.py | 51 +++--- pyproject.toml | 1 + siren/client.py | 5 +- siren/exceptions.py | 54 ++++++ siren/models/__init__.py | 3 + siren/models/base.py | 47 ++++++ siren/models/user.py | 60 +++++++ siren/users.py | 134 +++++++-------- siren/utils.py | 28 ++++ tests/test_users.py | 222 ++++++++++++++---------- uv.lock | 335 +++++++++++++++++++++++++++++++++++++ 13 files changed, 1026 insertions(+), 253 deletions(-) create mode 100644 .cursor/rules/sdk_rules.md create mode 100644 siren/exceptions.py create mode 100644 siren/models/__init__.py create mode 100644 siren/models/base.py create mode 100644 siren/models/user.py create mode 100644 siren/utils.py diff --git a/.cursor/rules/sdk_rules.md b/.cursor/rules/sdk_rules.md new file mode 100644 index 0000000..a148d6c --- /dev/null +++ b/.cursor/rules/sdk_rules.md @@ -0,0 +1,98 @@ +# SDK Development Guidelines + +## Models +- Create base model for common fields +- Create separate models for request, response, and API wrapper +- Use Pydantic for validation +- Make fields optional where appropriate +- Use field aliases for API compatibility (snake_case → camelCase) +- Add clear field descriptions +- Include proper type hints +- Don't create empty models just to wrap Dict[str, Any] - use the raw type instead +- Use @property methods in models for data transformation (e.g., error_detail property) + +## Manager Class +- Keep focused on one resource type +- Initialize with required config (api_key, base_url) +- Method structure: + - Clear docstring (purpose, params, returns, exceptions) + - Prepare request + - Make API call + - Handle response + - Handle errors +- Use type hints consistently +- Use SirenAPIError for API errors (400, 401, 404) +- Use SirenSDKError for SDK errors (validation, network) +- Keep data conversion logic in models, not managers +- Use model properties for error parsing rather than inline validation + +## Client Class +- Keep thin, delegate to managers +- Method structure: + - Clear docstring matching manager method + - Delegate to manager + - Return manager response +- Use proper type hints +- Keep error handling consistent + +## Status Code Handling +- Use explicit status code checks (if status_code == 200) rather than response.ok + +## Error Handling +- Use SirenAPIError for API errors: + - Include status code + - Include error code + - Include API message + - Include error details +- Use SirenSDKError for SDK errors: + - Include original exception + - Include clear message + - Include status code if available + +## Tests +- Structure: + - Test success cases + - Test error cases + - Test validation + - Test API errors + - Test SDK errors +- Use clear mock data +- Verify request parameters +- Verify response handling +- Keep comments minimal but helpful + +## Example Script +- Keep focused on demonstrating the method +- Structure: + - Setup example data + - Call method + - Show basic response + - Basic error handling +- Use realistic but safe data +- Keep output minimal +- Handle errors gracefully +- Keep comments focused + +## Code Style +- Use type hints consistently +- Keep methods focused and small +- Use clear variable names +- Keep comments minimal but helpful +- Follow PEP 8 +- Use consistent error handling +- Keep code flat where possible +- Use utility functions for common tasks + +## Documentation +- Clear docstrings for all classes and methods +- Include parameter types and descriptions +- Include return types and descriptions +- Include possible exceptions +- Keep comments focused on why, not what + +## Code Organization +- Keep related code together +- Use clear file structure +- Keep files focused +- Use consistent naming +- Keep code modular diff --git a/coverage.xml b/coverage.xml index 584d7a5..59b96a0 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -25,47 +25,76 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -257,52 +286,53 @@ - + - + + - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + - - - - - - + - - + - + + + + + + + + + + + + @@ -406,5 +436,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/users.py b/examples/users.py index 7b53902..79548b4 100644 --- a/examples/users.py +++ b/examples/users.py @@ -5,38 +5,42 @@ import sys from siren.client import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError -# It allows the script to be run directly from the examples directory. +# Allow running from examples directory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -def run_add_user_example(client: SirenClient): - """Demonstrates adding a new user.""" - print("\n--- Running Add User Example ---") - - user_payload_1 = { - "unique_id": "example_user_sdk_001", - "first_name": "Alan", - "last_name": "Watts", - "email": "user.one.sdk@example.com", - "phone": "+9198727", - "whatsapp": "+9198727", +def add_user_example(client: SirenClient) -> None: + """Example of adding a user using the Siren SDK.""" + # Example user payload + user = { + "unique_id": "john_doe_003", + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@company.com", + "phone": "+919876543210", + "whatsapp": "+919876543210", "active_channels": ["EMAIL", "SMS", "WHATSAPP"], "active": True, - "reference_id": "ext_ref_001_sdk", + "reference_id": "EMP_001", "attributes": { - "department": "SDK Examples", + "department": "Engineering", + "role": "Senior Developer", + "location": "Bangalore", "preferred_language": "en", - "tags": ["sdk_test", "new_user"], }, } + try: - print(f"\nAttempting to add/update user: {user_payload_1['unique_id']}") - response = client.add_user(**user_payload_1) - print("API Response for user 1:") - print(response) - except Exception as e: - print(f"An unexpected error occurred for user 1: {e}") + # Call the SDK method + created_user = client.add_user(**user) + print(f"User ID: {created_user.id}") + + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") if __name__ == "__main__": @@ -45,6 +49,5 @@ def run_add_user_example(client: SirenClient): print("Error: SIREN_API_KEY environment variable not set.") sys.exit(1) - siren_client = SirenClient(api_key=api_key) - - run_add_user_example(siren_client) + client = SirenClient(api_key=api_key) + add_user_example(client) diff --git a/pyproject.toml b/pyproject.toml index be4e7ce..73ad629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ keywords = ["siren", "notifications", "api", "sdk", "ai", "messaging", "communic dependencies = [ "requests>=2.25.0", # HTTP client library + "pydantic[email]>=2.0,<3.0", # Data validation and settings management ] [project.urls] diff --git a/siren/client.py b/siren/client.py index c18b342..e110007 100644 --- a/siren/client.py +++ b/siren/client.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional from .messaging import MessagingManager +from .models.user import User from .templates import TemplatesManager from .users import UsersManager from .webhooks import WebhookManager @@ -252,7 +253,7 @@ def add_user( email: Optional[str] = None, phone: Optional[str] = None, attributes: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + ) -> User: """ Creates or updates a user. @@ -269,7 +270,7 @@ def add_user( attributes: A dictionary of additional custom attributes for the user. Returns: - A dictionary containing the API response. + User: A User model representing the created/updated user. """ return self._users.add_user( unique_id=unique_id, diff --git a/siren/exceptions.py b/siren/exceptions.py new file mode 100644 index 0000000..4eb1d17 --- /dev/null +++ b/siren/exceptions.py @@ -0,0 +1,54 @@ +"""Custom exceptions for the Siren SDK.""" + +from typing import Any, Dict, Optional + +from .models.base import APIErrorDetail + + +class SirenSDKError(Exception): + """Base exception for all SDK-level errors.""" + + def __init__( + self, + message: str, + original_exception: Optional[Exception] = None, + status_code: Optional[int] = None, + raw_response: Optional[Dict[str, Any]] = None, + ): + """Initialize the SDK error.""" + super().__init__(message) + self.message = message + self.original_exception = original_exception + self.status_code = status_code + self.raw_response = raw_response + + def __str__(self) -> str: + """Return string representation of the error.""" + return f"{self.__class__.__name__}: {self.message}" + + +class SirenAPIError(SirenSDKError): + """Exception for API-level errors.""" + + def __init__( + self, + error_detail: APIErrorDetail, + status_code: int, + raw_response: Dict[str, Any], + ): + """Initialize the API error.""" + self.error_detail = error_detail + self.error_code = error_detail.error_code + self.api_message = error_detail.message + self.details = error_detail.details + + message = f"{self.error_code} - {self.api_message}" + super().__init__( + message=message, + status_code=status_code, + raw_response=raw_response, + ) + + def __str__(self) -> str: + """Return string representation of the error.""" + return f"{self.__class__.__name__} (Status: {self.status_code}, Code: {self.error_code}): {self.api_message}" diff --git a/siren/models/__init__.py b/siren/models/__init__.py new file mode 100644 index 0000000..fef341e --- /dev/null +++ b/siren/models/__init__.py @@ -0,0 +1,3 @@ +# This file makes 'models' a Python package. + +"""Models package for the Siren SDK.""" diff --git a/siren/models/base.py b/siren/models/base.py new file mode 100644 index 0000000..7e265dd --- /dev/null +++ b/siren/models/base.py @@ -0,0 +1,47 @@ +"""Base models for the Siren SDK. + +This module contains base models that are used across the SDK. +""" + +from typing import Any, Dict, Generic, Optional, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class APIErrorDetail(BaseModel): + """Represents the 'error' object in an API response.""" + + error_code: str = Field(..., alias="errorCode", description="A unique error code.") + message: str = Field(..., description="A human-readable error message.") + details: Optional[Any] = Field( + None, description="Optional additional error details - can be Dict or List." + ) + + +class BaseAPIResponse(BaseModel, Generic[T]): + """Base model for all API responses. + + This model represents the common structure of API responses, + including data, error, and metadata fields. + + Args: + T: The type of data expected in the response. + """ + + data: Optional[T] = Field(None, description="Response data") + error: Optional[APIErrorDetail] = Field(None, description="Error information") + errors: Optional[list[APIErrorDetail]] = Field(None, description="List of errors") + meta: Optional[Dict[str, Any]] = Field( + None, description="Metadata about the response" + ) + + @property + def error_detail(self) -> Optional[APIErrorDetail]: + """Get the first available error detail from either error or errors field.""" + if self.error: + return self.error + if self.errors: + return self.errors[0] + return None diff --git a/siren/models/user.py b/siren/models/user.py new file mode 100644 index 0000000..2d07a4d --- /dev/null +++ b/siren/models/user.py @@ -0,0 +1,60 @@ +"""Pydantic models for Siren User API resources.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, EmailStr, Field, HttpUrl + +from .base import BaseAPIResponse + + +class UserBase(BaseModel): + """Base model with common user fields.""" + + unique_id: Optional[str] = Field(None, alias="uniqueId") + first_name: Optional[str] = Field(None, alias="firstName") + last_name: Optional[str] = Field(None, alias="lastName") + reference_id: Optional[str] = Field(None, alias="referenceId") + whatsapp: Optional[str] = None + active_channels: Optional[List[str]] = Field(None, alias="activeChannels") + active: Optional[bool] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + attributes: Optional[Dict[str, Any]] = None + + class Config: + """Pydantic model configuration.""" + + populate_by_name = True + extra = "ignore" + + +class UserCreateRequest(UserBase): + """Request model for creating or updating a user.""" + + pass + + +class User(UserBase): + """Represents a user object in the Siren system.""" + + id: Optional[str] = None + created_at: Optional[str] = Field(None, alias="createdAt") + updated_at: Optional[str] = Field(None, alias="updatedAt") + avatar_url: Optional[HttpUrl] = Field(None, alias="avatarUrl") + + sms: Optional[str] = None + push_token: Optional[str] = Field(None, alias="pushToken") + in_app: Optional[bool] = Field(None, alias="inApp") + slack: Optional[str] = None + discord: Optional[str] = None + teams: Optional[str] = None + line: Optional[str] = None + + custom_data: Optional[Dict[str, Any]] = Field(None, alias="customData") + segments: Optional[List[str]] = None + + +class UserAPIResponse(BaseAPIResponse[User]): + """Specific API response structure for operations returning a User.""" + + pass diff --git a/siren/users.py b/siren/users.py index fe3f691..aba8814 100644 --- a/siren/users.py +++ b/siren/users.py @@ -1,8 +1,11 @@ """Manages user-related operations for the Siren API client.""" -from typing import Any, Dict, List, Optional - import requests +from pydantic import ValidationError + +from .exceptions import SirenAPIError, SirenSDKError +from .models.user import User, UserAPIResponse, UserCreateRequest +from .utils import parse_json_response class UsersManager: @@ -18,41 +21,23 @@ def __init__(self, api_key: str, base_url: str): """ self.api_key = api_key self.base_url = base_url + # TODO: Make timeout configurable through client initialization + self.timeout = 10 - def add_user( # noqa: C901 - self, - unique_id: str, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - reference_id: Optional[str] = None, - whatsapp: Optional[str] = None, - active_channels: Optional[List[str]] = None, - active: Optional[bool] = None, - email: Optional[str] = None, - phone: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + def add_user(self, **user_data) -> User: """ - Creates or updates a user. + Creates a user. Args: - unique_id: The unique identifier for the user. - first_name: The user's first name. - last_name: The user's last name. - reference_id: An external reference ID for the user. - whatsapp: The user's WhatsApp number. - active_channels: A list of channels the user is active on (e.g., ["SLACK", "EMAIL"]). - active: Boolean indicating if the user is active. - email: The user's email address. - phone: The user's phone number. - attributes: A dictionary of additional custom attributes for the user. + **user_data: User attributes matching the UserCreateRequest model fields. + Use snake_case for field names (e.g., first_name, unique_id). Returns: - A dictionary containing the API response. + User: A User model representing the created/updated user. Raises: - requests.exceptions.HTTPError: If the API returns an HTTP error status. - requests.exceptions.RequestException: For other request-related errors. + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). """ url = f"{self.base_url}/api/v1/public/users" headers = { @@ -60,44 +45,59 @@ def add_user( # noqa: C901 "Content-Type": "application/json", } - payload: Dict[str, Any] = {"uniqueId": unique_id} - - if first_name is not None: - payload["firstName"] = first_name - if last_name is not None: - payload["lastName"] = last_name - if reference_id is not None: - payload["referenceId"] = reference_id - if whatsapp is not None: - payload["whatsapp"] = whatsapp - if active_channels is not None: - payload["activeChannels"] = active_channels - if active is not None: - payload["active"] = active - if email is not None: - payload["email"] = email - if phone is not None: - payload["phone"] = phone - if attributes is not None: - payload["attributes"] = attributes - - final_payload = { - k: v for k, v in payload.items() if v is not None or k == "uniqueId" - } - try: + # Prepare the request with Pydantic validation + user_request = UserCreateRequest.model_validate(user_data) + payload = user_request.model_dump(by_alias=True, exclude_none=True) + + # Make API request response = requests.post( - url, headers=headers, json=final_payload, timeout=10 + url, headers=headers, json=payload, timeout=self.timeout + ) + response_json = parse_json_response(response) + + # Parse the response + parsed_response = UserAPIResponse.model_validate(response_json) + + # Handle success case (200 OK) + if response.status_code == 200 and parsed_response.data: + return parsed_response.data + + # Handle API error + # API error response structure: + # { + # "data": null, + # "error": { "errorCode": "...", "message": "..." }, + # "errors": [{ "errorCode": "...", "message": "..." }], + # "meta": null + # } + # Status codes: + # 200 - OK + # 400 - BAD REQUEST + # 401 - UNAUTHORISED + # 404 - NOT FOUND + if response.status_code in (400, 401, 404): + error_detail = parsed_response.error_detail + if error_detail: + raise SirenAPIError( + error_detail=error_detail, + status_code=response.status_code, + raw_response=response_json, + ) + + # Fallback error for unexpected status codes + raise SirenSDKError( + message=f"Unexpected API response. Status: {response.status_code}", + status_code=response.status_code, + raw_response=response_json, + ) + + except ValidationError as e: + # Input validation error + raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) + + except requests.exceptions.RequestException as e: + # Network or connection error + raise SirenSDKError( + f"Network or connection error: {e}", original_exception=e ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - # Attempt to parse JSON error response from API - error_json = http_err.response.json() - return error_json - except requests.exceptions.JSONDecodeError: - # If error response is not JSON, re-raise the original HTTPError - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err diff --git a/siren/utils.py b/siren/utils.py new file mode 100644 index 0000000..ce9a58d --- /dev/null +++ b/siren/utils.py @@ -0,0 +1,28 @@ +"""Utility functions for the Siren SDK.""" + +import requests + +from .exceptions import SirenSDKError + + +def parse_json_response(response: requests.Response) -> dict: + """ + Parse JSON response and handle parsing errors. + + Args: + response: The HTTP response to parse. + + Returns: + dict: The parsed JSON response. + + Raises: + SirenSDKError: If the response is not valid JSON. + """ + try: + return response.json() + except requests.exceptions.JSONDecodeError as e: + raise SirenSDKError( + f"API response was not valid JSON. Status: {response.status_code}. Content: {response.text}", + original_exception=e, + status_code=response.status_code, + ) diff --git a/tests/test_users.py b/tests/test_users.py index 25b7e8f..ac45ee9 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -8,9 +8,11 @@ import requests from siren.client import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError +from siren.models.user import User from siren.users import UsersManager -# Mock API responses +# Test constants MOCK_API_KEY = "test_api_key" MOCK_BASE_URL = "https://api.siren.com" MOCK_USER_ID = "user_123" @@ -49,13 +51,30 @@ class TestUsersManager: @patch("siren.users.requests.post") def test_add_user_success(self, mock_post, users_manager: UsersManager): - """Test successful user creation/update.""" - expected_response = { - "status": "success", - "data": {"id": MOCK_USER_ID, "uniqueId": MOCK_USER_ID}, + """Test successful user creation/update returns a User model instance.""" + # Mock API response with all possible user fields + mock_api_json_response = { + "data": { + "id": "user_api_generated_id_001", + "uniqueId": MOCK_USER_ID, + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com", + "activeChannels": ["EMAIL"], + "attributes": {"custom_field": "value1"}, + "referenceId": None, + "whatsapp": None, + "active": True, + "phone": None, + "createdAt": "2023-01-01T12:00:00Z", + "updatedAt": "2023-01-01T12:00:00Z", + "avatarUrl": None, + }, + "error": None, } - mock_post.return_value = mock_response(200, expected_response) + mock_post.return_value = mock_response(200, json_data=mock_api_json_response) + # Test payload with snake_case keys (SDK input) payload = { "unique_id": MOCK_USER_ID, "first_name": "John", @@ -66,80 +85,88 @@ def test_add_user_success(self, mock_post, users_manager: UsersManager): } response = users_manager.add_user(**payload) + # Expected API request with camelCase keys expected_headers = { "Authorization": f"Bearer {MOCK_API_KEY}", "Content-Type": "application/json", } - mock_post.assert_called_once_with( - f"{MOCK_BASE_URL}/api/v1/public/users", - json={ - "uniqueId": MOCK_USER_ID, - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@example.com", - "activeChannels": ["EMAIL"], - "attributes": {"custom_field": "value1"}, - }, - headers=expected_headers, - timeout=10, - ) - assert response == expected_response - - @patch("siren.users.requests.post") - def test_add_user_minimal_payload_success( - self, mock_post, users_manager: UsersManager - ): - """Test successful user creation/update with minimal payload.""" - expected_response = { - "status": "success", - "data": {"id": MOCK_USER_ID, "uniqueId": MOCK_USER_ID}, - } - mock_post.return_value = mock_response(200, expected_response) - - response = users_manager.add_user(unique_id=MOCK_USER_ID) - - expected_headers = { - "Authorization": f"Bearer {MOCK_API_KEY}", - "Content-Type": "application/json", + expected_json_payload = { + "uniqueId": MOCK_USER_ID, + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com", + "activeChannels": ["EMAIL"], + "attributes": {"custom_field": "value1"}, } mock_post.assert_called_once_with( f"{MOCK_BASE_URL}/api/v1/public/users", - json={"uniqueId": MOCK_USER_ID}, + json=expected_json_payload, headers=expected_headers, timeout=10, ) - assert response == expected_response + + # Verify all User model fields + assert isinstance(response, User) + assert response.id == "user_api_generated_id_001" + assert response.unique_id == MOCK_USER_ID + assert response.first_name == "John" + assert response.last_name == "Doe" + assert response.email == "john.doe@example.com" + assert response.active_channels == ["EMAIL"] + assert response.attributes == {"custom_field": "value1"} + assert response.created_at == "2023-01-01T12:00:00Z" + assert response.updated_at == "2023-01-01T12:00:00Z" + assert response.active is True + assert response.reference_id is None + assert response.whatsapp is None + assert response.phone is None + assert response.avatar_url is None @patch("siren.users.requests.post") def test_add_user_api_error_returns_json( self, mock_post, users_manager: UsersManager ): - """Test API error (e.g., 400, 422) that returns a JSON body.""" - error_response_json = { - "error": "Validation failed", - "details": {"uniqueId": "is required"}, + """Test API error (400, 401, 404) with JSON body raises SirenAPIError.""" + # Mock API error response with validation details + mock_api_error_payload = { + "error": { + "errorCode": "VALIDATION_ERROR", + "message": "Validation failed on one or more fields.", + "details": [ + { + "field": "uniqueId", + "message": "This field is required and cannot be empty.", + }, + {"field": "email", "message": "Not a valid email address."}, + ], + } } + status_code = 400 - # Create a mock response object that will be associated with the HTTPError - err_response_obj = mock_response(422, error_response_json) + err_response_obj = mock_response(status_code, json_data=mock_api_error_payload) http_error = requests.exceptions.HTTPError(response=err_response_obj) - err_response_obj.raise_for_status.side_effect = ( - http_error # Configure raise_for_status to raise the error - ) - - mock_post.return_value = ( - err_response_obj # The session.post call returns this response object - ) + err_response_obj.raise_for_status.side_effect = http_error + mock_post.return_value = err_response_obj - response = users_manager.add_user(unique_id=MOCK_USER_ID) + with pytest.raises(SirenAPIError) as excinfo: + users_manager.add_user(unique_id=MOCK_USER_ID) - assert response == error_response_json + # Verify error details + assert excinfo.value.status_code == status_code + assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] + assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] + assert ( + excinfo.value.error_detail.details + == mock_api_error_payload["error"]["details"] + ) @patch("siren.users.requests.post") def test_add_user_http_error_no_json(self, mock_post, users_manager: UsersManager): - """Test API error (e.g., 500) that does not return a JSON body.""" - # Mock response that raises HTTPError but .json() call on response raises JSONDecodeError - err_response_obj = mock_response(500, text_data="Internal Server Error") + """Test API error (500) without JSON body raises SirenSDKError.""" + # Mock non-JSON error response + status_code = 500 + error_text = "Internal Server Error - Not JSON" + err_response_obj = mock_response(status_code, text_data=error_text) http_error = requests.exceptions.HTTPError(response=err_response_obj) err_response_obj.raise_for_status.side_effect = http_error err_response_obj.json.side_effect = requests.exceptions.JSONDecodeError( @@ -148,29 +175,43 @@ def test_add_user_http_error_no_json(self, mock_post, users_manager: UsersManage mock_post.return_value = err_response_obj - with pytest.raises(requests.exceptions.HTTPError) as excinfo: + with pytest.raises(SirenSDKError) as excinfo: users_manager.add_user(unique_id=MOCK_USER_ID) - assert excinfo.value.response.status_code == 500 - assert excinfo.value.response.text == "Internal Server Error" + assert isinstance( + excinfo.value.original_exception, requests.exceptions.JSONDecodeError + ) + assert "API response was not valid JSON" in excinfo.value.message + assert error_text in excinfo.value.message @patch("siren.users.requests.post") def test_add_user_request_exception(self, mock_post, users_manager: UsersManager): - """Test handling of requests.exceptions.RequestException.""" - mock_post.side_effect = requests.exceptions.ConnectionError("Connection failed") + """Test handling of requests.exceptions.RequestException (e.g., network error) raises SirenSDKError.""" + # Mock network error + original_exception = requests.exceptions.ConnectionError( + "Simulated connection failed" + ) + mock_post.side_effect = original_exception - with pytest.raises(requests.exceptions.RequestException): + with pytest.raises(SirenSDKError) as excinfo: users_manager.add_user(unique_id=MOCK_USER_ID) + assert excinfo.value.original_exception == original_exception + assert isinstance( + excinfo.value.original_exception, requests.exceptions.ConnectionError + ) + assert "network or connection error" in excinfo.value.message.lower() + class TestSirenClientUsers: """Tests for user management methods exposed on SirenClient.""" - @patch.object(UsersManager, "add_user") + @patch("siren.client.UsersManager.add_user") def test_client_add_user_delegates_to_manager( self, mock_manager_add_user, siren_client: SirenClient ): """Test that SirenClient.add_user correctly delegates to UsersManager.add_user.""" + # Test data payload = { "unique_id": "client_user_001", "first_name": "Client", @@ -178,27 +219,34 @@ def test_client_add_user_delegates_to_manager( "email": "client.user@example.com", "attributes": {"source": "client_test"}, } - expected_return_value = {"id": "client_user_001", "status": "delegated"} - mock_manager_add_user.return_value = expected_return_value - - # This is the payload passed to the client method - payload_to_client = payload.copy() - # This is the expected payload for the manager method, including defaults - expected_payload_for_manager = { - "unique_id": "client_user_001", - "first_name": "Client", - "last_name": "User", - "reference_id": None, - "whatsapp": None, - "active_channels": None, - "active": None, - "email": "client.user@example.com", - "phone": None, - "attributes": {"source": "client_test"}, - } - - response = siren_client.add_user(**payload_to_client) - - mock_manager_add_user.assert_called_once_with(**expected_payload_for_manager) - assert response == expected_return_value + # Mock response + mock_user_instance = User( + id="user_api_id_123", + uniqueId="client_user_001", + createdAt="2023-01-01T10:00:00Z", + updatedAt="2023-01-01T10:00:00Z", + firstName="Client", + lastName="User", + email="client.user@example.com", + attributes={"source": "client_test"}, + referenceId=None, + whatsapp=None, + activeChannels=None, + active=None, + phone=None, + avatarUrl=None, + ) + mock_manager_add_user.return_value = mock_user_instance + + response = siren_client.add_user(**payload) + + # Verify delegation + mock_manager_add_user.assert_called_once() + call_args = mock_manager_add_user.call_args[1] + assert call_args["unique_id"] == payload["unique_id"] + assert call_args["first_name"] == payload["first_name"] + assert call_args["last_name"] == payload["last_name"] + assert call_args["email"] == payload["email"] + assert call_args["attributes"] == payload["attributes"] + assert response == mock_user_instance diff --git a/uv.lock b/uv.lock index 5c58473..0ebb4fb 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,18 @@ resolution-markers = [ "python_full_version < '3.9'", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -415,6 +427,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "dnspython" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/c871f55054e403fdfd6b8f65fd6d1c4e147ed100d3e9f9ba1fe695403939/dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc", size = 332727, upload-time = "2024-02-18T18:48:48.952Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/a1/8c5287991ddb8d3e4662f71356d9656d91ab3a36618c3dd11b280df0d255/dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50", size = 307696, upload-time = "2024-02-18T18:48:46.786Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + [[package]] name = "docutils" version = "0.20.1" @@ -439,6 +475,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython", version = "2.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -851,6 +901,276 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "annotated-types", marker = "python_full_version < '3.9'" }, + { name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator", marker = "python_full_version < '3.9'" }, +] + +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "annotated-types", marker = "python_full_version >= '3.9'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator", marker = "python_full_version >= '3.9'" }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938, upload-time = "2024-12-18T11:27:14.406Z" }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684, upload-time = "2024-12-18T11:27:16.489Z" }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169, upload-time = "2024-12-18T11:27:22.16Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227, upload-time = "2024-12-18T11:27:25.097Z" }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695, upload-time = "2024-12-18T11:27:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662, upload-time = "2024-12-18T11:27:30.798Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370, upload-time = "2024-12-18T11:27:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813, upload-time = "2024-12-18T11:27:37.111Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287, upload-time = "2024-12-18T11:27:40.566Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414, upload-time = "2024-12-18T11:27:43.757Z" }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301, upload-time = "2024-12-18T11:27:47.36Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685, upload-time = "2024-12-18T11:27:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876, upload-time = "2024-12-18T11:27:53.54Z" }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload-time = "2024-12-18T11:27:55.409Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload-time = "2024-12-18T11:27:57.252Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload-time = "2024-12-18T11:27:59.146Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload-time = "2024-12-18T11:28:02.625Z" }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload-time = "2024-12-18T11:28:04.442Z" }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload-time = "2024-12-18T11:28:07.679Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload-time = "2024-12-18T11:28:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload-time = "2024-12-18T11:28:13.362Z" }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload-time = "2024-12-18T11:28:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload-time = "2024-12-18T11:28:18.407Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload-time = "2024-12-18T11:28:21.471Z" }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload-time = "2024-12-18T11:28:23.53Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload-time = "2024-12-18T11:28:25.391Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload-time = "2024-12-18T11:28:28.593Z" }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" }, + { url = "https://files.pythonhosted.org/packages/43/53/13e9917fc69c0a4aea06fd63ed6a8d6cda9cf140ca9584d49c1650b0ef5e/pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506", size = 1899595, upload-time = "2024-12-18T11:29:40.887Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/26c549249769ed84877f862f7bb93f89a6ee08b4bee1ed8781616b7fbb5e/pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320", size = 1775010, upload-time = "2024-12-18T11:29:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/35/eb/8234e05452d92d2b102ffa1b56d801c3567e628fdc63f02080fdfc68fd5e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145", size = 1830727, upload-time = "2024-12-18T11:29:46.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/df/59f915c8b929d5f61e5a46accf748a87110ba145156f9326d1a7d28912b2/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1", size = 1868393, upload-time = "2024-12-18T11:29:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/d5/52/81cf4071dca654d485c277c581db368b0c95b2b883f4d7b736ab54f72ddf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228", size = 2040300, upload-time = "2024-12-18T11:29:51.43Z" }, + { url = "https://files.pythonhosted.org/packages/9c/00/05197ce1614f5c08d7a06e1d39d5d8e704dc81971b2719af134b844e2eaf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046", size = 2738785, upload-time = "2024-12-18T11:29:55.001Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a3/5f19bc495793546825ab160e530330c2afcee2281c02b5ffafd0b32ac05e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5", size = 1996493, upload-time = "2024-12-18T11:29:57.13Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e8/e0102c2ec153dc3eed88aea03990e1b06cfbca532916b8a48173245afe60/pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a", size = 1998544, upload-time = "2024-12-18T11:30:00.681Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a3/4be70845b555bd80aaee9f9812a7cf3df81550bce6dadb3cfee9c5d8421d/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d", size = 2007449, upload-time = "2024-12-18T11:30:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/b779ed2480ba355c054e6d7ea77792467631d674b13d8257085a4bc7dcda/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9", size = 2129460, upload-time = "2024-12-18T11:30:06.55Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f0/a6ab0681f6e95260c7fbf552874af7302f2ea37b459f9b7f00698f875492/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da", size = 2159609, upload-time = "2024-12-18T11:30:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/e1059506795104349712fbca647b18b3f4a7fd541c099e6259717441e1e0/pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b", size = 1819886, upload-time = "2024-12-18T11:30:11.777Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6d/df49c17f024dfc58db0bacc7b03610058018dd2ea2eaf748ccbada4c3d06/pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad", size = 1980773, upload-time = "2024-12-18T11:30:14.828Z" }, + { url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475, upload-time = "2024-12-18T11:30:18.316Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279, upload-time = "2024-12-18T11:30:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112, upload-time = "2024-12-18T11:30:23.255Z" }, + { url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780, upload-time = "2024-12-18T11:30:25.742Z" }, + { url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943, upload-time = "2024-12-18T11:30:28.036Z" }, + { url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492, upload-time = "2024-12-18T11:30:30.412Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714, upload-time = "2024-12-18T11:30:34.358Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163, upload-time = "2024-12-18T11:30:37.979Z" }, + { url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217, upload-time = "2024-12-18T11:30:40.367Z" }, + { url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899, upload-time = "2024-12-18T11:30:42.737Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726, upload-time = "2024-12-18T11:30:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219, upload-time = "2024-12-18T11:30:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382, upload-time = "2024-12-18T11:30:51.871Z" }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159, upload-time = "2024-12-18T11:30:54.382Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331, upload-time = "2024-12-18T11:30:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467, upload-time = "2024-12-18T11:31:00.6Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797, upload-time = "2024-12-18T11:31:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839, upload-time = "2024-12-18T11:31:09.775Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861, upload-time = "2024-12-18T11:31:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582, upload-time = "2024-12-18T11:31:17.423Z" }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985, upload-time = "2024-12-18T11:31:19.901Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715, upload-time = "2024-12-18T11:31:22.821Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733, upload-time = "2024-12-18T11:31:26.876Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375, upload-time = "2024-12-18T11:31:29.276Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307, upload-time = "2024-12-18T11:31:33.123Z" }, + { url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971, upload-time = "2024-12-18T11:31:35.755Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616, upload-time = "2024-12-18T11:31:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943, upload-time = "2024-12-18T11:31:41.853Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654, upload-time = "2024-12-18T11:31:44.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292, upload-time = "2024-12-18T11:31:48.613Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961, upload-time = "2024-12-18T11:31:52.446Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -1203,6 +1523,8 @@ name = "siren-ai" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, extra = ["email"], marker = "python_full_version < '3.9'" }, + { name = "pydantic", version = "2.11.5", source = { registry = "https://pypi.org/simple" }, extra = ["email"], marker = "python_full_version >= '3.9'" }, { name = "requests" }, ] @@ -1229,6 +1551,7 @@ dev = [ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=0.10.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.0,<3.0" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'" }, @@ -1330,6 +1653,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + [[package]] name = "urllib3" version = "2.2.3" From 40eb35b43e3c45172960009c50698bde6c757f79 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Tue, 10 Jun 2025 17:56:22 +0530 Subject: [PATCH 18/42] add update user method --- README.md | 39 +++++++++ coverage.xml | 199 +++++++++++++++++++++++++------------------ examples/users.py | 39 +++++---- siren/client.py | 88 ++++++++++++++++--- siren/models/user.py | 2 +- siren/users.py | 75 +++++++++++++++- tests/test_users.py | 170 +++++++++++++++++++++++++++++++++++- 7 files changed, 492 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index c3bc375..3b51ec9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - [`schedule_workflow()`](#schedule_workflow) - [`add_user()`](#add_user) + - [`update_user()`](#update_user) - [Getting Started for Package Developers](#getting-started-for-package-developers) - [Prerequisites](#prerequisites) - [Setup Steps](#setup-steps) @@ -475,6 +476,44 @@ response = client.add_user(**update_user_payload) print(response) ``` +### `update_user()` + +Updates an existing user's information. + +**Parameters:** +* `unique_id` (str): The unique identifier of the user to update. This is a required field and identifies which user to update. +* `first_name` (Optional[str]): The user's first name. +* `last_name` (Optional[str]): The user's last name. +* `reference_id` (Optional[str]): An external reference ID for the user. +* `whatsapp` (Optional[str]): The user's WhatsApp number (e.g., "+14155552671"). +* `active_channels` (Optional[List[str]]): A list of channels the user is active on (e.g., `["EMAIL", "SMS", "WHATSAPP"]`). +* `active` (Optional[bool]): Boolean indicating if the user is active. +* `email` (Optional[str]): The user's email address. +* `phone` (Optional[str]): The user's phone number (e.g., "+14155552671"). +* `attributes` (Optional[Dict[str, Any]]): A dictionary of additional custom attributes for the user. + +**Example:** +```python +# Update user information +update_response = client.update_user( + unique_id="sdk_user_123", + first_name="Jane", + last_name="Smith", + email="jane.smith@example.com", + active_channels=["EMAIL", "SLACK"], + attributes={"department": "Engineering", "role": "Developer"} +) +print(update_response) + +# Update only specific fields +partial_update_response = client.update_user( + unique_id="sdk_user_123", + phone="+15551234567", + active=True +) +print(partial_update_response) +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 59b96a0..79bb567 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -21,80 +21,81 @@ - - - + + + - - - - - - + + + + + + - - - + + + - - - + + + - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - + + + + + + + + + - - - + - - + @@ -286,7 +287,7 @@ - + @@ -306,19 +307,40 @@ - - - - - + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -436,27 +458,34 @@ - + - + - - - + + - - + - + - - + + + + + + + + + + + @@ -466,7 +495,6 @@ - @@ -476,27 +504,28 @@ - + - + - - - - + + - - + - + + + + + diff --git a/examples/users.py b/examples/users.py index 79548b4..f304569 100644 --- a/examples/users.py +++ b/examples/users.py @@ -1,5 +1,5 @@ # examples/users.py -"""Example script demonstrating the usage of the add_user method in the Siren SDK.""" +"""Example script demonstrating user management methods in the Siren SDK.""" import os import sys @@ -12,31 +12,36 @@ def add_user_example(client: SirenClient) -> None: - """Example of adding a user using the Siren SDK.""" - # Example user payload + """Example of adding a user.""" user = { "unique_id": "john_doe_003", "first_name": "John", "last_name": "Doe", "email": "john.doe@company.com", - "phone": "+919876543210", - "whatsapp": "+919876543210", - "active_channels": ["EMAIL", "SMS", "WHATSAPP"], + "active_channels": ["EMAIL", "SMS"], "active": True, - "reference_id": "EMP_001", - "attributes": { - "department": "Engineering", - "role": "Senior Developer", - "location": "Bangalore", - "preferred_language": "en", - }, } try: - # Call the SDK method created_user = client.add_user(**user) - print(f"User ID: {created_user.id}") + print(f"Created user: {created_user.id}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") + +def update_user_example(client: SirenClient) -> None: + """Example of updating a user.""" + try: + updated_user = client.update_user( + "john_doe_003", + first_name="Jane", + last_name="Smith", + email="jane.smith@company.com", + active_channels=["EMAIL", "SMS", "WHATSAPP"], + ) + print(f"Updated user: {updated_user.id}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: @@ -50,4 +55,6 @@ def add_user_example(client: SirenClient) -> None: sys.exit(1) client = SirenClient(api_key=api_key) - add_user_example(client) + + # add_user_example(client) + update_user_example(client) diff --git a/siren/client.py b/siren/client.py index e110007..99d4cbc 100644 --- a/siren/client.py +++ b/siren/client.py @@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional +from pydantic import EmailStr + from .messaging import MessagingManager from .models.user import User from .templates import TemplatesManager @@ -243,34 +245,40 @@ def schedule_workflow( def add_user( self, - unique_id: str, + unique_id: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, reference_id: Optional[str] = None, whatsapp: Optional[str] = None, active_channels: Optional[List[str]] = None, active: Optional[bool] = None, - email: Optional[str] = None, + email: Optional[EmailStr] = None, phone: Optional[str] = None, attributes: Optional[Dict[str, Any]] = None, + **kwargs, ) -> User: """ - Creates or updates a user. + Creates a user. Args: - unique_id: The unique identifier for the user. - first_name: The user's first name. - last_name: The user's last name. - reference_id: An external reference ID for the user. - whatsapp: The user's WhatsApp number. - active_channels: A list of channels the user is active on (e.g., ["SLACK", "EMAIL"]). - active: Boolean indicating if the user is active. - email: The user's email address. - phone: The user's phone number. - attributes: A dictionary of additional custom attributes for the user. + unique_id: Unique identifier for the user. + first_name: The first name of the user. + last_name: The last name of the user. + reference_id: Reference ID for the user. + whatsapp: WhatsApp number for the user. + active_channels: List of active channels for the user. + active: Whether the user is active. + email: Email address of the user. + phone: Phone number of the user. + attributes: Additional custom attributes for the user. + **kwargs: Additional user data. Returns: - User: A User model representing the created/updated user. + User: A User model representing the created or updated user. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). """ return self._users.add_user( unique_id=unique_id, @@ -283,6 +291,58 @@ def add_user( email=email, phone=phone, attributes=attributes, + **kwargs, + ) + + def update_user( + self, + unique_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + reference_id: Optional[str] = None, + whatsapp: Optional[str] = None, + active_channels: Optional[List[str]] = None, + active: Optional[bool] = None, + email: Optional[EmailStr] = None, + phone: Optional[str] = None, + attributes: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> User: + """ + Updates a user. + + Args: + unique_id: The unique ID of the user to update. + first_name: The first name of the user. + last_name: The last name of the user. + reference_id: Reference ID for the user. + whatsapp: WhatsApp number for the user. + active_channels: List of active channels for the user. + active: Whether the user is active. + email: Email address of the user. + phone: Phone number of the user. + attributes: Additional custom attributes for the user. + **kwargs: Additional user data. + + Returns: + User: A User model representing the updated user. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + return self._users.update_user( + unique_id=unique_id, + first_name=first_name, + last_name=last_name, + reference_id=reference_id, + whatsapp=whatsapp, + active_channels=active_channels, + active=active, + email=email, + phone=phone, + attributes=attributes, + **kwargs, ) def send_message( diff --git a/siren/models/user.py b/siren/models/user.py index 2d07a4d..126a904 100644 --- a/siren/models/user.py +++ b/siren/models/user.py @@ -28,7 +28,7 @@ class Config: extra = "ignore" -class UserCreateRequest(UserBase): +class UserRequest(UserBase): """Request model for creating or updating a user.""" pass diff --git a/siren/users.py b/siren/users.py index aba8814..4fd451e 100644 --- a/siren/users.py +++ b/siren/users.py @@ -4,7 +4,7 @@ from pydantic import ValidationError from .exceptions import SirenAPIError, SirenSDKError -from .models.user import User, UserAPIResponse, UserCreateRequest +from .models.user import User, UserAPIResponse, UserRequest from .utils import parse_json_response @@ -29,7 +29,7 @@ def add_user(self, **user_data) -> User: Creates a user. Args: - **user_data: User attributes matching the UserCreateRequest model fields. + **user_data: User attributes matching the UserRequest model fields. Use snake_case for field names (e.g., first_name, unique_id). Returns: @@ -47,7 +47,7 @@ def add_user(self, **user_data) -> User: try: # Prepare the request with Pydantic validation - user_request = UserCreateRequest.model_validate(user_data) + user_request = UserRequest.model_validate(user_data) payload = user_request.model_dump(by_alias=True, exclude_none=True) # Make API request @@ -101,3 +101,72 @@ def add_user(self, **user_data) -> User: raise SirenSDKError( f"Network or connection error: {e}", original_exception=e ) + + def update_user(self, unique_id: str, **user_data) -> User: + """ + Updates a user. + + Args: + unique_id: The unique ID of the user to update. + **user_data: User attributes matching the UserRequest model fields. + + Returns: + User: A User model representing the updated user. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + url = f"{self.base_url}/api/v1/public/users/{unique_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + try: + # Add unique_id to the payload + user_data["unique_id"] = unique_id + + # Prepare the request with Pydantic validation + user_request = UserRequest.model_validate(user_data) + payload = user_request.model_dump(by_alias=True, exclude_none=True) + + # Make API request + response = requests.put( + url, headers=headers, json=payload, timeout=self.timeout + ) + response_json = parse_json_response(response) + + # Parse the response + parsed_response = UserAPIResponse.model_validate(response_json) + + # Handle success case (200 OK) + if response.status_code == 200 and parsed_response.data: + return parsed_response.data + + # Handle API error + if response.status_code in (400, 401, 404): + error_detail = parsed_response.error_detail + if error_detail: + raise SirenAPIError( + error_detail=error_detail, + status_code=response.status_code, + raw_response=response_json, + ) + + # Fallback error for unexpected status codes + raise SirenSDKError( + message=f"Unexpected API response. Status: {response.status_code}", + status_code=response.status_code, + raw_response=response_json, + ) + + except ValidationError as e: + # Input validation error + raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) + + except requests.exceptions.RequestException as e: + # Network or connection error + raise SirenSDKError( + f"Network or connection error: {e}", original_exception=e + ) diff --git a/tests/test_users.py b/tests/test_users.py index ac45ee9..be4c41e 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -200,7 +200,129 @@ def test_add_user_request_exception(self, mock_post, users_manager: UsersManager assert isinstance( excinfo.value.original_exception, requests.exceptions.ConnectionError ) - assert "network or connection error" in excinfo.value.message.lower() + assert "Network or connection error" in excinfo.value.message + + @patch("siren.users.requests.put") + def test_update_user_success(self, mock_put, users_manager: UsersManager): + """Test successful user update returns a User model instance.""" + # Mock API response + mock_api_json_response = { + "data": { + "id": "user_api_generated_id_001", + "uniqueId": MOCK_USER_ID, + "firstName": "Jane", + "lastName": "Smith", + "email": "jane.smith@example.com", + "activeChannels": ["SLACK"], + "attributes": {"updated_field": "value2"}, + "referenceId": "020", + "whatsapp": "+919632323154", + "active": True, + "phone": None, + "createdAt": "2023-01-01T12:00:00Z", + "updatedAt": "2023-01-02T12:00:00Z", + "avatarUrl": None, + }, + "error": None, + } + mock_put.return_value = mock_response(200, json_data=mock_api_json_response) + + # Test payload with snake_case keys (SDK input) + payload = { + "first_name": "Jane", + "last_name": "Smith", + "email": "jane.smith@example.com", + "active_channels": ["SLACK"], + "attributes": {"updated_field": "value2"}, + "reference_id": "020", + "whatsapp": "+919632323154", + } + response = users_manager.update_user(MOCK_USER_ID, **payload) + + # Expected API request with camelCase keys + expected_headers = { + "Authorization": f"Bearer {MOCK_API_KEY}", + "Content-Type": "application/json", + } + expected_json_payload = { + "uniqueId": MOCK_USER_ID, + "firstName": "Jane", + "lastName": "Smith", + "email": "jane.smith@example.com", + "activeChannels": ["SLACK"], + "attributes": {"updated_field": "value2"}, + "referenceId": "020", + "whatsapp": "+919632323154", + } + mock_put.assert_called_once_with( + f"{MOCK_BASE_URL}/api/v1/public/users/{MOCK_USER_ID}", + json=expected_json_payload, + headers=expected_headers, + timeout=10, + ) + + # Verify User model fields + assert isinstance(response, User) + assert response.id == "user_api_generated_id_001" + assert response.unique_id == MOCK_USER_ID + assert response.first_name == "Jane" + assert response.last_name == "Smith" + assert response.email == "jane.smith@example.com" + assert response.active_channels == ["SLACK"] + assert response.reference_id == "020" + assert response.whatsapp == "+919632323154" + assert response.updated_at == "2023-01-02T12:00:00Z" + + @patch("siren.users.requests.put") + def test_update_user_api_error_returns_json( + self, mock_put, users_manager: UsersManager + ): + """Test API error (400, 401, 404) with JSON body raises SirenAPIError.""" + # Mock API error response + mock_api_error_payload = { + "error": { + "errorCode": "USER_NOT_FOUND", + "message": "User with the specified unique ID does not exist.", + } + } + status_code = 404 + + err_response_obj = mock_response(status_code, json_data=mock_api_error_payload) + http_error = requests.exceptions.HTTPError(response=err_response_obj) + err_response_obj.raise_for_status.side_effect = http_error + mock_put.return_value = err_response_obj + + with pytest.raises(SirenAPIError) as excinfo: + users_manager.update_user(MOCK_USER_ID, first_name="Jane") + + # Verify error details + assert excinfo.value.status_code == status_code + assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] + assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] + + @patch("siren.users.requests.put") + def test_update_user_validation_error(self, mock_put, users_manager: UsersManager): + """Test invalid parameters raise SirenSDKError.""" + with pytest.raises(SirenSDKError) as excinfo: + users_manager.update_user(MOCK_USER_ID, email="invalid-email") + + assert "Invalid parameters" in excinfo.value.message + mock_put.assert_not_called() + + @patch("siren.users.requests.put") + def test_update_user_request_exception(self, mock_put, users_manager: UsersManager): + """Test handling of requests.exceptions.RequestException raises SirenSDKError.""" + # Mock network error + original_exception = requests.exceptions.ConnectionError( + "Simulated connection failed" + ) + mock_put.side_effect = original_exception + + with pytest.raises(SirenSDKError) as excinfo: + users_manager.update_user(MOCK_USER_ID, first_name="Jane") + + assert excinfo.value.original_exception == original_exception + assert "Network or connection error" in excinfo.value.message class TestSirenClientUsers: @@ -250,3 +372,49 @@ def test_client_add_user_delegates_to_manager( assert call_args["email"] == payload["email"] assert call_args["attributes"] == payload["attributes"] assert response == mock_user_instance + + @patch("siren.client.UsersManager.update_user") + def test_client_update_user_delegates_to_manager( + self, mock_manager_update_user, siren_client: SirenClient + ): + """Test that SirenClient.update_user correctly delegates to UsersManager.update_user.""" + # Test data + unique_id = "client_user_001" + payload = { + "first_name": "Updated", + "last_name": "User", + "email": "updated.user@example.com", + "attributes": {"source": "update_test"}, + } + + # Mock response + mock_user_instance = User( + id="user_api_id_123", + uniqueId=unique_id, + createdAt="2023-01-01T10:00:00Z", + updatedAt="2023-01-02T15:00:00Z", + firstName="Updated", + lastName="User", + email="updated.user@example.com", + attributes={"source": "update_test"}, + referenceId=None, + whatsapp=None, + activeChannels=None, + active=None, + phone=None, + avatarUrl=None, + ) + mock_manager_update_user.return_value = mock_user_instance + + response = siren_client.update_user(unique_id, **payload) + + # Verify delegation + mock_manager_update_user.assert_called_once() + call_args = mock_manager_update_user.call_args + call_kwargs = call_args[1] + assert call_kwargs["unique_id"] == unique_id + assert call_kwargs["first_name"] == payload["first_name"] + assert call_kwargs["last_name"] == payload["last_name"] + assert call_kwargs["email"] == payload["email"] + assert call_kwargs["attributes"] == payload["attributes"] + assert response == mock_user_instance From 6ffdec20fc55d62e886af1bab5eb502117358e13 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Tue, 10 Jun 2025 18:15:55 +0530 Subject: [PATCH 19/42] feat: add delete_user() method --- README.md | 18 ++++++++++ coverage.xml | 40 +++++++++++++++++------ examples/users.py | 14 +++++++- siren/client.py | 16 +++++++++ siren/users.py | 58 ++++++++++++++++++++++++++++++++ tests/test_users.py | 80 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3b51ec9..f9042ab 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [`schedule_workflow()`](#schedule_workflow) - [`add_user()`](#add_user) - [`update_user()`](#update_user) + - [`delete_user()`](#delete_user) - [Getting Started for Package Developers](#getting-started-for-package-developers) - [Prerequisites](#prerequisites) - [Setup Steps](#setup-steps) @@ -514,6 +515,23 @@ partial_update_response = client.update_user( print(partial_update_response) ``` +### `delete_user()` + +Deletes an existing user. + +**Parameters:** +* `unique_id` (str): The unique identifier of the user to delete. This is a required field. + +**Returns:** +* `bool`: Returns `True` if the user was successfully deleted. + +**Example:** +```python +# Delete a user +deleted = client.delete_user("sdk_user_123") +print(f"User deleted: {deleted}") # True +``` + ## Getting Started for Package Developers This guide will help you set up your environment to contribute to the `siren-ai` SDK. diff --git a/coverage.xml b/coverage.xml index 79bb567..e654cc5 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -61,15 +61,17 @@ - - - - - + + + + - + - + + + + @@ -287,7 +289,7 @@ - + @@ -341,6 +343,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/examples/users.py b/examples/users.py index f304569..c633378 100644 --- a/examples/users.py +++ b/examples/users.py @@ -48,6 +48,17 @@ def update_user_example(client: SirenClient) -> None: print(f"SDK Error: {e.message}") +def delete_user_example(client: SirenClient) -> None: + """Example of deleting a user.""" + try: + deleted = client.delete_user("john_doe_002") + print(f"Deleted user: {deleted}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") + + if __name__ == "__main__": api_key = os.environ.get("SIREN_API_KEY") if not api_key: @@ -57,4 +68,5 @@ def update_user_example(client: SirenClient) -> None: client = SirenClient(api_key=api_key) # add_user_example(client) - update_user_example(client) + # update_user_example(client) + delete_user_example(client) diff --git a/siren/client.py b/siren/client.py index 99d4cbc..8e3dbbb 100644 --- a/siren/client.py +++ b/siren/client.py @@ -345,6 +345,22 @@ def update_user( **kwargs, ) + def delete_user(self, unique_id: str) -> bool: + """ + Deletes a user. + + Args: + unique_id: The unique ID of the user to delete. + + Returns: + bool: True if the user was successfully deleted. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + return self._users.delete_user(unique_id) + def send_message( self, template_name: str, diff --git a/siren/users.py b/siren/users.py index 4fd451e..5f71de8 100644 --- a/siren/users.py +++ b/siren/users.py @@ -170,3 +170,61 @@ def update_user(self, unique_id: str, **user_data) -> User: raise SirenSDKError( f"Network or connection error: {e}", original_exception=e ) + + def delete_user(self, unique_id: str) -> bool: + """ + Deletes a user. + + Args: + unique_id: The unique ID of the user to delete. + + Returns: + bool: True if the user was successfully deleted. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + url = f"{self.base_url}/api/v1/public/users/{unique_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + } + + try: + # Make API request + response = requests.delete(url, headers=headers, timeout=self.timeout) + + # Handle success case (204 No Content) + if response.status_code == 204: + return True + + # Parse response for error cases + response_json = parse_json_response(response) + parsed_response = UserAPIResponse.model_validate(response_json) + + # Handle API error + if response.status_code in (400, 401, 404): + error_detail = parsed_response.error_detail + if error_detail: + raise SirenAPIError( + error_detail=error_detail, + status_code=response.status_code, + raw_response=response_json, + ) + + # Fallback error for unexpected status codes + raise SirenSDKError( + message=f"Unexpected API response. Status: {response.status_code}", + status_code=response.status_code, + raw_response=response_json, + ) + + except ValidationError as e: + # Input validation error + raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) + + except requests.exceptions.RequestException as e: + # Network or connection error + raise SirenSDKError( + f"Network or connection error: {e}", original_exception=e + ) diff --git a/tests/test_users.py b/tests/test_users.py index be4c41e..27f4dfd 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -324,6 +324,69 @@ def test_update_user_request_exception(self, mock_put, users_manager: UsersManag assert excinfo.value.original_exception == original_exception assert "Network or connection error" in excinfo.value.message + @patch("siren.users.requests.delete") + def test_delete_user_success(self, mock_delete, users_manager: UsersManager): + """Test successful user deletion returns True.""" + # Mock API response for 204 No Content + mock_delete.return_value = mock_response(204) + + response = users_manager.delete_user(MOCK_USER_ID) + + # Expected API request + expected_headers = { + "Authorization": f"Bearer {MOCK_API_KEY}", + } + mock_delete.assert_called_once_with( + f"{MOCK_BASE_URL}/api/v1/public/users/{MOCK_USER_ID}", + headers=expected_headers, + timeout=10, + ) + + # Verify response + assert response is True + + @patch("siren.users.requests.delete") + def test_delete_user_not_found(self, mock_delete, users_manager: UsersManager): + """Test API error (404) raises SirenAPIError.""" + # Mock API error response + mock_api_error_payload = { + "error": { + "errorCode": "USER_NOT_FOUND", + "message": "User with the specified unique ID does not exist.", + } + } + status_code = 404 + + err_response_obj = mock_response(status_code, json_data=mock_api_error_payload) + http_error = requests.exceptions.HTTPError(response=err_response_obj) + err_response_obj.raise_for_status.side_effect = http_error + mock_delete.return_value = err_response_obj + + with pytest.raises(SirenAPIError) as excinfo: + users_manager.delete_user(MOCK_USER_ID) + + # Verify error details + assert excinfo.value.status_code == status_code + assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] + assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] + + @patch("siren.users.requests.delete") + def test_delete_user_request_exception( + self, mock_delete, users_manager: UsersManager + ): + """Test handling of requests.exceptions.RequestException raises SirenSDKError.""" + # Mock network error + original_exception = requests.exceptions.ConnectionError( + "Simulated connection failed" + ) + mock_delete.side_effect = original_exception + + with pytest.raises(SirenSDKError) as excinfo: + users_manager.delete_user(MOCK_USER_ID) + + assert excinfo.value.original_exception == original_exception + assert "Network or connection error" in excinfo.value.message + class TestSirenClientUsers: """Tests for user management methods exposed on SirenClient.""" @@ -418,3 +481,20 @@ def test_client_update_user_delegates_to_manager( assert call_kwargs["email"] == payload["email"] assert call_kwargs["attributes"] == payload["attributes"] assert response == mock_user_instance + + @patch("siren.client.UsersManager.delete_user") + def test_client_delete_user_delegates_to_manager( + self, mock_manager_delete_user, siren_client: SirenClient + ): + """Test that SirenClient.delete_user correctly delegates to UsersManager.delete_user.""" + # Test data + unique_id = "client_user_001" + + # Mock response + mock_manager_delete_user.return_value = True + + response = siren_client.delete_user(unique_id) + + # Verify delegation + mock_manager_delete_user.assert_called_once_with(unique_id) + assert response is True From dae4ceec03dd67a61c219e1672cf882d4a84644a Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 10:56:21 +0530 Subject: [PATCH 20/42] refactor --- .cursor/rules/general.mdc | 69 +++++++++++++++++ .cursor/rules/project_context.mdc | 119 ++++++++++++++++++++++++++++++ .cursor/rules/sdk_rules.md | 98 ------------------------ coverage.xml | 22 +++--- examples/messages.py | 2 +- tests/test_users.py | 21 ++++++ 6 files changed, 221 insertions(+), 110 deletions(-) create mode 100644 .cursor/rules/general.mdc create mode 100644 .cursor/rules/project_context.mdc delete mode 100644 .cursor/rules/sdk_rules.md diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc new file mode 100644 index 0000000..5985ed0 --- /dev/null +++ b/.cursor/rules/general.mdc @@ -0,0 +1,69 @@ +--- +description: +globs: +alwaysApply: true +--- +Development Approach: +- Make small, focused changes incrementally +- Break complex problems into manageable pieces +- Give concise responses, avoid verbosity +- Don't change code unless explicitly asked +- Follow existing patterns and conventions + +Code Quality: +- Write clean, self-documenting code +- Single responsibility per function/class +- Make code easily unit testable +- Consider future developers +- Use consistent type hints + +Organization: +- Group by functionality, not by type +- Use logical file structure matching domain +- Keep files focused on single concerns +- Follow consistent naming conventions +- Prefer composition over inheritance + +Method Structure: +- Each method should do one thing well +- Include proper docstrings with purpose, parameters, returns, exceptions +- Use type hints consistently +- Handle errors explicitly and predictably +- Keep methods small and focused + +Error Handling: +- Be explicit, avoid implicit behavior +- Use custom exception classes with clear hierarchy +- Include enough context for debugging +- Fail fast, detect and report errors early +- Handle errors appropriately for the use case + +Testing: +- Test success cases to verify happy path +- Test error cases for proper error handling +- Test edge cases and boundary conditions +- Use realistic data resembling production scenarios +- Keep tests focused on one concept per method + +Documentation: +- Write concise, clear docstrings for public APIs +- Comment complex logic to explain why, not what +- Keep comments current, remove outdated ones +- Focus on intent and reasoning behind decisions +- Be minimal but helpful, avoid noise + +Code Style: +- Follow language conventions like PEP 8 for Python +- Use automated formatters for consistency +- Prefer descriptive names over comments +- Use named constants, avoid magic numbers +- Keep code flat, avoid deep nesting + +Response Guidelines: +- Start response with '' +- Answer what's asked, don't over-engineer +- Provide immediately runnable solutions +- Explain trade-offs when multiple approaches exist +- Be helpful but concise +- Work within established architecture patterns +- End response with '' diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc new file mode 100644 index 0000000..dcaa543 --- /dev/null +++ b/.cursor/rules/project_context.mdc @@ -0,0 +1,119 @@ +--- +description: +globs: +alwaysApply: false +--- +# Project Context: Siren AI Python SDK + +## Project Summary + +Official Python SDK for the [Siren notification platform](mdc:https:/docs.trysiren.io). Provides type-safe interface for managing templates, workflows, users, messaging, and webhooks. Built with Pydantic validation, structured error handling, and modular manager-based architecture. + +## Key Features / Functional Modules + +- **Templates** - Create, update, delete, publish templates and channel configurations → `siren/templates.py` +- **Workflows** - Trigger single/bulk workflows and scheduling → `siren/workflows.py` +- **Users** - Add, update, delete users → `siren/users.py` +- **Messaging** - Send messages, get replies, track status → `siren/messaging.py` +- **Webhooks** - Configure notification and inbound webhooks → `siren/webhooks.py` +- **Client** - Unified API entry point → `siren/client.py` + +## Codebase Structure Overview + +``` +siren-ai/ +├── siren/ # Main SDK package +│ ├── client.py # Main SirenClient - unified API entry point +│ ├── templates.py # TemplatesManager - template operations +│ ├── workflows.py # WorkflowsManager - workflow operations +│ ├── users.py # UsersManager - user management +│ ├── messaging.py # MessagingManager - message operations +│ ├── webhooks.py # WebhookManager - webhook configuration +│ ├── exceptions.py # Custom exception classes (SirenAPIError, SirenSDKError) +│ ├── utils.py # Shared utility functions +│ └── models/ # Pydantic data models +│ ├── base.py # Base response models and error handling +│ └── user.py # User-specific models +├── tests/ # Comprehensive test suite with ~95% coverage +├── examples/ # Usage examples for each module +├── pyproject.toml # Project configuration, dependencies, tools +└── README.md # Installation, usage, and API documentation +``` + +## Architecture & Data Flow + +**Layered Architecture**: +- **Client** (`SirenClient`) - Thin facade delegating to managers +- **Managers** (`TemplatesManager`, `UsersManager`, etc.) - Domain-specific API handlers, one per resource type +- **Models** (Pydantic) - Request/response validation, field aliasing (snake_case ↔ camelCase) +- **Exceptions** - `SirenAPIError` (API errors: 400/401/404) vs `SirenSDKError` (SDK issues: network/validation) + +**Request Flow**: Client → Manager → HTTP Request → API → Response → Model → Client +- Managers prepare requests with Pydantic validation → HTTP to Siren API → Responses parsed through models → Errors become structured exceptions + +**Implementation Details**: +- **HTTP Client**: `requests` library with 10s timeout (hardcoded, TODO: make configurable) +- **Authentication**: Bearer token in `Authorization` header +- **Status Handling**: Explicit `if status_code == 200` checks instead of `response.ok` +- **API Versioning**: Templates/Users/Messaging/Webhooks use `/api/v1/public/`, Workflows use `/api/v2/` +**Base URL**: `https://api.dev.trysiren.io` (TODO: auto-select based on API key type) + +## Tech Stack + +**Core**: Python 3.8+, `requests`, `pydantic[email]` +**Dev Tools**: `pytest` + mocking, `ruff`, `pyright`, `pre-commit`, `uv` + +## Dev & Build Commands + +```bash +# Setup development environment +uv venv && source .venv/bin/activate +uv pip install -e ".[dev]" + +# Code quality +uv run ruff check . # Lint code +uv run ruff format . # Format code +uv run pyright # Type checking +uv run pre-commit run --all-files # Run all pre-commit hooks + +# Testing +uv run pytest # Run all tests +uv run pytest --cov=siren # Run tests with coverage +uv run pytest tests/test_templates.py -v # Run specific test module + +# Build and publish +python -m build # Build wheel and sdist +twine upload dist/* # Upload to PyPI +``` + + + +## Testing + +**Strategy**: `requests-mock` with realistic API data +**Organization**: One test file per manager, shared `client` fixture + +## Key Files + +- **`siren/client.py`** - Main client interface +- **`siren/templates.py`** - Most complex manager, full patterns +- **`siren/models/base.py`** - Core models and error handling +- **`siren/exceptions.py`** - Exception patterns + +## Gotchas + +**Mixed Return Types**: Templates/Workflows/Messaging return `Dict[str, Any]`, Users return `User` models +**Field Serialization**: Always use `by_alias=True` when calling `model_dump()` + +## TODO / Future Areas + +**Configuration Improvements**: +- Auto-select API URL based on API key type (dev/prod) or environment variable + +**Architecture Enhancements**: +- Consider standardizing return types across all managers (currently mixed Dict vs Models) +- Add retry logic for transient network failures +- Add request/response logging capabilities + +**Testing Gaps**: +- Integration tests against live API (currently only unit tests with mocks) diff --git a/.cursor/rules/sdk_rules.md b/.cursor/rules/sdk_rules.md deleted file mode 100644 index a148d6c..0000000 --- a/.cursor/rules/sdk_rules.md +++ /dev/null @@ -1,98 +0,0 @@ -# SDK Development Guidelines - -## Models -- Create base model for common fields -- Create separate models for request, response, and API wrapper -- Use Pydantic for validation -- Make fields optional where appropriate -- Use field aliases for API compatibility (snake_case → camelCase) -- Add clear field descriptions -- Include proper type hints -- Don't create empty models just to wrap Dict[str, Any] - use the raw type instead -- Use @property methods in models for data transformation (e.g., error_detail property) - -## Manager Class -- Keep focused on one resource type -- Initialize with required config (api_key, base_url) -- Method structure: - - Clear docstring (purpose, params, returns, exceptions) - - Prepare request - - Make API call - - Handle response - - Handle errors -- Use type hints consistently -- Use SirenAPIError for API errors (400, 401, 404) -- Use SirenSDKError for SDK errors (validation, network) -- Keep data conversion logic in models, not managers -- Use model properties for error parsing rather than inline validation - -## Client Class -- Keep thin, delegate to managers -- Method structure: - - Clear docstring matching manager method - - Delegate to manager - - Return manager response -- Use proper type hints -- Keep error handling consistent - -## Status Code Handling -- Use explicit status code checks (if status_code == 200) rather than response.ok - -## Error Handling -- Use SirenAPIError for API errors: - - Include status code - - Include error code - - Include API message - - Include error details -- Use SirenSDKError for SDK errors: - - Include original exception - - Include clear message - - Include status code if available - -## Tests -- Structure: - - Test success cases - - Test error cases - - Test validation - - Test API errors - - Test SDK errors -- Use clear mock data -- Verify request parameters -- Verify response handling -- Keep comments minimal but helpful - -## Example Script -- Keep focused on demonstrating the method -- Structure: - - Setup example data - - Call method - - Show basic response - - Basic error handling -- Use realistic but safe data -- Keep output minimal -- Handle errors gracefully -- Keep comments focused - -## Code Style -- Use type hints consistently -- Keep methods focused and small -- Use clear variable names -- Keep comments minimal but helpful -- Follow PEP 8 -- Use consistent error handling -- Keep code flat where possible -- Use utility functions for common tasks - -## Documentation -- Clear docstrings for all classes and methods -- Include parameter types and descriptions -- Include return types and descriptions -- Include possible exceptions -- Keep comments focused on why, not what - -## Code Organization -- Keep related code together -- Use clear file structure -- Keep files focused -- Use consistent naming -- Keep code modular diff --git a/coverage.xml b/coverage.xml index e654cc5..b979971 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -348,19 +348,19 @@ - - - - + + + + + + - - - - - + + + + - diff --git a/examples/messages.py b/examples/messages.py index 5f4db84..2760a4b 100644 --- a/examples/messages.py +++ b/examples/messages.py @@ -72,7 +72,7 @@ def get_replies_example(client: SirenClient, message_id: str): print(f"Found {len(replies)} replies:") for i, reply in enumerate(replies): print( - f" Reply {i+1}: {reply.get('text', 'N/A')} (User: {reply.get('user', 'N/A')}, Timestamp: {reply.get('ts', 'N/A')})" + f" Reply {i + 1}: {reply.get('text', 'N/A')} (User: {reply.get('user', 'N/A')}, Timestamp: {reply.get('ts', 'N/A')})" ) elif isinstance(replies, list) and not replies: print("No replies found for this message.") diff --git a/tests/test_users.py b/tests/test_users.py index 27f4dfd..5230d12 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -421,6 +421,13 @@ def test_client_add_user_delegates_to_manager( active=None, phone=None, avatarUrl=None, + pushToken=None, + inApp=None, + slack=None, + discord=None, + teams=None, + line=None, + customData=None, ) mock_manager_add_user.return_value = mock_user_instance @@ -448,6 +455,13 @@ def test_client_update_user_delegates_to_manager( "last_name": "User", "email": "updated.user@example.com", "attributes": {"source": "update_test"}, + "push_token": "push_token_123", + "in_app": True, + "slack": "slack_user_id_123", + "discord": "discord_user_id_123", + "teams": "teams_user_id_123", + "line": "line_user_id_123", + "custom_data": {"custom_field": "custom_value"}, } # Mock response @@ -466,6 +480,13 @@ def test_client_update_user_delegates_to_manager( active=None, phone=None, avatarUrl=None, + pushToken="push_token_123", + inApp=True, + slack="slack_user_id_123", + discord="discord_user_id_123", + teams="teams_user_id_123", + line="line_user_id_123", + customData={"custom_field": "custom_value"}, ) mock_manager_update_user.return_value = mock_user_instance From 7e3f08a07ad7e1a062eb3ca350d00800c6792ba5 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 13:06:24 +0530 Subject: [PATCH 21/42] refactor: introduce base manager class --- .cursor/rules/general.mdc | 1 + .cursor/rules/project_context.mdc | 10 +- coverage.xml | 173 +++++++++++----------- examples/users.py | 7 +- siren/client.py | 2 +- siren/managers/__init__.py | 6 + siren/managers/base.py | 149 +++++++++++++++++++ siren/managers/users.py | 72 ++++++++++ siren/users.py | 230 ------------------------------ siren/utils.py | 28 ---- tests/test_users.py | 107 +++++++------- 11 files changed, 380 insertions(+), 405 deletions(-) create mode 100644 siren/managers/__init__.py create mode 100644 siren/managers/base.py create mode 100644 siren/managers/users.py delete mode 100644 siren/users.py delete mode 100644 siren/utils.py diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 5985ed0..81225e9 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -66,4 +66,5 @@ Response Guidelines: - Explain trade-offs when multiple approaches exist - Be helpful but concise - Work within established architecture patterns +- When you make structural, architectural, or functional changes, (or model changes, new decisions/gotchas, etc.) ask whether to update @project_context.mdc - End response with '' diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index dcaa543..a5267f3 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -13,7 +13,7 @@ Official Python SDK for the [Siren notification platform](mdc:https:/docs.trysir - **Templates** - Create, update, delete, publish templates and channel configurations → `siren/templates.py` - **Workflows** - Trigger single/bulk workflows and scheduling → `siren/workflows.py` -- **Users** - Add, update, delete users → `siren/users.py` +- **Users** - Add, update, delete users → `siren/managers/users.py` - **Messaging** - Send messages, get replies, track status → `siren/messaging.py` - **Webhooks** - Configure notification and inbound webhooks → `siren/webhooks.py` - **Client** - Unified API entry point → `siren/client.py` @@ -26,11 +26,12 @@ siren-ai/ │ ├── client.py # Main SirenClient - unified API entry point │ ├── templates.py # TemplatesManager - template operations │ ├── workflows.py # WorkflowsManager - workflow operations -│ ├── users.py # UsersManager - user management │ ├── messaging.py # MessagingManager - message operations │ ├── webhooks.py # WebhookManager - webhook configuration │ ├── exceptions.py # Custom exception classes (SirenAPIError, SirenSDKError) -│ ├── utils.py # Shared utility functions +│ ├── managers/ # Manager base classes and implementations +│ │ ├── base.py # BaseManager - shared HTTP/error handling +│ │ └── users.py # UsersManager - user management │ └── models/ # Pydantic data models │ ├── base.py # Base response models and error handling │ └── user.py # User-specific models @@ -44,7 +45,7 @@ siren-ai/ **Layered Architecture**: - **Client** (`SirenClient`) - Thin facade delegating to managers -- **Managers** (`TemplatesManager`, `UsersManager`, etc.) - Domain-specific API handlers, one per resource type +- **Managers** (`TemplatesManager`, `UsersManager`, etc.) - Domain-specific API handlers, inherit from `BaseManager` for unified HTTP/error handling - **Models** (Pydantic) - Request/response validation, field aliasing (snake_case ↔ camelCase) - **Exceptions** - `SirenAPIError` (API errors: 400/401/404) vs `SirenSDKError` (SDK issues: network/validation) @@ -96,6 +97,7 @@ twine upload dist/* # Upload to PyPI ## Key Files - **`siren/client.py`** - Main client interface +- **`siren/managers/base.py`** - BaseManager with unified HTTP/error handling - **`siren/templates.py`** - Most complex manager, full patterns - **`siren/models/base.py`** - Core models and error handling - **`siren/exceptions.py`** - Exception patterns diff --git a/coverage.xml b/coverage.xml index b979971..202aa1e 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -289,92 +289,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -478,6 +392,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/users.py b/examples/users.py index c633378..918588f 100644 --- a/examples/users.py +++ b/examples/users.py @@ -14,8 +14,7 @@ def add_user_example(client: SirenClient) -> None: """Example of adding a user.""" user = { - "unique_id": "john_doe_003", - "first_name": "John", + "unique_id": "john_doe_008", "last_name": "Doe", "email": "john.doe@company.com", "active_channels": ["EMAIL", "SMS"], @@ -35,7 +34,7 @@ def update_user_example(client: SirenClient) -> None: """Example of updating a user.""" try: updated_user = client.update_user( - "john_doe_003", + "john_doe_008", first_name="Jane", last_name="Smith", email="jane.smith@company.com", @@ -51,7 +50,7 @@ def update_user_example(client: SirenClient) -> None: def delete_user_example(client: SirenClient) -> None: """Example of deleting a user.""" try: - deleted = client.delete_user("john_doe_002") + deleted = client.delete_user("123") print(f"Deleted user: {deleted}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") diff --git a/siren/client.py b/siren/client.py index 8e3dbbb..a9aa0a5 100644 --- a/siren/client.py +++ b/siren/client.py @@ -4,10 +4,10 @@ from pydantic import EmailStr +from .managers.users import UsersManager from .messaging import MessagingManager from .models.user import User from .templates import TemplatesManager -from .users import UsersManager from .webhooks import WebhookManager from .workflows import WorkflowsManager diff --git a/siren/managers/__init__.py b/siren/managers/__init__.py new file mode 100644 index 0000000..3cef71d --- /dev/null +++ b/siren/managers/__init__.py @@ -0,0 +1,6 @@ +"""Manager classes for the Siren SDK.""" + +from .base import BaseManager +from .users import UsersManager + +__all__ = ["BaseManager", "UsersManager"] diff --git a/siren/managers/base.py b/siren/managers/base.py new file mode 100644 index 0000000..b5b8da3 --- /dev/null +++ b/siren/managers/base.py @@ -0,0 +1,149 @@ +"""Base manager class for all Siren API managers.""" + +from typing import Any, Dict, Optional, Type, Union + +import requests +from pydantic import BaseModel, ValidationError + +from ..exceptions import SirenAPIError, SirenSDKError + + +class BaseManager: + """Base class for all API managers with common HTTP handling.""" + + def __init__(self, api_key: str, base_url: str, timeout: int = 10): + """Initialize the BaseManager. + + Args: + api_key: The API key for authentication. + base_url: The base URL for the Siren API. + timeout: Request timeout in seconds. + """ + self.api_key = api_key + self.base_url = base_url + self.timeout = timeout + + def _parse_json_response(self, response: requests.Response) -> dict: + """Parse JSON response and handle parsing errors. + + Args: + response: The HTTP response to parse. + + Returns: + dict: The parsed JSON response. + + Raises: + SirenSDKError: If the response is not valid JSON. + """ + try: + return response.json() + except requests.exceptions.JSONDecodeError as e: + raise SirenSDKError( + f"API response was not valid JSON. Status: {response.status_code}. Content: {response.text}", + original_exception=e, + status_code=response.status_code, + ) + + def _make_request( # noqa: C901 + self, + method: str, + endpoint: str, + request_model: Optional[Type[BaseModel]] = None, + response_model: Optional[Type[BaseModel]] = None, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + expected_status: int = 200, + ) -> Union[BaseModel, bool]: + """Make HTTP request with complete error handling. + + Args: + method: HTTP method (GET, POST, PUT, DELETE). + endpoint: API endpoint (e.g., "/api/v1/public/users"). + request_model: Pydantic model for request validation. + response_model: Pydantic model for response parsing. + data: Raw data to validate and send. + params: Query parameters for GET requests. + expected_status: Expected HTTP status code. + + Returns: + Parsed response data or True for successful operations. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue. + """ + url = f"{self.base_url}{endpoint}" + headers = {"Authorization": f"Bearer {self.api_key}"} + + # Validate request data first (outside main try block) + json_data = None + if data and request_model: + try: + validated_request = request_model.model_validate(data) + json_data = validated_request.model_dump( + by_alias=True, exclude_none=True + ) + except ValidationError as e: + raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) + + try: + # Prepare headers + if json_data: + headers["Content-Type"] = "application/json" + + # Make HTTP request + response = requests.request( + method=method, + url=url, + headers=headers, + json=json_data, + params=params, + timeout=self.timeout, + ) + + # Handle success cases + if response.status_code == expected_status: + if expected_status == 204: # No Content + return True + + if response_model: + response_json = self._parse_json_response(response) + parsed_response = response_model.model_validate(response_json) + if hasattr(parsed_response, "data") and parsed_response.data: + return parsed_response.data + + # Handle error cases + response_json = self._parse_json_response(response) + # Try to parse as structured error response + if response_model: + try: + parsed_response = response_model.model_validate(response_json) + if ( + hasattr(parsed_response, "error_detail") + and parsed_response.error_detail + ): + raise SirenAPIError( + error_detail=parsed_response.error_detail, + status_code=response.status_code, + raw_response=response_json, + ) + except ValidationError: + pass # Fall through to generic error + + # Generic error for unexpected responses + raise SirenSDKError( + message=f"Unexpected API response. Status: {response.status_code}", + status_code=response.status_code, + raw_response=response_json, + ) + + except requests.exceptions.RequestException as e: + raise SirenSDKError( + f"Network or connection error: {e}", original_exception=e + ) + except (SirenAPIError, SirenSDKError): + # Let our custom exceptions bubble up unchanged + raise + except Exception as e: + # Catch any other exceptions (e.g., JSON parsing errors) + raise SirenSDKError(f"Unexpected error: {e}", original_exception=e) diff --git a/siren/managers/users.py b/siren/managers/users.py new file mode 100644 index 0000000..fe3fcba --- /dev/null +++ b/siren/managers/users.py @@ -0,0 +1,72 @@ +"""Manages user-related operations for the Siren API client.""" + +from ..models.user import User, UserAPIResponse, UserRequest +from .base import BaseManager + + +class UsersManager(BaseManager): + """Manages user-related operations for the Siren API.""" + + def add_user(self, **user_data) -> User: + """Create a user. + + Args: + **user_data: User attributes matching the UserRequest model fields. + + Returns: + User: A User model representing the created user. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + return self._make_request( + method="POST", + endpoint="/api/v1/public/users", + request_model=UserRequest, + response_model=UserAPIResponse, + data=user_data, + ) + + def update_user(self, unique_id: str, **user_data) -> User: + """Update a user. + + Args: + unique_id: The unique ID of the user to update. + **user_data: User attributes matching the UserRequest model fields. + + Returns: + User: A User model representing the updated user. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + user_data["unique_id"] = unique_id + return self._make_request( + method="PUT", + endpoint=f"/api/v1/public/users/{unique_id}", + request_model=UserRequest, + response_model=UserAPIResponse, + data=user_data, + ) + + def delete_user(self, unique_id: str) -> bool: + """Delete a user. + + Args: + unique_id: The unique ID of the user to delete. + + Returns: + bool: True if the user was successfully deleted. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + return self._make_request( + method="DELETE", + endpoint=f"/api/v1/public/users/{unique_id}", + response_model=UserAPIResponse, + expected_status=204, + ) diff --git a/siren/users.py b/siren/users.py deleted file mode 100644 index 5f71de8..0000000 --- a/siren/users.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Manages user-related operations for the Siren API client.""" - -import requests -from pydantic import ValidationError - -from .exceptions import SirenAPIError, SirenSDKError -from .models.user import User, UserAPIResponse, UserRequest -from .utils import parse_json_response - - -class UsersManager: - """Manages user-related operations for the Siren API.""" - - def __init__(self, api_key: str, base_url: str): - """ - Initializes the UsersManager. - - Args: - api_key: The API key for authentication. - base_url: The base URL for the Siren API. - """ - self.api_key = api_key - self.base_url = base_url - # TODO: Make timeout configurable through client initialization - self.timeout = 10 - - def add_user(self, **user_data) -> User: - """ - Creates a user. - - Args: - **user_data: User attributes matching the UserRequest model fields. - Use snake_case for field names (e.g., first_name, unique_id). - - Returns: - User: A User model representing the created/updated user. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - url = f"{self.base_url}/api/v1/public/users" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - try: - # Prepare the request with Pydantic validation - user_request = UserRequest.model_validate(user_data) - payload = user_request.model_dump(by_alias=True, exclude_none=True) - - # Make API request - response = requests.post( - url, headers=headers, json=payload, timeout=self.timeout - ) - response_json = parse_json_response(response) - - # Parse the response - parsed_response = UserAPIResponse.model_validate(response_json) - - # Handle success case (200 OK) - if response.status_code == 200 and parsed_response.data: - return parsed_response.data - - # Handle API error - # API error response structure: - # { - # "data": null, - # "error": { "errorCode": "...", "message": "..." }, - # "errors": [{ "errorCode": "...", "message": "..." }], - # "meta": null - # } - # Status codes: - # 200 - OK - # 400 - BAD REQUEST - # 401 - UNAUTHORISED - # 404 - NOT FOUND - if response.status_code in (400, 401, 404): - error_detail = parsed_response.error_detail - if error_detail: - raise SirenAPIError( - error_detail=error_detail, - status_code=response.status_code, - raw_response=response_json, - ) - - # Fallback error for unexpected status codes - raise SirenSDKError( - message=f"Unexpected API response. Status: {response.status_code}", - status_code=response.status_code, - raw_response=response_json, - ) - - except ValidationError as e: - # Input validation error - raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) - - except requests.exceptions.RequestException as e: - # Network or connection error - raise SirenSDKError( - f"Network or connection error: {e}", original_exception=e - ) - - def update_user(self, unique_id: str, **user_data) -> User: - """ - Updates a user. - - Args: - unique_id: The unique ID of the user to update. - **user_data: User attributes matching the UserRequest model fields. - - Returns: - User: A User model representing the updated user. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - url = f"{self.base_url}/api/v1/public/users/{unique_id}" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - try: - # Add unique_id to the payload - user_data["unique_id"] = unique_id - - # Prepare the request with Pydantic validation - user_request = UserRequest.model_validate(user_data) - payload = user_request.model_dump(by_alias=True, exclude_none=True) - - # Make API request - response = requests.put( - url, headers=headers, json=payload, timeout=self.timeout - ) - response_json = parse_json_response(response) - - # Parse the response - parsed_response = UserAPIResponse.model_validate(response_json) - - # Handle success case (200 OK) - if response.status_code == 200 and parsed_response.data: - return parsed_response.data - - # Handle API error - if response.status_code in (400, 401, 404): - error_detail = parsed_response.error_detail - if error_detail: - raise SirenAPIError( - error_detail=error_detail, - status_code=response.status_code, - raw_response=response_json, - ) - - # Fallback error for unexpected status codes - raise SirenSDKError( - message=f"Unexpected API response. Status: {response.status_code}", - status_code=response.status_code, - raw_response=response_json, - ) - - except ValidationError as e: - # Input validation error - raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) - - except requests.exceptions.RequestException as e: - # Network or connection error - raise SirenSDKError( - f"Network or connection error: {e}", original_exception=e - ) - - def delete_user(self, unique_id: str) -> bool: - """ - Deletes a user. - - Args: - unique_id: The unique ID of the user to delete. - - Returns: - bool: True if the user was successfully deleted. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - url = f"{self.base_url}/api/v1/public/users/{unique_id}" - headers = { - "Authorization": f"Bearer {self.api_key}", - } - - try: - # Make API request - response = requests.delete(url, headers=headers, timeout=self.timeout) - - # Handle success case (204 No Content) - if response.status_code == 204: - return True - - # Parse response for error cases - response_json = parse_json_response(response) - parsed_response = UserAPIResponse.model_validate(response_json) - - # Handle API error - if response.status_code in (400, 401, 404): - error_detail = parsed_response.error_detail - if error_detail: - raise SirenAPIError( - error_detail=error_detail, - status_code=response.status_code, - raw_response=response_json, - ) - - # Fallback error for unexpected status codes - raise SirenSDKError( - message=f"Unexpected API response. Status: {response.status_code}", - status_code=response.status_code, - raw_response=response_json, - ) - - except ValidationError as e: - # Input validation error - raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) - - except requests.exceptions.RequestException as e: - # Network or connection error - raise SirenSDKError( - f"Network or connection error: {e}", original_exception=e - ) diff --git a/siren/utils.py b/siren/utils.py deleted file mode 100644 index ce9a58d..0000000 --- a/siren/utils.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Utility functions for the Siren SDK.""" - -import requests - -from .exceptions import SirenSDKError - - -def parse_json_response(response: requests.Response) -> dict: - """ - Parse JSON response and handle parsing errors. - - Args: - response: The HTTP response to parse. - - Returns: - dict: The parsed JSON response. - - Raises: - SirenSDKError: If the response is not valid JSON. - """ - try: - return response.json() - except requests.exceptions.JSONDecodeError as e: - raise SirenSDKError( - f"API response was not valid JSON. Status: {response.status_code}. Content: {response.text}", - original_exception=e, - status_code=response.status_code, - ) diff --git a/tests/test_users.py b/tests/test_users.py index 5230d12..7194b65 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -9,8 +9,8 @@ from siren.client import SirenClient from siren.exceptions import SirenAPIError, SirenSDKError +from siren.managers.users import UsersManager from siren.models.user import User -from siren.users import UsersManager # Test constants MOCK_API_KEY = "test_api_key" @@ -49,8 +49,8 @@ def mock_response( class TestUsersManager: """Tests for the UsersManager class.""" - @patch("siren.users.requests.post") - def test_add_user_success(self, mock_post, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_add_user_success(self, mock_request, users_manager: UsersManager): """Test successful user creation/update returns a User model instance.""" # Mock API response with all possible user fields mock_api_json_response = { @@ -72,7 +72,7 @@ def test_add_user_success(self, mock_post, users_manager: UsersManager): }, "error": None, } - mock_post.return_value = mock_response(200, json_data=mock_api_json_response) + mock_request.return_value = mock_response(200, json_data=mock_api_json_response) # Test payload with snake_case keys (SDK input) payload = { @@ -98,10 +98,12 @@ def test_add_user_success(self, mock_post, users_manager: UsersManager): "activeChannels": ["EMAIL"], "attributes": {"custom_field": "value1"}, } - mock_post.assert_called_once_with( - f"{MOCK_BASE_URL}/api/v1/public/users", - json=expected_json_payload, + mock_request.assert_called_once_with( + method="POST", + url=f"{MOCK_BASE_URL}/api/v1/public/users", headers=expected_headers, + json=expected_json_payload, + params=None, timeout=10, ) @@ -122,9 +124,9 @@ def test_add_user_success(self, mock_post, users_manager: UsersManager): assert response.phone is None assert response.avatar_url is None - @patch("siren.users.requests.post") + @patch("siren.managers.base.requests.request") def test_add_user_api_error_returns_json( - self, mock_post, users_manager: UsersManager + self, mock_request, users_manager: UsersManager ): """Test API error (400, 401, 404) with JSON body raises SirenAPIError.""" # Mock API error response with validation details @@ -144,9 +146,7 @@ def test_add_user_api_error_returns_json( status_code = 400 err_response_obj = mock_response(status_code, json_data=mock_api_error_payload) - http_error = requests.exceptions.HTTPError(response=err_response_obj) - err_response_obj.raise_for_status.side_effect = http_error - mock_post.return_value = err_response_obj + mock_request.return_value = err_response_obj with pytest.raises(SirenAPIError) as excinfo: users_manager.add_user(unique_id=MOCK_USER_ID) @@ -160,20 +160,20 @@ def test_add_user_api_error_returns_json( == mock_api_error_payload["error"]["details"] ) - @patch("siren.users.requests.post") - def test_add_user_http_error_no_json(self, mock_post, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_add_user_http_error_no_json( + self, mock_request, users_manager: UsersManager + ): """Test API error (500) without JSON body raises SirenSDKError.""" # Mock non-JSON error response status_code = 500 error_text = "Internal Server Error - Not JSON" err_response_obj = mock_response(status_code, text_data=error_text) - http_error = requests.exceptions.HTTPError(response=err_response_obj) - err_response_obj.raise_for_status.side_effect = http_error err_response_obj.json.side_effect = requests.exceptions.JSONDecodeError( "Expecting value", "doc", 0 ) - mock_post.return_value = err_response_obj + mock_request.return_value = err_response_obj with pytest.raises(SirenSDKError) as excinfo: users_manager.add_user(unique_id=MOCK_USER_ID) @@ -184,14 +184,16 @@ def test_add_user_http_error_no_json(self, mock_post, users_manager: UsersManage assert "API response was not valid JSON" in excinfo.value.message assert error_text in excinfo.value.message - @patch("siren.users.requests.post") - def test_add_user_request_exception(self, mock_post, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_add_user_request_exception( + self, mock_request, users_manager: UsersManager + ): """Test handling of requests.exceptions.RequestException (e.g., network error) raises SirenSDKError.""" # Mock network error original_exception = requests.exceptions.ConnectionError( "Simulated connection failed" ) - mock_post.side_effect = original_exception + mock_request.side_effect = original_exception with pytest.raises(SirenSDKError) as excinfo: users_manager.add_user(unique_id=MOCK_USER_ID) @@ -202,8 +204,8 @@ def test_add_user_request_exception(self, mock_post, users_manager: UsersManager ) assert "Network or connection error" in excinfo.value.message - @patch("siren.users.requests.put") - def test_update_user_success(self, mock_put, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_update_user_success(self, mock_request, users_manager: UsersManager): """Test successful user update returns a User model instance.""" # Mock API response mock_api_json_response = { @@ -225,7 +227,7 @@ def test_update_user_success(self, mock_put, users_manager: UsersManager): }, "error": None, } - mock_put.return_value = mock_response(200, json_data=mock_api_json_response) + mock_request.return_value = mock_response(200, json_data=mock_api_json_response) # Test payload with snake_case keys (SDK input) payload = { @@ -254,10 +256,12 @@ def test_update_user_success(self, mock_put, users_manager: UsersManager): "referenceId": "020", "whatsapp": "+919632323154", } - mock_put.assert_called_once_with( - f"{MOCK_BASE_URL}/api/v1/public/users/{MOCK_USER_ID}", - json=expected_json_payload, + mock_request.assert_called_once_with( + method="PUT", + url=f"{MOCK_BASE_URL}/api/v1/public/users/{MOCK_USER_ID}", headers=expected_headers, + json=expected_json_payload, + params=None, timeout=10, ) @@ -273,9 +277,9 @@ def test_update_user_success(self, mock_put, users_manager: UsersManager): assert response.whatsapp == "+919632323154" assert response.updated_at == "2023-01-02T12:00:00Z" - @patch("siren.users.requests.put") + @patch("siren.managers.base.requests.request") def test_update_user_api_error_returns_json( - self, mock_put, users_manager: UsersManager + self, mock_request, users_manager: UsersManager ): """Test API error (400, 401, 404) with JSON body raises SirenAPIError.""" # Mock API error response @@ -288,9 +292,7 @@ def test_update_user_api_error_returns_json( status_code = 404 err_response_obj = mock_response(status_code, json_data=mock_api_error_payload) - http_error = requests.exceptions.HTTPError(response=err_response_obj) - err_response_obj.raise_for_status.side_effect = http_error - mock_put.return_value = err_response_obj + mock_request.return_value = err_response_obj with pytest.raises(SirenAPIError) as excinfo: users_manager.update_user(MOCK_USER_ID, first_name="Jane") @@ -300,23 +302,27 @@ def test_update_user_api_error_returns_json( assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] - @patch("siren.users.requests.put") - def test_update_user_validation_error(self, mock_put, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_update_user_validation_error( + self, mock_request, users_manager: UsersManager + ): """Test invalid parameters raise SirenSDKError.""" with pytest.raises(SirenSDKError) as excinfo: users_manager.update_user(MOCK_USER_ID, email="invalid-email") assert "Invalid parameters" in excinfo.value.message - mock_put.assert_not_called() + mock_request.assert_not_called() - @patch("siren.users.requests.put") - def test_update_user_request_exception(self, mock_put, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_update_user_request_exception( + self, mock_request, users_manager: UsersManager + ): """Test handling of requests.exceptions.RequestException raises SirenSDKError.""" # Mock network error original_exception = requests.exceptions.ConnectionError( "Simulated connection failed" ) - mock_put.side_effect = original_exception + mock_request.side_effect = original_exception with pytest.raises(SirenSDKError) as excinfo: users_manager.update_user(MOCK_USER_ID, first_name="Jane") @@ -324,11 +330,11 @@ def test_update_user_request_exception(self, mock_put, users_manager: UsersManag assert excinfo.value.original_exception == original_exception assert "Network or connection error" in excinfo.value.message - @patch("siren.users.requests.delete") - def test_delete_user_success(self, mock_delete, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_delete_user_success(self, mock_request, users_manager: UsersManager): """Test successful user deletion returns True.""" # Mock API response for 204 No Content - mock_delete.return_value = mock_response(204) + mock_request.return_value = mock_response(204) response = users_manager.delete_user(MOCK_USER_ID) @@ -336,17 +342,20 @@ def test_delete_user_success(self, mock_delete, users_manager: UsersManager): expected_headers = { "Authorization": f"Bearer {MOCK_API_KEY}", } - mock_delete.assert_called_once_with( - f"{MOCK_BASE_URL}/api/v1/public/users/{MOCK_USER_ID}", + mock_request.assert_called_once_with( + method="DELETE", + url=f"{MOCK_BASE_URL}/api/v1/public/users/{MOCK_USER_ID}", headers=expected_headers, + json=None, + params=None, timeout=10, ) # Verify response assert response is True - @patch("siren.users.requests.delete") - def test_delete_user_not_found(self, mock_delete, users_manager: UsersManager): + @patch("siren.managers.base.requests.request") + def test_delete_user_not_found(self, mock_request, users_manager: UsersManager): """Test API error (404) raises SirenAPIError.""" # Mock API error response mock_api_error_payload = { @@ -358,9 +367,7 @@ def test_delete_user_not_found(self, mock_delete, users_manager: UsersManager): status_code = 404 err_response_obj = mock_response(status_code, json_data=mock_api_error_payload) - http_error = requests.exceptions.HTTPError(response=err_response_obj) - err_response_obj.raise_for_status.side_effect = http_error - mock_delete.return_value = err_response_obj + mock_request.return_value = err_response_obj with pytest.raises(SirenAPIError) as excinfo: users_manager.delete_user(MOCK_USER_ID) @@ -370,16 +377,16 @@ def test_delete_user_not_found(self, mock_delete, users_manager: UsersManager): assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] - @patch("siren.users.requests.delete") + @patch("siren.managers.base.requests.request") def test_delete_user_request_exception( - self, mock_delete, users_manager: UsersManager + self, mock_request, users_manager: UsersManager ): """Test handling of requests.exceptions.RequestException raises SirenSDKError.""" # Mock network error original_exception = requests.exceptions.ConnectionError( "Simulated connection failed" ) - mock_delete.side_effect = original_exception + mock_request.side_effect = original_exception with pytest.raises(SirenSDKError) as excinfo: users_manager.delete_user(MOCK_USER_ID) From 24db7523a919ad82f0de9a08594e3ebf4de015aa Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 15:24:19 +0530 Subject: [PATCH 22/42] refactor: message methods --- examples/messages.py | 151 +++++++++++------------------------- examples/users.py | 19 +++-- siren/client.py | 21 +++-- siren/managers/__init__.py | 3 +- siren/managers/messaging.py | 97 +++++++++++++++++++++++ siren/models/messaging.py | 73 +++++++++++++++++ 6 files changed, 238 insertions(+), 126 deletions(-) create mode 100644 siren/managers/messaging.py create mode 100644 siren/models/messaging.py diff --git a/examples/messages.py b/examples/messages.py index 2760a4b..f41c243 100644 --- a/examples/messages.py +++ b/examples/messages.py @@ -1,127 +1,66 @@ -"""Example script to demonstrate sending messages using the Siren SDK.""" +"""Example script demonstrating messaging methods in the Siren SDK.""" import os import sys -from dotenv import load_dotenv +from siren.client import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError -# Add the project root to the Python path -# This allows us to import the 'siren' package -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, project_root) +# Allow running from examples directory +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from siren import SirenClient # noqa: E402 - -def send_message_example(client: SirenClient): - """Demonstrates sending a message using a template.""" - print("Attempting to send a message...") +def send_message_example(client: SirenClient) -> str: + """Example of sending a message.""" try: - template_name = "sampleTemplate" - channel = "SLACK" - recipient_type = "direct" - recipient_value = "U01UBCD06BB" - - # Optional: Provide template variables if your template requires them - template_variables = { - "user_name": "Jithu", - } - - response = client.send_message( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - template_variables=template_variables, + message_id = client.send_message( + template_name="sampleTemplate", + channel="SLACK", + recipient_type="direct", + recipient_value="U01UBCD06BB", + template_variables={"user_name": "John"}, ) - print("Send message API response:") - print(response) - - if ( - response - and response.get("data") - and response.get("data", {}).get("notificationId") - ): - print( - f"Message sent successfully! Notification ID: {response['data']['notificationId']}" - ) - elif response and response.get("error"): - print(f"Failed to send message. Error: {response['error']}") - else: - print("Received an unexpected response structure for send_message.") - - except Exception as e: - print(f"An error occurred while sending the message: {e}") - + print(f"Message sent: {message_id}") + return message_id + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") -def get_replies_example(client: SirenClient, message_id: str): - """Demonstrates retrieving replies for a message.""" - print("\nAttempting to get message replies...") - # IMPORTANT: Replace with an actual message ID that has replies - message_id_with_replies = "9004b6b0-3e77-4add-9541-56ba28c37f27" +def get_message_status_example(client: SirenClient, message_id: str) -> None: + """Example of getting message status.""" try: - print(f"Fetching replies for message ID: {message_id_with_replies}") - response = client.get_replies(message_id=message_id_with_replies) - print("Get message replies API response:") - print(response) + status = client.get_message_status(message_id=message_id) + print(f"Message status: {status}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") - if response and response.get("data") is not None: # Check if 'data' key exists - replies = response["data"] - if isinstance(replies, list) and replies: - print(f"Found {len(replies)} replies:") - for i, reply in enumerate(replies): - print( - f" Reply {i + 1}: {reply.get('text', 'N/A')} (User: {reply.get('user', 'N/A')}, Timestamp: {reply.get('ts', 'N/A')})" - ) - elif isinstance(replies, list) and not replies: - print("No replies found for this message.") - else: - print("Received 'data' but it's not a list of replies as expected.") - elif response and response.get("error"): - print(f"Failed to get replies. Error: {response['error']}") - else: - print("Received an unexpected response structure for get_message_replies.") - - except Exception as e: - print(f"An error occurred while getting message replies: {e}") - - -def get_message_status_example(client: SirenClient, message_id: str): - """Demonstrates retrieving the status for a message.""" - print("\nAttempting to get message status...") - # IMPORTANT: Replace with an actual message ID - # Using an ID from a previous successful run for demonstration - message_id_to_check_status = message_id +def get_replies_example(client: SirenClient, message_id: str) -> None: + """Example of getting message replies.""" try: - print(f"Fetching status for message ID: {message_id_to_check_status}") - response = client.get_message_status(message_id=message_id_to_check_status) - print("Get message status API response:") - print(response) - - if response and response.get("data") and response["data"].get("status"): - print(f"Message Status: {response['data']['status']}") - elif response and response.get("error"): - print(f"Failed to get message status. Error: {response['error']}") - else: - print("Received an unexpected response structure for get_message_status.") - - except Exception as e: - print(f"An error occurred while getting the message status: {e}") + replies = client.get_replies(message_id=message_id) + print(f"Found {len(replies)} replies:") + for i, reply in enumerate(replies): + print(f" Reply {i + 1}: {reply.text} (User: {reply.user})") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") if __name__ == "__main__": - load_dotenv() - api_key = os.getenv("SIREN_API_KEY") + api_key = os.environ.get("SIREN_API_KEY") if not api_key: - print("Error: SIREN_API_KEY not found in environment variables or .env file.") - print("Please set it to run the example.") + print("Error: SIREN_API_KEY environment variable not set.") sys.exit(1) - siren_client = SirenClient(api_key=api_key) - # send_message_example(siren_client) - # get_replies_example( - # siren_client, "9004b6b0-3e77-4add-9541-56ba28c37f27" - # ) - get_message_status_example(siren_client, "c53539ce-2d74-4071-b671-ead6c8465b5b") + client = SirenClient(api_key=api_key) + + message_id = send_message_example(client) + if message_id: + get_message_status_example(client, message_id) + get_replies_example(client, message_id) diff --git a/examples/users.py b/examples/users.py index 918588f..1322df6 100644 --- a/examples/users.py +++ b/examples/users.py @@ -11,7 +11,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -def add_user_example(client: SirenClient) -> None: +def add_user_example(client: SirenClient) -> str: """Example of adding a user.""" user = { "unique_id": "john_doe_008", @@ -24,17 +24,18 @@ def add_user_example(client: SirenClient) -> None: try: created_user = client.add_user(**user) print(f"Created user: {created_user.id}") + return created_user.unique_id except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: print(f"SDK Error: {e.message}") -def update_user_example(client: SirenClient) -> None: +def update_user_example(client: SirenClient, unique_id: str) -> None: """Example of updating a user.""" try: updated_user = client.update_user( - "john_doe_008", + unique_id, first_name="Jane", last_name="Smith", email="jane.smith@company.com", @@ -47,10 +48,10 @@ def update_user_example(client: SirenClient) -> None: print(f"SDK Error: {e.message}") -def delete_user_example(client: SirenClient) -> None: +def delete_user_example(client: SirenClient, unique_id: str) -> None: """Example of deleting a user.""" try: - deleted = client.delete_user("123") + deleted = client.delete_user(unique_id) print(f"Deleted user: {deleted}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") @@ -66,6 +67,8 @@ def delete_user_example(client: SirenClient) -> None: client = SirenClient(api_key=api_key) - # add_user_example(client) - # update_user_example(client) - delete_user_example(client) + unique_id = add_user_example(client) + if unique_id: + update_user_example(client, unique_id) + # Uncomment to delete the user after testing + delete_user_example(client, unique_id) diff --git a/siren/client.py b/siren/client.py index a9aa0a5..ab376cf 100644 --- a/siren/client.py +++ b/siren/client.py @@ -4,8 +4,9 @@ from pydantic import EmailStr +from .managers.messaging import MessagingManager from .managers.users import UsersManager -from .messaging import MessagingManager +from .models.messaging import ReplyData from .models.user import User from .templates import TemplatesManager from .webhooks import WebhookManager @@ -368,7 +369,7 @@ def send_message( recipient_type: str, recipient_value: str, template_variables: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + ) -> str: """Send a message using a specific template. Args: @@ -379,7 +380,7 @@ def send_message( template_variables: A dictionary of variables to populate the template. Returns: - A dictionary containing the API response. + The message ID of the sent message. """ return self._messaging.send_message( template_name=template_name, @@ -389,27 +390,25 @@ def send_message( template_variables=template_variables, ) - def get_replies(self, message_id: str) -> Dict[str, Any]: - """ - Retrieve replies for a specific message ID. + def get_replies(self, message_id: str) -> List[ReplyData]: + """Retrieve replies for a specific message. Args: message_id: The ID of the message for which to retrieve replies. Returns: - A dictionary containing the API response with replies. + A list of reply objects containing message details. """ return self._messaging.get_replies(message_id=message_id) - def get_message_status(self, message_id: str) -> Dict[str, Any]: - """ - Retrieve the status of a specific message. + def get_message_status(self, message_id: str) -> str: + """Retrieve the status of a specific message. Args: message_id: The ID of the message for which to retrieve the status. Returns: - A dictionary containing the API response with the message status. + The status of the message (e.g., "DELIVERED", "PENDING"). """ return self._messaging.get_message_status(message_id=message_id) diff --git a/siren/managers/__init__.py b/siren/managers/__init__.py index 3cef71d..2ebc4d7 100644 --- a/siren/managers/__init__.py +++ b/siren/managers/__init__.py @@ -1,6 +1,7 @@ """Manager classes for the Siren SDK.""" from .base import BaseManager +from .messaging import MessagingManager from .users import UsersManager -__all__ = ["BaseManager", "UsersManager"] +__all__ = ["BaseManager", "UsersManager", "MessagingManager"] diff --git a/siren/managers/messaging.py b/siren/managers/messaging.py new file mode 100644 index 0000000..f1a9952 --- /dev/null +++ b/siren/managers/messaging.py @@ -0,0 +1,97 @@ +"""Manages messaging-related API interactions for the Siren SDK.""" + +from typing import Any, Dict, List, Optional + +from ..models.messaging import ( + MessageRepliesResponse, + MessageStatusResponse, + ReplyData, + SendMessageRequest, + SendMessageResponse, +) +from .base import BaseManager + + +class MessagingManager(BaseManager): + """Manages direct message sending operations.""" + + def send_message( + self, + template_name: str, + channel: str, + recipient_type: str, + recipient_value: str, + template_variables: Optional[Dict[str, Any]] = None, + ) -> str: + """Send a message using a specific template. + + Args: + template_name: The name of the template to use. + channel: The channel to send the message through (e.g., "SLACK", "EMAIL"). + recipient_type: The type of recipient (e.g., "direct"). + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address). + template_variables: A dictionary of variables to populate the template. + + Returns: + The message ID of the sent message. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + payload = { + "template": {"name": template_name}, + "recipient": {"type": recipient_type, "value": recipient_value}, + "channel": channel, + } + if template_variables is not None: + payload["template_variables"] = template_variables + + response = self._make_request( + method="POST", + endpoint="/api/v1/public/send-messages", + request_model=SendMessageRequest, + response_model=SendMessageResponse, + data=payload, + ) + return response.message_id + + def get_message_status(self, message_id: str) -> str: + """Retrieve the status of a specific message. + + Args: + message_id: The ID of the message for which to retrieve the status. + + Returns: + The status of the message (e.g., "DELIVERED", "PENDING"). + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="GET", + endpoint=f"/api/v1/public/message-status/{message_id}", + response_model=MessageStatusResponse, + ) + return response.status + + def get_replies(self, message_id: str) -> List[ReplyData]: + """Retrieve replies for a specific message. + + Args: + message_id: The ID of the message for which to retrieve replies. + + Returns: + A list of reply objects containing message details. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="GET", + endpoint=f"/api/v1/public/get-reply/{message_id}", + response_model=MessageRepliesResponse, + ) + return response diff --git a/siren/models/messaging.py b/siren/models/messaging.py new file mode 100644 index 0000000..712cc29 --- /dev/null +++ b/siren/models/messaging.py @@ -0,0 +1,73 @@ +"""Messaging-related models for the Siren SDK.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .base import BaseAPIResponse + + +class TemplateInfo(BaseModel): + """Template information for messaging.""" + + name: str + + +class Recipient(BaseModel): + """Recipient information for messaging.""" + + type: str + value: str + + +class SendMessageRequest(BaseModel): + """Request model for sending messages.""" + + # Fix: template_variables was silently becoming None during model creation + model_config = ConfigDict(populate_by_name=True) + + template: TemplateInfo + recipient: Recipient + channel: str + template_variables: Optional[Dict[str, Any]] = Field( + alias="templateVariables", default=None + ) + + +class MessageData(BaseModel): + """Message response data.""" + + message_id: str = Field(alias="notificationId") + + +class StatusData(BaseModel): + """Message status data.""" + + status: str + + +class ReplyData(BaseModel): + """Individual reply data.""" + + text: str + thread_ts: str = Field(alias="threadTs") + user: str + ts: str + + +class SendMessageResponse(BaseAPIResponse[MessageData]): + """API response for send message operations.""" + + pass + + +class MessageStatusResponse(BaseAPIResponse[StatusData]): + """API response for message status operations.""" + + pass + + +class MessageRepliesResponse(BaseAPIResponse[List[ReplyData]]): + """API response for message replies operations.""" + + pass From 8c5b9e17d5698600dd875cf9c3487354fbd8856f Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 15:44:56 +0530 Subject: [PATCH 23/42] tests: for messaging methods --- coverage.xml | 315 +++++++++++++++----------- siren/managers/base.py | 5 +- tests/test_messaging.py | 489 ++++++++++++++-------------------------- 3 files changed, 360 insertions(+), 449 deletions(-) diff --git a/coverage.xml b/coverage.xml index 202aa1e..67a0f32 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,7 +16,7 @@ - + @@ -27,54 +27,55 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - + @@ -87,7 +88,7 @@ - + @@ -100,60 +101,60 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -392,70 +393,92 @@ - + - + + - + - - - - - + + + + - - + + - - + + - + - - + + - - + + - - + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -475,7 +498,7 @@ - + @@ -505,6 +528,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/siren/managers/base.py b/siren/managers/base.py index b5b8da3..ef1a234 100644 --- a/siren/managers/base.py +++ b/siren/managers/base.py @@ -109,7 +109,10 @@ def _make_request( # noqa: C901 if response_model: response_json = self._parse_json_response(response) parsed_response = response_model.model_validate(response_json) - if hasattr(parsed_response, "data") and parsed_response.data: + if ( + hasattr(parsed_response, "data") + and parsed_response.data is not None + ): return parsed_response.data # Handle error cases diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 06f301e..4296583 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -1,343 +1,194 @@ -"""Unit tests for the messaging module of the Siren SDK.""" +"""Unit tests for the updated messaging manager using BaseManager.""" + +from unittest.mock import Mock, patch import pytest -import requests -from requests_mock import Mocker as RequestsMocker -from siren.client import SirenClient -from siren.messaging import MessagingManager +from siren.exceptions import SirenAPIError, SirenSDKError +from siren.managers.messaging import MessagingManager API_KEY = "test_api_key" BASE_URL = "https://api.dev.trysiren.io" class TestMessagingManager: - """Tests for the MessagingManager class.""" - - def test_get_message_status_success(self, requests_mock: RequestsMocker): - """Test successful retrieval of message status.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_status_123" - expected_status = {"status": "DELIVERED"} - mock_response_data = {"data": expected_status, "error": None} - - requests_mock.get( - f"{BASE_URL}/api/v1/public/message-status/{message_id}", - json=mock_response_data, - status_code=200, - ) - response = manager.get_message_status(message_id=message_id) - assert response == mock_response_data - assert response["data"] == expected_status - - def test_get_message_status_http_error_json_response( - self, requests_mock: RequestsMocker - ): - """Test HTTP error with JSON response during get_message_status.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_status_error_json" - error_response = {"error": "Not Found", "message": "Message ID does not exist."} - requests_mock.get( - f"{BASE_URL}/api/v1/public/message-status/{message_id}", - json=error_response, - status_code=404, - ) - response = manager.get_message_status(message_id=message_id) - assert response == error_response - - def test_get_message_status_http_error_no_json_response( - self, requests_mock: RequestsMocker - ): - """Test HTTP error without JSON response during get_message_status.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_status_error_no_json" - requests_mock.get( - f"{BASE_URL}/api/v1/public/message-status/{message_id}", - text="Server Error", - status_code=500, - ) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: - manager.get_message_status(message_id=message_id) - assert excinfo.value.response.status_code == 500 - assert excinfo.value.response.text == "Server Error" - - def test_get_message_status_request_exception(self, requests_mock: RequestsMocker): - """Test requests.exceptions.RequestException during get_message_status.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_status_req_exception" - requests_mock.get( - f"{BASE_URL}/api/v1/public/message-status/{message_id}", - exc=requests.exceptions.Timeout("Connection timed out"), - ) - with pytest.raises(requests.exceptions.RequestException): - manager.get_message_status(message_id=message_id) - - """Tests for the MessagingManager class.""" - - def test_send_message_success(self, requests_mock: RequestsMocker): - """Test successful message sending.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - template_name = "test_template" - channel = "SLACK" - recipient_type = "direct" - recipient_value = "U123ABC" - template_variables = {"name": "John Doe"} - - expected_payload = { - "template": {"name": template_name}, - "recipient": {"type": recipient_type, "value": recipient_value}, - "channel": channel, - "templateVariables": template_variables, - } - mock_response_data = {"data": {"notificationId": "notif_123"}, "error": None} - - requests_mock.post( - f"{BASE_URL}/api/v1/public/send-messages", - json=mock_response_data, - status_code=200, - ) - - response = manager.send_message( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - template_variables=template_variables, - ) - - assert response == mock_response_data - assert requests_mock.called_once - last_request = requests_mock.last_request - assert last_request is not None - assert last_request.json() == expected_payload - assert last_request.headers["Authorization"] == f"Bearer {API_KEY}" - assert last_request.headers["Content-Type"] == "application/json" - assert last_request.headers["Accept"] == "application/json" - - def test_send_message_success_no_variables(self, requests_mock: RequestsMocker): - """Test successful message sending without template variables.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - template_name = "test_template_no_vars" - channel = "EMAIL" - recipient_type = "direct" - recipient_value = "test@example.com" - - expected_payload = { - "template": {"name": template_name}, - "recipient": {"type": recipient_type, "value": recipient_value}, - "channel": channel, + """Tests for MessagingManager with BaseManager.""" + + def setup_method(self): + """Set up test fixtures.""" + self.manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + + @patch("siren.managers.base.requests.request") + def test_send_message_success(self, mock_request): + """Test successful message sending with new BaseManager.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"notificationId": "test_msg_123"}, + "error": None, } - mock_response_data = {"data": {"notificationId": "notif_456"}, "error": None} - - requests_mock.post( - f"{BASE_URL}/api/v1/public/send-messages", - json=mock_response_data, - status_code=200, - ) - - response = manager.send_message( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - ) + mock_request.return_value = mock_response - assert response == mock_response_data - assert requests_mock.called_once - last_request = requests_mock.last_request - assert last_request is not None - assert last_request.json() == expected_payload - - def test_send_message_http_error_with_json_response( - self, requests_mock: RequestsMocker - ): - """Test HTTP error with JSON response during message sending.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - error_response = {"error": "Bad Request", "message": "Invalid template name"} - requests_mock.post( - f"{BASE_URL}/api/v1/public/send-messages", - json=error_response, - status_code=400, - ) - - response = manager.send_message( - template_name="invalid_template", + # Call the method + result = self.manager.send_message( + template_name="test_template", channel="SLACK", recipient_type="direct", - recipient_value="U123", - ) - assert response == error_response - - def test_send_message_http_error_no_json_response( - self, requests_mock: RequestsMocker - ): - """Test HTTP error without JSON response during message sending.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - requests_mock.post( - f"{BASE_URL}/api/v1/public/send-messages", - text="Internal Server Error", - status_code=500, - ) + recipient_value="U123ABC", + template_variables={"name": "John"}, + ) + + # Verify result + assert result == "test_msg_123" + + # Verify request was made correctly + mock_request.assert_called_once() + call_args = mock_request.call_args + + # Check URL and method + assert call_args[1]["method"] == "POST" + assert call_args[1]["url"] == f"{BASE_URL}/api/v1/public/send-messages" + + # Check payload has camelCase fields + payload = call_args[1]["json"] + assert "templateVariables" in payload + assert payload["templateVariables"]["name"] == "John" + assert payload["template"]["name"] == "test_template" + + @patch("siren.managers.base.requests.request") + def test_get_message_status_success(self, mock_request): + """Test successful message status retrieval.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"status": "DELIVERED"}, + "error": None, + } + mock_request.return_value = mock_response + + # Call the method + result = self.manager.get_message_status("test_msg_123") + + # Verify result + assert result == "DELIVERED" + + @patch("siren.managers.base.requests.request") + def test_get_replies_success(self, mock_request): + """Test successful replies retrieval.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "text": "Reply 1", + "user": "U123", + "ts": "12345.6789", + "threadTs": "12345.0000", + }, + { + "text": "Reply 2", + "user": "U456", + "ts": "12346.7890", + "threadTs": "12345.0000", + }, + ], + "error": None, + } + mock_request.return_value = mock_response + + # Call the method + result = self.manager.get_replies("test_msg_123") + + # Verify result + assert len(result) == 2 + assert result[0].text == "Reply 1" + assert result[0].user == "U123" + assert result[1].text == "Reply 2" + assert result[1].user == "U456" + + @patch("siren.managers.base.requests.request") + def test_api_error_handling(self, mock_request): + """Test that API errors are properly handled.""" + # Mock API error response + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.return_value = { + "data": None, + "error": {"errorCode": "NOT_FOUND", "message": "Template not found"}, + } + mock_request.return_value = mock_response - with pytest.raises(requests.exceptions.HTTPError) as excinfo: - manager.send_message( - template_name="any_template", + # Should raise SirenAPIError + with pytest.raises(SirenAPIError) as exc_info: + self.manager.send_message( + template_name="nonexistent", channel="SLACK", recipient_type="direct", recipient_value="U123", ) - assert excinfo.value.response.status_code == 500 - assert excinfo.value.response.text == "Internal Server Error" - - def test_send_message_request_exception(self, requests_mock: RequestsMocker): - """Test requests.exceptions.RequestException during message sending.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - requests_mock.post( - f"{BASE_URL}/api/v1/public/send-messages", - exc=requests.exceptions.ConnectTimeout, - ) - with pytest.raises(requests.exceptions.RequestException): - manager.send_message( - template_name="any_template", + assert exc_info.value.error_code == "NOT_FOUND" + assert "Template not found" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_send_message_without_template_variables(self, mock_request): + """Test sending message without template variables.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"notificationId": "test_msg_456"}, + "error": None, + } + mock_request.return_value = mock_response + + # Call the method without template_variables + result = self.manager.send_message( + template_name="simple_template", + channel="EMAIL", + recipient_type="direct", + recipient_value="test@example.com", + ) + + # Verify result + assert result == "test_msg_456" + + # Verify payload excludes templateVariables when None + payload = mock_request.call_args[1]["json"] + assert "templateVariables" not in payload + + @patch("siren.managers.base.requests.request") + def test_get_replies_empty_list(self, mock_request): + """Test get_replies when no replies exist.""" + # Mock successful API response with empty list + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": [], "error": None} + mock_request.return_value = mock_response + + # Call the method + result = self.manager.get_replies("test_msg_no_replies") + + # Verify result + assert result == [] + assert len(result) == 0 + + @patch("siren.managers.base.requests.request") + def test_network_error_handling(self, mock_request): + """Test handling of network errors.""" + # Mock network error + mock_request.side_effect = Exception("Connection timeout") + + # Should raise SirenSDKError + with pytest.raises(SirenSDKError) as exc_info: + self.manager.send_message( + template_name="test", channel="SLACK", recipient_type="direct", recipient_value="U123", ) - def test_get_replies_success(self, requests_mock: RequestsMocker): - """Test successful retrieval of message replies.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_123" - expected_replies = [ - {"text": "Reply 1", "user": "U123", "ts": "12345.6789"}, - {"text": "Reply 2", "user": "U456", "ts": "12346.7890"}, - ] - mock_response_data = {"data": expected_replies, "error": None} - - requests_mock.get( - f"{BASE_URL}/api/v1/public/get-reply/{message_id}", - json=mock_response_data, - status_code=200, - ) - response = manager.get_replies(message_id=message_id) - assert response == mock_response_data - assert response["data"] == expected_replies - - def test_get_replies_no_replies(self, requests_mock: RequestsMocker): - """Test retrieval when a message has no replies.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_no_replies" - mock_response_data = {"data": [], "error": None} - - requests_mock.get( - f"{BASE_URL}/api/v1/public/get-reply/{message_id}", - json=mock_response_data, - status_code=200, - ) - response = manager.get_replies(message_id=message_id) - assert response == mock_response_data - assert response["data"] == [] - - def test_get_replies_http_error_json_response(self, requests_mock: RequestsMocker): - """Test HTTP error with JSON response during get_replies.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_error_json" - error_response = {"error": "Not Found", "message": "Message ID does not exist."} - requests_mock.get( - f"{BASE_URL}/api/v1/public/get-reply/{message_id}", - json=error_response, - status_code=404, - ) - response = manager.get_replies(message_id=message_id) - assert response == error_response - - def test_get_replies_http_error_no_json_response( - self, requests_mock: RequestsMocker - ): - """Test HTTP error without JSON response during get_replies.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_error_no_json" - requests_mock.get( - f"{BASE_URL}/api/v1/public/get-reply/{message_id}", - text="Internal Server Error", - status_code=500, - ) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: - manager.get_replies(message_id=message_id) - assert excinfo.value.response.status_code == 500 - assert excinfo.value.response.text == "Internal Server Error" - - def test_get_replies_request_exception(self, requests_mock: RequestsMocker): - """Test requests.exceptions.RequestException during get_replies.""" - manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) - message_id = "msg_req_exception" - requests_mock.get( - f"{BASE_URL}/api/v1/public/get-reply/{message_id}", - exc=requests.exceptions.Timeout("Connection timed out"), - ) - with pytest.raises(requests.exceptions.RequestException): - manager.get_replies(message_id=message_id) - - -def test_siren_client_send_message(mocker): - """Test SirenClient.send_message calls MessagingManager correctly.""" - client = SirenClient(api_key=API_KEY) - mock_messaging_manager_send = mocker.patch.object(client._messaging, "send_message") - mock_response = {"data": "success"} - mock_messaging_manager_send.return_value = mock_response - - template_name = "client_test_template" - channel = "EMAIL" - recipient_type = "direct_client" - recipient_value = "client@example.com" - template_variables = {"client_var": "client_val"} - - response = client.send_message( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - template_variables=template_variables, - ) - - assert response == mock_response - mock_messaging_manager_send.assert_called_once_with( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - template_variables=template_variables, - ) - - -def test_siren_client_get_replies(mocker): - """Test SirenClient.get_replies calls MessagingManager correctly.""" - client = SirenClient(api_key=API_KEY) - mock_messaging_manager_get_replies = mocker.patch.object( - client._messaging, "get_replies" - ) - mock_response = {"data": [{"text": "A reply"}]} - mock_messaging_manager_get_replies.return_value = mock_response - message_id = "msg_client_test" - - response = client.get_replies(message_id=message_id) - - mock_messaging_manager_get_replies.assert_called_once_with(message_id=message_id) - assert response == mock_response - - -def test_siren_client_get_message_status(mocker): - """Test SirenClient.get_message_status calls MessagingManager correctly.""" - client = SirenClient(api_key=API_KEY) - mock_messaging_manager_get_status = mocker.patch.object( - client._messaging, "get_message_status" - ) - mock_response = {"data": {"status": "SENT"}} - mock_messaging_manager_get_status.return_value = mock_response - message_id = "msg_client_status_test" - - response = client.get_message_status(message_id=message_id) - - mock_messaging_manager_get_status.assert_called_once_with(message_id=message_id) - assert response == mock_response + assert "Connection timeout" in str(exc_info.value) From 4ace86d05c7037e54ff7fd9b6ab1dcf02a3efae1 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 16:35:47 +0530 Subject: [PATCH 24/42] refactor webhook methods --- coverage.xml | 202 +++++++++++++++------------- examples/templates.py | 4 +- examples/webhooks.py | 77 +++-------- siren/client.py | 13 +- siren/managers/__init__.py | 3 +- siren/managers/webhooks.py | 61 +++++++++ siren/models/webhooks.py | 60 +++++++++ siren/webhooks.py | 69 ---------- tests/test_webhooks.py | 267 +++++++++++++++++++++++-------------- 9 files changed, 431 insertions(+), 325 deletions(-) create mode 100644 siren/managers/webhooks.py create mode 100644 siren/models/webhooks.py delete mode 100644 siren/webhooks.py diff --git a/coverage.xml b/coverage.xml index 67a0f32..bf4761d 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,7 +16,7 @@ - + @@ -28,51 +28,52 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -290,38 +291,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -393,7 +362,7 @@ - + @@ -401,7 +370,8 @@ - + + @@ -442,22 +412,22 @@ - - - - - - + + + + + - - + + - + + @@ -496,9 +466,25 @@ + + + + + + + + + + + + + + + + - + @@ -602,6 +588,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/templates.py b/examples/templates.py index e0a8dc8..bed9123 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -183,10 +183,10 @@ def run_get_channel_templates_example(client: SirenClient): siren_client = SirenClient(api_key=api_key) - # run_get_templates_example(siren_client) + run_get_templates_example(siren_client) # run_create_template_example(siren_client) # run_update_template_example(siren_client) # run_delete_template_example(siren_client) # run_publish_template_example(siren_client) # run_create_channel_templates_example(siren_client) - run_get_channel_templates_example(siren_client) + # run_get_channel_templates_example(siren_client) diff --git a/examples/webhooks.py b/examples/webhooks.py index f4658f5..890168a 100644 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -6,66 +6,35 @@ from dotenv import load_dotenv from siren import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError def configure_notifications_webhook_example(client: SirenClient, webhook_url: str): - """Demonstrates configuring the notifications webhook.""" - print("\nAttempting to configure notifications webhook...") + """Example of configuring the notifications webhook.""" try: - print(f"Configuring notifications webhook URL to: {webhook_url}") - response = client.configure_notifications_webhook(url=webhook_url) - print("Configure notifications webhook API response:") - print(response) - - if response and response.get("data") and response["data"].get("id"): - print( - f"Successfully configured notifications webhook. ID: {response['data']['id']}" - ) - if response["data"].get("webhookConfig"): - print(f"Configured URL: {response['data']['webhookConfig'].get('url')}") - print( - f"Verification Key: {response['data']['webhookConfig'].get('verificationKey')}" - ) - elif response and response.get("error"): - print( - f"Failed to configure notifications webhook. Error: {response['error']}" - ) - else: - print("Received an unexpected response structure.") - - except Exception as e: - print(f"An error occurred: {e}") + webhook_config = client.configure_notifications_webhook(url=webhook_url) + print( + f"Notifications webhook configured: {webhook_config.url} (key: {webhook_config.verification_key})" + ) + return webhook_config + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") def configure_inbound_message_webhook_example(client: SirenClient, webhook_url: str): - """Demonstrates configuring the inbound message webhook.""" - print("\nAttempting to configure inbound message webhook...") + """Example of configuring the inbound message webhook.""" try: - print(f"Configuring inbound message webhook URL to: {webhook_url}") - response = client.configure_inbound_message_webhook(url=webhook_url) - print("Configure inbound message webhook API response:") - print(response) - - if response and response.get("data") and response["data"].get("id"): - print( - f"Successfully configured inbound message webhook. ID: {response['data']['id']}" - ) - if response["data"].get("inboundWebhookConfig"): - print( - f"Configured URL: {response['data']['inboundWebhookConfig'].get('url')}" - ) - print( - f"Verification Key: {response['data']['inboundWebhookConfig'].get('verificationKey')}" - ) - elif response and response.get("error"): - print( - f"Failed to configure inbound message webhook. Error: {response['error']}" - ) - else: - print("Received an unexpected response structure.") - - except Exception as e: - print(f"An error occurred: {e}") + webhook_config = client.configure_inbound_message_webhook(url=webhook_url) + print( + f"Inbound webhook configured: {webhook_config.url} (key: {webhook_config.verification_key})" + ) + return webhook_config + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") if __name__ == "__main__": @@ -73,13 +42,11 @@ def configure_inbound_message_webhook_example(client: SirenClient, webhook_url: api_key = os.getenv("SIREN_API_KEY") if not api_key: print("Error: SIREN_API_KEY not found in environment variables or .env file.") - print("Please set it to run the example.") sys.exit(1) siren_client = SirenClient(api_key=api_key) - # IMPORTANT: Replace with your desired webhook URL - example_webhook_url = "https://siren-ai-test.example.com/siren" + example_webhook_url = "https://siren-ai-test.example.com/siren123" configure_notifications_webhook_example(siren_client, example_webhook_url) configure_inbound_message_webhook_example(siren_client, example_webhook_url) diff --git a/siren/client.py b/siren/client.py index ab376cf..5546181 100644 --- a/siren/client.py +++ b/siren/client.py @@ -6,10 +6,11 @@ from .managers.messaging import MessagingManager from .managers.users import UsersManager +from .managers.webhooks import WebhooksManager from .models.messaging import ReplyData from .models.user import User +from .models.webhooks import WebhookConfig from .templates import TemplatesManager -from .webhooks import WebhookManager from .workflows import WorkflowsManager @@ -33,7 +34,7 @@ def __init__(self, api_key: str): api_key=self.api_key, base_url=self.BASE_API_URL, # Note: WorkflowsManager uses /api/v2 internally ) - self._webhooks = WebhookManager( + self._webhooks = WebhooksManager( api_key=self.api_key, base_url=self.BASE_API_URL ) self._messaging = MessagingManager( @@ -414,7 +415,7 @@ def get_message_status(self, message_id: str) -> str: # Webhook Management - def configure_notifications_webhook(self, url: str) -> Dict[str, Any]: + def configure_notifications_webhook(self, url: str) -> WebhookConfig: """ Configure the webhook for outgoing notifications. @@ -422,11 +423,11 @@ def configure_notifications_webhook(self, url: str) -> Dict[str, Any]: url: The URL to be configured for the notifications webhook. Returns: - A dictionary containing the API response. + The webhook configuration object with URL, headers, and verification key. """ return self._webhooks.configure_notifications_webhook(url=url) - def configure_inbound_message_webhook(self, url: str) -> Dict[str, Any]: + def configure_inbound_message_webhook(self, url: str) -> WebhookConfig: """ Configure the webhook for inbound messages. @@ -434,6 +435,6 @@ def configure_inbound_message_webhook(self, url: str) -> Dict[str, Any]: url: The URL to be configured for the inbound message webhook. Returns: - A dictionary containing the API response. + The webhook configuration object with URL, headers, and verification key. """ return self._webhooks.configure_inbound_message_webhook(url=url) diff --git a/siren/managers/__init__.py b/siren/managers/__init__.py index 2ebc4d7..7347d46 100644 --- a/siren/managers/__init__.py +++ b/siren/managers/__init__.py @@ -3,5 +3,6 @@ from .base import BaseManager from .messaging import MessagingManager from .users import UsersManager +from .webhooks import WebhooksManager -__all__ = ["BaseManager", "UsersManager", "MessagingManager"] +__all__ = ["BaseManager", "UsersManager", "MessagingManager", "WebhooksManager"] diff --git a/siren/managers/webhooks.py b/siren/managers/webhooks.py new file mode 100644 index 0000000..f0e4ecc --- /dev/null +++ b/siren/managers/webhooks.py @@ -0,0 +1,61 @@ +"""Manages webhook-related API interactions for the Siren SDK.""" + +from ..models.webhooks import ( + InboundWebhookRequest, + NotificationsWebhookRequest, + WebhookConfig, + WebhookResponse, +) +from .base import BaseManager + + +class WebhooksManager(BaseManager): + """Manages webhook configuration operations.""" + + def configure_notifications_webhook(self, url: str) -> WebhookConfig: + """Configure the webhook for notifications. + + Args: + url: The URL to be configured for the notifications webhook. + + Returns: + The webhook configuration object. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + payload = {"webhook_config": {"url": url}} + + response = self._make_request( + method="PUT", + endpoint="/api/v1/public/webhooks", + request_model=NotificationsWebhookRequest, + response_model=WebhookResponse, + data=payload, + ) + return response.webhook_config + + def configure_inbound_message_webhook(self, url: str) -> WebhookConfig: + """Configure the webhook for inbound messages. + + Args: + url: The URL to be configured for the inbound message webhook. + + Returns: + The webhook configuration object. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + payload = {"inbound_webhook_config": {"url": url}} + + response = self._make_request( + method="PUT", + endpoint="/api/v1/public/webhooks", + request_model=InboundWebhookRequest, + response_model=WebhookResponse, + data=payload, + ) + return response.inbound_webhook_config diff --git a/siren/models/webhooks.py b/siren/models/webhooks.py new file mode 100644 index 0000000..c256e47 --- /dev/null +++ b/siren/models/webhooks.py @@ -0,0 +1,60 @@ +"""Webhook-related models for the Siren SDK.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .base import BaseAPIResponse + + +class WebhookConfig(BaseModel): + """Webhook configuration with URL, headers, and verification key.""" + + url: str + headers: List[Dict[str, Any]] = [] + verification_key: str = Field(alias="verificationKey") + + +class WebhookData(BaseModel): + """Webhook response data containing both webhook configurations.""" + + id: str + created_at: Optional[str] = Field(None, alias="createdAt") + updated_at: Optional[str] = Field(None, alias="updatedAt") + deleted_at: Optional[str] = Field(None, alias="deletedAt") + created_by: Optional[str] = Field(None, alias="createdBy") + updated_by: Optional[str] = Field(None, alias="updatedBy") + deleted_by: Optional[str] = Field(None, alias="deletedBy") + environment: Optional[str] = None + webhook_config: Optional[WebhookConfig] = Field(None, alias="webhookConfig") + inbound_webhook_config: Optional[WebhookConfig] = Field( + None, alias="inboundWebhookConfig" + ) + + +class NotificationsWebhookRequest(BaseModel): + """Request model for notifications webhook configuration.""" + + model_config = ConfigDict(validate_by_name=True) + + webhook_config: "WebhookConfigRequest" = Field(alias="webhookConfig") + + +class InboundWebhookRequest(BaseModel): + """Request model for inbound webhook configuration.""" + + model_config = ConfigDict(validate_by_name=True) + + inbound_webhook_config: "WebhookConfigRequest" = Field(alias="inboundWebhookConfig") + + +class WebhookConfigRequest(BaseModel): + """Webhook configuration for requests.""" + + url: str + + +class WebhookResponse(BaseAPIResponse[WebhookData]): + """API response for webhook operations.""" + + pass diff --git a/siren/webhooks.py b/siren/webhooks.py deleted file mode 100644 index 5798690..0000000 --- a/siren/webhooks.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Manages webhook-related API interactions for the Siren SDK.""" - -from typing import Any, Dict - -import requests - - -class WebhookManager: - """Manages webhook configuration operations.""" - - def __init__(self, api_key: str, base_url: str): - """ - Initialize the WebhookManager. - - Args: - api_key: The API key for authentication. - base_url: The base URL of the Siren API. - """ - self.api_key = api_key - self.base_url = f"{base_url}/api/v1/public" - - def _make_put_request( - self, endpoint: str, payload: Dict[str, Any] - ) -> Dict[str, Any]: - """Helper function to make PUT requests and handle common logic.""" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - try: - response = requests.put(endpoint, headers=headers, json=payload, timeout=10) - response.raise_for_status() # Raise an exception for HTTP errors - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def configure_notifications_webhook(self, url: str) -> Dict[str, Any]: - """ - Configure the webhook for notifications. - - Args: - url: The URL to be configured for the notifications webhook. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/webhooks" - payload = {"webhookConfig": {"url": url}} - return self._make_put_request(endpoint, payload) - - def configure_inbound_message_webhook(self, url: str) -> Dict[str, Any]: - """ - Configure the webhook for inbound messages. - - Args: - url: The URL to be configured for the inbound message webhook. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/webhooks" - payload = {"inboundWebhookConfig": {"url": url}} - return self._make_put_request(endpoint, payload) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 0eb9a51..868216f 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,142 +1,207 @@ -"""Unit tests for the webhook module of the Siren SDK.""" +"""Unit tests for the webhooks manager using BaseManager.""" + +from unittest.mock import Mock, patch import pytest -import requests -from requests_mock import Mocker as RequestsMocker from siren.client import SirenClient -from siren.webhooks import WebhookManager +from siren.exceptions import SirenAPIError, SirenSDKError +from siren.managers.webhooks import WebhooksManager +from siren.models.webhooks import WebhookConfig API_KEY = "test_api_key" BASE_URL = "https://api.dev.trysiren.io" WEBHOOK_URL = "https://example.com/webhook" -class TestWebhookManager: - """Tests for the WebhookManager class.""" +def mock_response(status_code: int, json_data: dict = None): + """Helper function to create a mock HTTP response.""" + mock_resp = Mock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_data if json_data is not None else {} + return mock_resp + + +class TestWebhooksManager: + """Tests for the WebhooksManager class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.manager = WebhooksManager(api_key=API_KEY, base_url=BASE_URL) - def test_configure_notifications_webhook_success( - self, requests_mock: RequestsMocker - ): + @patch("siren.managers.base.requests.request") + def test_configure_notifications_webhook_success(self, mock_request): """Test successful configuration of notifications webhook.""" - manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) - expected_response = { - "data": {"id": "wh_123", "webhookConfig": {"url": WEBHOOK_URL}} + # Mock successful API response + mock_api_response = { + "data": { + "id": "wh_123", + "webhookConfig": { + "url": WEBHOOK_URL, + "headers": [], + "verificationKey": "test_key_123", + }, + }, + "error": None, } - requests_mock.put( - f"{BASE_URL}/api/v1/public/webhooks", - json=expected_response, - status_code=200, + mock_request.return_value = mock_response(200, mock_api_response) + + # Call the method + result = self.manager.configure_notifications_webhook(url=WEBHOOK_URL) + + # Verify result is WebhookConfig object + assert isinstance(result, WebhookConfig) + assert result.url == WEBHOOK_URL + assert result.verification_key == "test_key_123" + assert result.headers == [] + + # Verify request was made correctly with BaseManager + mock_request.assert_called_once_with( + method="PUT", + url=f"{BASE_URL}/api/v1/public/webhooks", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + }, + json={"webhookConfig": {"url": WEBHOOK_URL}}, + params=None, + timeout=10, ) - response = manager.configure_notifications_webhook(url=WEBHOOK_URL) - assert response == expected_response - assert requests_mock.last_request is not None - assert requests_mock.last_request.json() == { - "webhookConfig": {"url": WEBHOOK_URL} - } - def test_configure_inbound_message_webhook_success( - self, requests_mock: RequestsMocker - ): + @patch("siren.managers.base.requests.request") + def test_configure_inbound_message_webhook_success(self, mock_request): """Test successful configuration of inbound message webhook.""" - manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) - expected_response = { - "data": {"id": "wh_456", "inboundWebhookConfig": {"url": WEBHOOK_URL}} + # Mock successful API response + mock_api_response = { + "data": { + "id": "wh_456", + "inboundWebhookConfig": { + "url": WEBHOOK_URL, + "headers": [], + "verificationKey": "test_key_456", + }, + }, + "error": None, } - requests_mock.put( - f"{BASE_URL}/api/v1/public/webhooks", - json=expected_response, - status_code=200, + mock_request.return_value = mock_response(200, mock_api_response) + + # Call the method + result = self.manager.configure_inbound_message_webhook(url=WEBHOOK_URL) + + # Verify result is WebhookConfig object + assert isinstance(result, WebhookConfig) + assert result.url == WEBHOOK_URL + assert result.verification_key == "test_key_456" + assert result.headers == [] + + # Verify request was made correctly with BaseManager + mock_request.assert_called_once_with( + method="PUT", + url=f"{BASE_URL}/api/v1/public/webhooks", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + }, + json={"inboundWebhookConfig": {"url": WEBHOOK_URL}}, + params=None, + timeout=10, ) - response = manager.configure_inbound_message_webhook(url=WEBHOOK_URL) - assert response == expected_response - assert requests_mock.last_request is not None - assert requests_mock.last_request.json() == { - "inboundWebhookConfig": {"url": WEBHOOK_URL} - } @pytest.mark.parametrize( - "method_name", - ["configure_notifications_webhook", "configure_inbound_message_webhook"], + "method_name,config_key", + [ + ("configure_notifications_webhook", "webhookConfig"), + ("configure_inbound_message_webhook", "inboundWebhookConfig"), + ], ) - def test_webhook_http_error_json_response( - self, requests_mock: RequestsMocker, method_name: str - ): - """Test HTTP error with JSON response during webhook configuration.""" - manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) - error_response = {"error": "Bad Request", "message": "Invalid URL"} - requests_mock.put( - f"{BASE_URL}/api/v1/public/webhooks", - json=error_response, - status_code=400, - ) - method_to_call = getattr(manager, method_name) - response = method_to_call(url=WEBHOOK_URL) - assert response == error_response + @patch("siren.managers.base.requests.request") + def test_webhook_api_error(self, mock_request, method_name: str, config_key: str): + """Test API error during webhook configuration.""" + # Mock API error response + mock_api_error = { + "data": None, + "error": { + "errorCode": "INVALID_REQUEST", + "message": "Invalid webhook URL format", + }, + } + mock_request.return_value = mock_response(400, mock_api_error) - @pytest.mark.parametrize( - "method_name", - ["configure_notifications_webhook", "configure_inbound_message_webhook"], - ) - def test_webhook_http_error_no_json_response( - self, requests_mock: RequestsMocker, method_name: str - ): - """Test HTTP error without JSON response during webhook configuration.""" - manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) - requests_mock.put( - f"{BASE_URL}/api/v1/public/webhooks", - text="Server Error", - status_code=500, - ) - method_to_call = getattr(manager, method_name) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: + method_to_call = getattr(self.manager, method_name) + + with pytest.raises(SirenAPIError) as exc_info: method_to_call(url=WEBHOOK_URL) - assert excinfo.value.response.status_code == 500 - assert excinfo.value.response.text == "Server Error" + + assert exc_info.value.error_code == "INVALID_REQUEST" + assert "Invalid webhook URL format" in exc_info.value.api_message + + # Verify correct payload was sent + expected_json = {config_key: {"url": WEBHOOK_URL}} + mock_request.assert_called_once_with( + method="PUT", + url=f"{BASE_URL}/api/v1/public/webhooks", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + }, + json=expected_json, + params=None, + timeout=10, + ) @pytest.mark.parametrize( "method_name", ["configure_notifications_webhook", "configure_inbound_message_webhook"], ) - def test_webhook_request_exception( - self, requests_mock: RequestsMocker, method_name: str - ): - """Test requests.exceptions.RequestException during webhook configuration.""" - manager = WebhookManager(api_key=API_KEY, base_url=BASE_URL) - requests_mock.put( - f"{BASE_URL}/api/v1/public/webhooks", - exc=requests.exceptions.Timeout("Connection timed out"), - ) - method_to_call = getattr(manager, method_name) - with pytest.raises(requests.exceptions.RequestException): + @patch("siren.managers.base.requests.request") + def test_webhook_network_error(self, mock_request, method_name: str): + """Test network error during webhook configuration.""" + from requests.exceptions import ConnectionError + + # Mock network error + mock_request.side_effect = ConnectionError("Connection failed") + + method_to_call = getattr(self.manager, method_name) + + with pytest.raises(SirenSDKError) as exc_info: method_to_call(url=WEBHOOK_URL) + assert "Connection failed" in exc_info.value.message -def test_siren_client_configure_notifications_webhook(mocker): - """Test SirenClient.configure_notifications_webhook calls WebhookManager correctly.""" + +def test_siren_client_configure_notifications_webhook(): + """Test SirenClient.configure_notifications_webhook calls WebhooksManager correctly.""" client = SirenClient(api_key=API_KEY) - mock_webhook_manager_method = mocker.patch.object( + + with patch.object( client._webhooks, "configure_notifications_webhook" - ) - expected_response = {"data": "success"} - mock_webhook_manager_method.return_value = expected_response + ) as mock_method: + # Create WebhookConfig using model_validate to handle aliases properly + mock_config = WebhookConfig.model_validate( + {"url": WEBHOOK_URL, "headers": [], "verificationKey": "test_key_123"} + ) + mock_method.return_value = mock_config - response = client.configure_notifications_webhook(url=WEBHOOK_URL) + result = client.configure_notifications_webhook(url=WEBHOOK_URL) - mock_webhook_manager_method.assert_called_once_with(url=WEBHOOK_URL) - assert response == expected_response + mock_method.assert_called_once_with(url=WEBHOOK_URL) + assert result == mock_config -def test_siren_client_configure_inbound_message_webhook(mocker): - """Test SirenClient.configure_inbound_message_webhook calls WebhookManager correctly.""" +def test_siren_client_configure_inbound_message_webhook(): + """Test SirenClient.configure_inbound_message_webhook calls WebhooksManager correctly.""" client = SirenClient(api_key=API_KEY) - mock_webhook_manager_method = mocker.patch.object( + + with patch.object( client._webhooks, "configure_inbound_message_webhook" - ) - expected_response = {"data": "success_inbound"} - mock_webhook_manager_method.return_value = expected_response + ) as mock_method: + # Create WebhookConfig using model_validate to handle aliases properly + mock_config = WebhookConfig.model_validate( + {"url": WEBHOOK_URL, "headers": [], "verificationKey": "test_key_456"} + ) + mock_method.return_value = mock_config - response = client.configure_inbound_message_webhook(url=WEBHOOK_URL) + result = client.configure_inbound_message_webhook(url=WEBHOOK_URL) - mock_webhook_manager_method.assert_called_once_with(url=WEBHOOK_URL) - assert response == expected_response + mock_method.assert_called_once_with(url=WEBHOOK_URL) + assert result == mock_config From f816fa7cff553b7952f9cedb004d253a19c3dc95 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 20:58:51 +0530 Subject: [PATCH 25/42] refactor: migrate templates to use basemanager --- .cursor/rules/project_context.mdc | 73 +- coverage.xml | 401 +++++---- examples/templates.py | 287 +++--- siren/client.py | 58 +- siren/managers/__init__.py | 9 +- siren/managers/templates.py | 226 +++++ siren/managers/users.py | 3 +- siren/messaging.py | 131 --- siren/models/base.py | 10 + siren/models/templates.py | 139 +++ siren/templates.py | 288 ------- tests/test_client.py | 4 +- tests/test_templates.py | 1342 +++++++++++++---------------- 13 files changed, 1352 insertions(+), 1619 deletions(-) create mode 100644 siren/managers/templates.py delete mode 100644 siren/messaging.py create mode 100644 siren/models/templates.py delete mode 100644 siren/templates.py diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index a5267f3..9b36c7f 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -11,11 +11,11 @@ Official Python SDK for the [Siren notification platform](mdc:https:/docs.trysir ## Key Features / Functional Modules -- **Templates** - Create, update, delete, publish templates and channel configurations → `siren/templates.py` -- **Workflows** - Trigger single/bulk workflows and scheduling → `siren/workflows.py` +- **Templates** - Create, update, delete, publish templates and channel configurations → `siren/managers/templates.py` - **Users** - Add, update, delete users → `siren/managers/users.py` -- **Messaging** - Send messages, get replies, track status → `siren/messaging.py` -- **Webhooks** - Configure notification and inbound webhooks → `siren/webhooks.py` +- **Messaging** - Send messages, get replies, track status → `siren/managers/messaging.py` +- **Webhooks** - Configure notification and inbound webhooks → `siren/managers/webhooks.py` +- **Workflows** - Trigger single/bulk workflows and scheduling → `siren/workflows.py` - **Client** - Unified API entry point → `siren/client.py` ## Codebase Structure Overview @@ -24,17 +24,20 @@ Official Python SDK for the [Siren notification platform](mdc:https:/docs.trysir siren-ai/ ├── siren/ # Main SDK package │ ├── client.py # Main SirenClient - unified API entry point -│ ├── templates.py # TemplatesManager - template operations -│ ├── workflows.py # WorkflowsManager - workflow operations -│ ├── messaging.py # MessagingManager - message operations -│ ├── webhooks.py # WebhookManager - webhook configuration │ ├── exceptions.py # Custom exception classes (SirenAPIError, SirenSDKError) -│ ├── managers/ # Manager base classes and implementations +│ ├── workflows.py # WorkflowsManager - workflow operations (legacy direct HTTP) +│ ├── managers/ # BaseManager implementations (core pattern) │ │ ├── base.py # BaseManager - shared HTTP/error handling -│ │ └── users.py # UsersManager - user management +│ │ ├── templates.py # TemplatesManager - template operations +│ │ ├── users.py # UsersManager - user management +│ │ ├── messaging.py # MessagingManager - message operations +│ │ └── webhooks.py # WebhooksManager - webhook configuration │ └── models/ # Pydantic data models -│ ├── base.py # Base response models and error handling -│ └── user.py # User-specific models +│ ├── base.py # Base response models and common patterns +│ ├── templates.py # Template-specific models +│ ├── user.py # User-specific models +│ ├── messaging.py # Messaging models +│ └── webhooks.py # Webhook models ├── tests/ # Comprehensive test suite with ~95% coverage ├── examples/ # Usage examples for each module ├── pyproject.toml # Project configuration, dependencies, tools @@ -49,6 +52,12 @@ siren-ai/ - **Models** (Pydantic) - Request/response validation, field aliasing (snake_case ↔ camelCase) - **Exceptions** - `SirenAPIError` (API errors: 400/401/404) vs `SirenSDKError` (SDK issues: network/validation) +**BaseManager Pattern** (Core Architecture): +- All managers inherit from `BaseManager` for consistent HTTP handling +- Requires both `request_model` and `response_model` for JSON operations +- Automatic Pydantic validation, error handling, and response parsing +- Common patterns: `DeleteResponse[None]` for 204 responses, flexible models with optional fields + **Request Flow**: Client → Manager → HTTP Request → API → Response → Model → Client - Managers prepare requests with Pydantic validation → HTTP to Siren API → Responses parsed through models → Errors become structured exceptions @@ -57,63 +66,37 @@ siren-ai/ - **Authentication**: Bearer token in `Authorization` header - **Status Handling**: Explicit `if status_code == 200` checks instead of `response.ok` - **API Versioning**: Templates/Users/Messaging/Webhooks use `/api/v1/public/`, Workflows use `/api/v2/` -**Base URL**: `https://api.dev.trysiren.io` (TODO: auto-select based on API key type) +- **Base URL**: `https://api.dev.trysiren.io` (TODO: auto-select based on API key type) ## Tech Stack **Core**: Python 3.8+, `requests`, `pydantic[email]` **Dev Tools**: `pytest` + mocking, `ruff`, `pyright`, `pre-commit`, `uv` -## Dev & Build Commands - -```bash -# Setup development environment -uv venv && source .venv/bin/activate -uv pip install -e ".[dev]" - -# Code quality -uv run ruff check . # Lint code -uv run ruff format . # Format code -uv run pyright # Type checking -uv run pre-commit run --all-files # Run all pre-commit hooks - -# Testing -uv run pytest # Run all tests -uv run pytest --cov=siren # Run tests with coverage -uv run pytest tests/test_templates.py -v # Run specific test module - -# Build and publish -python -m build # Build wheel and sdist -twine upload dist/* # Upload to PyPI -``` - - - ## Testing **Strategy**: `requests-mock` with realistic API data **Organization**: One test file per manager, shared `client` fixture +**Philosophy**: SDK testing focuses on request formatting, response parsing, error propagation - not API business logic ## Key Files - **`siren/client.py`** - Main client interface -- **`siren/managers/base.py`** - BaseManager with unified HTTP/error handling -- **`siren/templates.py`** - Most complex manager, full patterns +- **`siren/managers/base.py`** - BaseManager with unified HTTP/error handling (core pattern) +- **`siren/managers/templates.py`** - Most complex manager, full BaseManager patterns - **`siren/models/base.py`** - Core models and error handling - **`siren/exceptions.py`** - Exception patterns ## Gotchas -**Mixed Return Types**: Templates/Workflows/Messaging return `Dict[str, Any]`, Users return `User` models **Field Serialization**: Always use `by_alias=True` when calling `model_dump()` +**BaseManager Requirements**: Both request_model and response_model needed for JSON operations ## TODO / Future Areas -**Configuration Improvements**: -- Auto-select API URL based on API key type (dev/prod) or environment variable - **Architecture Enhancements**: -- Consider standardizing return types across all managers (currently mixed Dict vs Models) +- Migrate WorkflowsManager to BaseManager pattern +- Auto-select API URL based on API key type (dev/prod) or environment variable - Add retry logic for transient network failures - Add request/response logging capabilities diff --git a/coverage.xml b/coverage.xml index bf4761d..a2fa9bb 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,7 +16,7 @@ - + @@ -29,51 +29,52 @@ - - - - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - + + + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -158,139 +159,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -362,7 +230,7 @@ - + @@ -371,7 +239,8 @@ - + + @@ -451,19 +320,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - + + + @@ -484,13 +406,13 @@ - + - + @@ -512,6 +434,8 @@ + + @@ -548,6 +472,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -592,14 +586,14 @@ - - - - + + + + - - + + @@ -609,17 +603,16 @@ - - - - - - - - - - + + + + + + + + + diff --git a/examples/templates.py b/examples/templates.py index bed9123..fcab153 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -1,192 +1,153 @@ -"""Examples for template-related operations using the Siren SDK.""" +"""Example script demonstrating template methods using SirenClient.""" -import json import os import sys -from dotenv import load_dotenv +from siren.client import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError -# Ensure the 'siren' package in the parent directory can be imported: +# Allow running from examples directory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from siren import SirenClient - -def run_get_templates_example(client: SirenClient): - """Runs the example for fetching templates.""" - print("--- Fetching Templates ---") +def get_templates_example(client: SirenClient) -> None: + """Example of getting templates.""" try: - templates_response = client.get_templates( - page=0, - size=2, # Get first 2 templates for brevity - ) - if templates_response and templates_response.get("error") is None: - print("Successfully fetched templates:") - print(json.dumps(templates_response, indent=2)) - except Exception as e: - print(f"Error fetching templates: {e}") - - -def run_create_template_example(client: SirenClient): - """Runs the example for creating a template.""" - print("\n--- Creating a Template ---") - new_template_payload = { - "name": "Sample5", - "description": "A simple template created via the examples/templates.py script.", - "tagNames": ["sdk-example", "template-ops"], - "variables": [{"name": "user_name", "defaultValue": "Guest"}], - "configurations": { - "EMAIL": { - "subject": "SDK Test Email for {{user_name}} from templates.py", - "channel": "EMAIL", - "body": "

Hello {{user_name}}, this is a test from examples/templates.py!

", - "isRawHTML": True, - "isPlainText": False, - } - }, - } - try: - created_template_response = client.create_template(new_template_payload) - if created_template_response and created_template_response.get("error") is None: - print("Successfully created template:") - print(json.dumps(created_template_response, indent=2)) - except Exception as e: - print(f"Error creating template: {e}") - - -def run_update_template_example(client: SirenClient): - """Runs the example for updating a template.""" - print("\n--- Updating a Template ---") - template_id_to_update = "dc58f20d-bad1-4ffd-8f92-34682397100f" - update_payload = { - "name": "Updated_SDK_Example_Template", - "description": "This template was updated by the examples/templates.py script.", - "tagNames": ["sdk-example", "update-op"], - "variables": [{"name": "customer_name", "defaultValue": "Valued Customer"}], - "configurations": { - "EMAIL": { - "subject": "Updated Subject for {{customer_name}}", - "body": "

Hello {{customer_name}}, your template has been updated!

", - } - }, - } + templates = client.get_templates(page=0, size=2) + print(f"Retrieved {len(templates)} templates") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") - try: - updated_template_response = client.update_template( - template_id_to_update, update_payload - ) - if updated_template_response and updated_template_response.get("error") is None: - print(f"Successfully updated template '{template_id_to_update}':") - print(json.dumps(updated_template_response, indent=2)) - except Exception as e: - print(f"Error updating template '{template_id_to_update}': {e}") +def create_template_example(client: SirenClient) -> str: + """Example of creating a template.""" + import time -def run_delete_template_example(client: SirenClient): - """Runs the example for deleting a template.""" - print("\n--- Deleting a Template ---") - template_id_to_delete = "b5d4cdf8-a46a-4867-aa02-c7551d3fe747" + timestamp = int(time.time()) try: - delete_response = client.delete_template(template_id_to_delete) - if delete_response and delete_response.get("status") == "success": - print(f"Successfully deleted template '{template_id_to_delete}':") - print(json.dumps(delete_response, indent=2)) - except Exception as e: - print(f"Error deleting template '{template_id_to_delete}': {e}") - + created = client.create_template( + name=f"SDK_Example_Template_{timestamp}", + description="Test template from SDK", + tag_names=["sdk-test", "example"], + variables=[{"name": "user_name", "defaultValue": "Guest"}], + configurations={ + "EMAIL": { + "subject": "Hello {{user_name}}!", + "channel": "EMAIL", + "body": "

Welcome {{user_name}}!

", + "isRawHTML": True, + "isPlainText": False, + } + }, + ) + print(f"Created template: {created.template_id}") + return created.template_id + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + return None + except SirenSDKError as e: + print(f"SDK Error: {e.message}") + return None + + +def update_template_example(client: SirenClient, template_id: str) -> None: + """Example of updating a template.""" + try: + updated = client.update_template( + template_id, + name="Updated_SDK_Example", + description="Updated description from SDK", + tag_names=["updated", "sdk-test"], + ) + print(f"Updated template: {updated.id}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") -def run_publish_template_example(client: SirenClient): - """Runs the example for publishing a template.""" - print("\n--- Publishing a Template ---") - template_id_to_publish = "11921404-4517-48b7-82ee-fcdcf8f9c03b" +def publish_template_example(client: SirenClient, template_id: str): + """Example of publishing a template.""" try: - publish_response = client.publish_template(template_id_to_publish) - if publish_response and publish_response.get("error") is None: - print(f"Successfully published template '{template_id_to_publish}':") - print(json.dumps(publish_response, indent=2)) - except Exception as e: - print(f"Error publishing template '{template_id_to_publish}': {e}") - - -def run_create_channel_templates_example(client: SirenClient): - """Runs the example for creating channel templates for a template.""" - print("\n--- Creating Channel Templates for a Template ---") - template_id_for_channel_templates = ( - "cacf1503-8283-42a8-b5fd-27d85054fb99" # Replace with an actual template ID - ) - channel_templates_payload = { - "SMS": { - "body": "Exciting discounts are ON", - "channel": "SMS", - "isFlash": False, - "isUnicode": False, - }, - "EMAIL": { - "subject": "Exciting discount at our store online", - "channel": "EMAIL", - "body": "

Hello from Siren SDK! This is an email channel configuration.

", - "attachments": [], - "isRawHTML": True, - "isPlainText": False, - }, - } - + published = client.publish_template(template_id) + print(f"Published template: {published.id}") + return published + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + return None + except SirenSDKError as e: + print(f"SDK Error: {e.message}") + return None + + +def create_channel_templates_example(client: SirenClient, template_id: str) -> None: + """Example of creating channel templates for a template.""" try: - response = client.create_channel_templates( - template_id_for_channel_templates, channel_templates_payload - ) - if response and response.get("error") is None: - print( - f"Successfully created/updated channel templates for template '{template_id_for_channel_templates}':" - ) - print(json.dumps(response, indent=2)) - except Exception as e: - print( - f"Error creating/updating channel templates for template '{template_id_for_channel_templates}': {e}" + result = client.create_channel_templates( + template_id, + SMS={ + "body": "Hello {{user_name}}! This is from SDK.", + "channel": "SMS", + "isFlash": False, + "isUnicode": False, + }, + EMAIL={ + "subject": "Welcome {{user_name}}!", + "channel": "EMAIL", + "body": "

Hello {{user_name}}, welcome from SDK!

", + "isRawHTML": True, + "isPlainText": False, + }, ) + print(f"Created {len(result)} channel templates") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") -def run_get_channel_templates_example(client: SirenClient): - """Runs the example for fetching channel templates for a template version.""" - print("\n--- Fetching Channel Templates for a Version ---") - version_id_to_fetch = "9138125c-d242-4b17-ae0e-16ade9d06568" - +def get_channel_templates_example(client: SirenClient, version_id: str) -> None: + """Example of getting channel templates for a template version.""" try: - response = client.get_channel_templates( - version_id=version_id_to_fetch, - page=0, # Optional: get the first page - size=5, # Optional: get up to 5 channel templates - ) - if response and response.get("error") is None: - print( - f"Successfully fetched channel templates for version '{version_id_to_fetch}':" - ) - print(json.dumps(response, indent=2)) - except Exception as e: - print( - f"Error fetching channel templates for version '{version_id_to_fetch}': {e}" - ) + result = client.get_channel_templates(version_id, page=0, size=5) + print(f"Retrieved {len(result)} channel templates") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") -if __name__ == "__main__": - load_dotenv() +def delete_template_example(client: SirenClient, template_id: str) -> None: + """Example of deleting a template.""" + try: + result = client.delete_template(template_id) + if result: + print(f"Successfully deleted template: {template_id}") + else: + print(f"Failed to delete template: {template_id}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") - api_key = os.getenv("SIREN_API_KEY") +if __name__ == "__main__": + api_key = os.environ.get("SIREN_API_KEY") if not api_key: - print( - "Error: SIREN_API_KEY is not set. Please check your .env file or environment variables." - ) + print("Error: SIREN_API_KEY environment variable not set.") sys.exit(1) - siren_client = SirenClient(api_key=api_key) - - run_get_templates_example(siren_client) - # run_create_template_example(siren_client) - # run_update_template_example(siren_client) - # run_delete_template_example(siren_client) - # run_publish_template_example(siren_client) - # run_create_channel_templates_example(siren_client) - # run_get_channel_templates_example(siren_client) + client = SirenClient(api_key) + + get_templates_example(client) + template_id = create_template_example(client) + if template_id: + update_template_example(client, template_id) + create_channel_templates_example(client, template_id) + published = publish_template_example(client, template_id) + if published and published.published_version: + get_channel_templates_example(client, published.published_version.id) + delete_template_example(client, template_id) diff --git a/siren/client.py b/siren/client.py index 5546181..972ba16 100644 --- a/siren/client.py +++ b/siren/client.py @@ -5,12 +5,13 @@ from pydantic import EmailStr from .managers.messaging import MessagingManager +from .managers.templates import TemplatesManager from .managers.users import UsersManager from .managers.webhooks import WebhooksManager from .models.messaging import ReplyData +from .models.templates import ChannelTemplate, CreatedTemplate, Template from .models.user import User from .models.webhooks import WebhookConfig -from .templates import TemplatesManager from .workflows import WorkflowsManager @@ -49,7 +50,7 @@ def get_templates( sort: Optional[str] = None, page: Optional[int] = None, size: Optional[int] = None, - ) -> Dict[str, Any]: + ) -> List[Template]: """Fetch templates. Args: @@ -60,7 +61,7 @@ def get_templates( size: Page size. Returns: - A dictionary containing the API response. + List[Template]: A list of Template models. """ return self._templates.get_templates( tag_names=tag_names, @@ -70,72 +71,67 @@ def get_templates( size=size, ) - def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: + def create_template(self, **template_data) -> CreatedTemplate: """Create a new template. Args: - template_data: A dictionary containing the template details. + **template_data: Template attributes (name, description, tag_names, variables, configurations). Returns: - A dictionary containing the API response. + CreatedTemplate: A CreatedTemplate model representing the created template. """ - return self._templates.create_template(template_data=template_data) + return self._templates.create_template(**template_data) - def update_template( - self, template_id: str, template_data: Dict[str, Any] - ) -> Dict[str, Any]: + def update_template(self, template_id: str, **template_data) -> Template: """Update an existing template. Args: template_id: The ID of the template to update. - template_data: A dictionary containing the template details to update. + **template_data: Template attributes to update (name, description, tag_names, variables). Returns: - A dictionary containing the API response. + Template: A Template model representing the updated template. """ - return self._templates.update_template( - template_id=template_id, template_data=template_data - ) + return self._templates.update_template(template_id, **template_data) - def delete_template(self, template_id: str) -> Dict[str, Any]: + def delete_template(self, template_id: str) -> bool: """Delete an existing template. Args: template_id: The ID of the template to delete. Returns: - A dictionary containing the API response. + bool: True if deletion was successful. """ - return self._templates.delete_template(template_id=template_id) + return self._templates.delete_template(template_id) - def publish_template(self, template_id: str) -> Dict[str, Any]: + def publish_template(self, template_id: str) -> Template: """Publish an existing template. Args: template_id: The ID of the template to publish. Returns: - A dictionary containing the API response. + Template: A Template model representing the published template. """ - return self._templates.publish_template(template_id=template_id) + return self._templates.publish_template(template_id) def create_channel_templates( - self, - template_id: str, - channel_templates: Dict[str, Any], - ) -> Dict[str, Any]: + self, template_id: str, **channel_templates_data + ) -> List[ChannelTemplate]: """Create or update channel templates for a specific template. Args: template_id: The ID of the template for which to create channel templates. - channel_templates: A dictionary where keys are channel names (e.g., "EMAIL", "SMS") - and values are the channel-specific template objects. + **channel_templates_data: Channel templates configuration where keys are + channel names (e.g., "EMAIL", "SMS") and values + are the channel-specific template objects. Returns: - A dictionary containing the API response. + List[ChannelTemplate]: List of created channel template objects. """ return self._templates.create_channel_templates( - template_id=template_id, channel_templates=channel_templates + template_id, **channel_templates_data ) def get_channel_templates( @@ -146,7 +142,7 @@ def get_channel_templates( sort: Optional[str] = None, page: Optional[int] = None, size: Optional[int] = None, - ) -> Dict[str, Any]: + ) -> List[ChannelTemplate]: """Get channel templates for a specific template version. Args: @@ -158,7 +154,7 @@ def get_channel_templates( size: Page size. Returns: - A dictionary containing the API response. + List[ChannelTemplate]: List of channel template objects. """ return self._templates.get_channel_templates( version_id=version_id, diff --git a/siren/managers/__init__.py b/siren/managers/__init__.py index 7347d46..6faeb6c 100644 --- a/siren/managers/__init__.py +++ b/siren/managers/__init__.py @@ -2,7 +2,14 @@ from .base import BaseManager from .messaging import MessagingManager +from .templates import TemplatesManager from .users import UsersManager from .webhooks import WebhooksManager -__all__ = ["BaseManager", "UsersManager", "MessagingManager", "WebhooksManager"] +__all__ = [ + "BaseManager", + "TemplatesManager", + "UsersManager", + "MessagingManager", + "WebhooksManager", +] diff --git a/siren/managers/templates.py b/siren/managers/templates.py new file mode 100644 index 0000000..e0056ee --- /dev/null +++ b/siren/managers/templates.py @@ -0,0 +1,226 @@ +"""New templates manager using BaseManager architecture.""" + +from typing import List, Optional + +from ..models.base import DeleteResponse +from ..models.templates import ( + ChannelTemplate, + CreateChannelTemplatesRequest, + CreateChannelTemplatesResponse, + CreatedTemplate, + CreateTemplateRequest, + CreateTemplateResponse, + GetChannelTemplatesResponse, + PublishTemplateResponse, + Template, + TemplateListResponse, + UpdateTemplateRequest, + UpdateTemplateResponse, +) +from .base import BaseManager + + +class TemplatesManager(BaseManager): + """Manager for template operations using BaseManager.""" + + def get_templates( + self, + tag_names: Optional[str] = None, + search: Optional[str] = None, + sort: Optional[str] = None, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> List[Template]: + """Fetch templates. + + Args: + tag_names: Filter by tag names. + search: Search by field. + sort: Sort by field. + page: Page number. + size: Page size. + + Returns: + List[Template]: A list of Template models. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + params = {} + if tag_names is not None: + params["tagNames"] = tag_names + if search is not None: + params["search"] = search + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if size is not None: + params["size"] = size + + response = self._make_request( + method="GET", + endpoint="/api/v1/public/template", + response_model=TemplateListResponse, + params=params, + ) + return response + + def update_template(self, template_id: str, **template_data) -> Template: + """Update an existing template. + + Args: + template_id: The ID of the template to update. + **template_data: Template attributes matching the UpdateTemplateRequest model fields. + + Returns: + Template: A Template model representing the updated template. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="PUT", + endpoint=f"/api/v1/public/template/{template_id}", + request_model=UpdateTemplateRequest, + response_model=UpdateTemplateResponse, + data=template_data, + ) + return response + + def create_template(self, **template_data) -> CreatedTemplate: + """Create a new template. + + Args: + **template_data: Template attributes matching the CreateTemplateRequest model fields. + + Returns: + CreatedTemplate: A CreatedTemplate model representing the created template. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="POST", + endpoint="/api/v1/public/template", + request_model=CreateTemplateRequest, + response_model=CreateTemplateResponse, + data=template_data, + ) + return response + + def delete_template(self, template_id: str) -> bool: + """Delete a template. + + Args: + template_id: The ID of the template to delete. + + Returns: + bool: True if deletion was successful. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + return self._make_request( + method="DELETE", + endpoint=f"/api/v1/public/template/{template_id}", + response_model=DeleteResponse, + expected_status=204, + ) + + def publish_template(self, template_id: str) -> Template: + """Publish a template. + + Args: + template_id: The ID of the template to publish. + + Returns: + Template: A Template model representing the published template. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="PATCH", + endpoint=f"/api/v1/public/template/{template_id}/publish", + response_model=PublishTemplateResponse, + ) + return response + + def create_channel_templates( + self, template_id: str, **channel_templates_data + ) -> List[ChannelTemplate]: + """Create or update channel templates for a specific template. + + Args: + template_id: The ID of the template for which to create channel templates. + **channel_templates_data: Channel templates configuration where keys are + channel names (e.g., "EMAIL", "SMS") and values + are the channel-specific template objects. + + Returns: + List[ChannelTemplate]: List of created channel template objects. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="POST", + endpoint=f"/api/v1/public/template/{template_id}/channel-templates", + request_model=CreateChannelTemplatesRequest, + response_model=CreateChannelTemplatesResponse, + data=channel_templates_data, + ) + return response + + def get_channel_templates( + self, + version_id: str, + channel: Optional[str] = None, + search: Optional[str] = None, + sort: Optional[str] = None, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> List[ChannelTemplate]: + """Fetch channel templates for a specific template version. + + Args: + version_id: The ID of the template version for which to fetch channel templates. + channel: Filter by channel type (e.g., "EMAIL", "SMS"). + search: Search term to filter channel templates. + sort: Sort by field. + page: Page number. + size: Page size. + + Returns: + List[ChannelTemplate]: List of channel template objects. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + params = {} + if channel is not None: + params["channel"] = channel + if search is not None: + params["search"] = search + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if size is not None: + params["size"] = size + + response = self._make_request( + method="GET", + endpoint=f"/api/v1/public/template/versions/{version_id}/channel-templates", + response_model=GetChannelTemplatesResponse, + params=params, + ) + return response diff --git a/siren/managers/users.py b/siren/managers/users.py index fe3fcba..e5bbbbc 100644 --- a/siren/managers/users.py +++ b/siren/managers/users.py @@ -1,5 +1,6 @@ """Manages user-related operations for the Siren API client.""" +from ..models.base import DeleteResponse from ..models.user import User, UserAPIResponse, UserRequest from .base import BaseManager @@ -67,6 +68,6 @@ def delete_user(self, unique_id: str) -> bool: return self._make_request( method="DELETE", endpoint=f"/api/v1/public/users/{unique_id}", - response_model=UserAPIResponse, + response_model=DeleteResponse, expected_status=204, ) diff --git a/siren/messaging.py b/siren/messaging.py deleted file mode 100644 index 8b97465..0000000 --- a/siren/messaging.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Manages messaging-related API interactions for the Siren SDK.""" - -from typing import Any, Dict, Optional - -import requests - - -class MessagingManager: - """Manages direct message sending operations.""" - - def __init__(self, api_key: str, base_url: str): - """ - Initialize the MessagingManager. - - Args: - api_key: The API key for authentication. - base_url: The base URL of the Siren API. - """ - self.api_key = api_key - self.base_url = f"{base_url}/api/v1/public" - - def send_message( - self, - template_name: str, - channel: str, - recipient_type: str, - recipient_value: str, - template_variables: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - Send a message using a specific template. - - Args: - template_name: The name of the template to use. - channel: The channel to send the message through (e.g., "SLACK", "EMAIL"). - recipient_type: The type of recipient (e.g., "direct"). - recipient_value: The identifier for the recipient (e.g., Slack user ID, email address). - template_variables: A dictionary of variables to populate the template. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/send-messages" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - payload: Dict[str, Any] = { - "template": {"name": template_name}, - "recipient": {"type": recipient_type, "value": recipient_value}, - "channel": channel, - } - if template_variables is not None: - payload["templateVariables"] = template_variables - - try: - response = requests.post( - endpoint, headers=headers, json=payload, timeout=10 - ) - response.raise_for_status() # Raise an exception for HTTP errors - return response.json() - except requests.exceptions.HTTPError as http_err: - # Try to return JSON error response from API if available - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - # If response is not JSON, re-raise the original HTTPError - raise http_err - except requests.exceptions.RequestException as req_err: - # For other network errors (timeout, connection error, etc.) - raise req_err - - def get_message_status(self, message_id: str) -> Dict[str, Any]: - """ - Retrieve the status of a specific message. - - Args: - message_id: The ID of the message for which to retrieve the status. - - Returns: - A dictionary containing the API response with the message status. - """ - endpoint = f"{self.base_url}/message-status/{message_id}" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - try: - response = requests.get(endpoint, headers=headers, timeout=10) - response.raise_for_status() # Raise an exception for HTTP errors - return response.json() - except requests.exceptions.HTTPError as http_err: - # Try to return JSON error response from API if available - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - # If response is not JSON, re-raise the original HTTPError - raise http_err - except requests.exceptions.RequestException as req_err: - # For other network errors (timeout, connection error, etc.) - raise req_err - - def get_replies(self, message_id: str) -> Dict[str, Any]: - """ - Retrieve replies for a specific message. - - Args: - message_id: The ID of the message for which to retrieve replies. - - Returns: - A dictionary containing the API response with replies. - """ - endpoint = f"{self.base_url}/get-reply/{message_id}" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - try: - response = requests.get(endpoint, headers=headers, timeout=10) - response.raise_for_status() # Raise an exception for HTTP errors - return response.json() - except requests.exceptions.HTTPError as http_err: - # Try to return JSON error response if available, otherwise re-raise - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - # For network errors or other request issues - raise req_err diff --git a/siren/models/base.py b/siren/models/base.py index 7e265dd..8430210 100644 --- a/siren/models/base.py +++ b/siren/models/base.py @@ -45,3 +45,13 @@ def error_detail(self) -> Optional[APIErrorDetail]: if self.errors: return self.errors[0] return None + + +class DeleteResponse(BaseAPIResponse[None]): + """Common response model for delete operations across the SDK. + + For successful deletions (204), data will be None. + For errors, error/errors/meta fields will be populated. + """ + + pass diff --git a/siren/models/templates.py b/siren/models/templates.py new file mode 100644 index 0000000..cd938ca --- /dev/null +++ b/siren/models/templates.py @@ -0,0 +1,139 @@ +"""Template-related models for the Siren SDK.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .base import BaseAPIResponse + + +class TemplateVersion(BaseModel): + """Template version information.""" + + model_config = ConfigDict(validate_by_name=True) + + id: str + version: int + status: str + published_at: Optional[str] = Field(None, alias="publishedAt") + + +class Template(BaseModel): + """Template model matching API response structure.""" + + model_config = ConfigDict(validate_by_name=True) + + id: str + name: str + variables: List[Dict[str, Any]] = [] + tags: List[str] = [] + draft_version: Optional[TemplateVersion] = Field(None, alias="draftVersion") + published_version: Optional[TemplateVersion] = Field(None, alias="publishedVersion") + template_versions: List[TemplateVersion] = Field( + default_factory=list, alias="templateVersions" + ) + + +class TemplateListMeta(BaseModel): + """Pagination metadata for template list.""" + + last: str + total_pages: str = Field(alias="totalPages") + page_size: str = Field(alias="pageSize") + current_page: str = Field(alias="currentPage") + first: str + total_elements: str = Field(alias="totalElements") + + +class ChannelTemplate(BaseModel): + """Channel template within a created template.""" + + model_config = ConfigDict(validate_by_name=True) + + id: Optional[str] = None + channel: str + configuration: Dict[str, Any] + template_version_id: Optional[str] = Field(None, alias="templateVersionId") + + +class CreatedTemplate(BaseModel): + """Created template response data.""" + + model_config = ConfigDict(validate_by_name=True) + + template_id: str = Field(alias="templateId") + template_name: str = Field(alias="templateName") + draft_version_id: str = Field(alias="draftVersionId") + channel_template_list: List[ChannelTemplate] = Field( + default_factory=list, alias="channelTemplateList" + ) + + +class CreateTemplateRequest(BaseModel): + """Request model for creating templates.""" + + model_config = ConfigDict(validate_by_name=True) + + name: str + description: Optional[str] = None + tag_names: List[str] = Field(default_factory=list, alias="tagNames") + variables: List[Dict[str, Any]] = Field(default_factory=list) + configurations: Dict[str, Any] = Field(default_factory=dict) + + +class TemplateListResponse(BaseAPIResponse[List[Template]]): + """API response for template list operations.""" + + pass + + +class UpdateTemplateRequest(BaseModel): + """Request model for updating templates.""" + + model_config = ConfigDict(validate_by_name=True) + + name: Optional[str] = None + description: Optional[str] = None + tag_names: Optional[List[str]] = Field(None, alias="tagNames") + variables: Optional[List[Dict[str, Any]]] = None + configurations: Optional[Dict[str, Any]] = None + + +class CreateTemplateResponse(BaseAPIResponse[CreatedTemplate]): + """API response for create template operations.""" + + pass + + +class UpdateTemplateResponse(BaseAPIResponse[Template]): + """API response for update template operations.""" + + pass + + +class PublishTemplateResponse(BaseAPIResponse[Template]): + """Response model for publish template API.""" + + pass + + +class CreateChannelTemplatesRequest(BaseModel): + """Request model for create channel templates operations. + + This is a flexible model that accepts any key-value pairs where + keys are channel names and values are channel configurations. + """ + + model_config = ConfigDict(extra="allow") + + +class CreateChannelTemplatesResponse(BaseAPIResponse[List[ChannelTemplate]]): + """Response model for create channel templates operations.""" + + pass + + +class GetChannelTemplatesResponse(BaseAPIResponse[List[ChannelTemplate]]): + """Response model for get channel templates operations.""" + + pass diff --git a/siren/templates.py b/siren/templates.py deleted file mode 100644 index 39be5b2..0000000 --- a/siren/templates.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Template management for the Siren SDK.""" - -from typing import Any, Dict, Optional - -import requests - - -class TemplatesManager: - """Manages template-related operations for the Siren API.""" - - def __init__(self, api_key: str, base_url: str): - """Initialize the TemplatesManager. - - Args: - api_key: The API key for authentication. - base_url: The general base URL for the Siren API (e.g., 'https://api.trysiren.io'). - """ - self.api_key = api_key - self.base_url = f"{base_url}/api/v1/public" - - def get_templates( - self, - tag_names: Optional[str] = None, - search: Optional[str] = None, - sort: Optional[str] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> Dict[str, Any]: - """Fetch templates. - - Args: - tag_names: Filter by tag names. - search: Search by field. - sort: Sort by field. - page: Page number. - size: Page size. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/template" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - params: Dict[str, Any] = {} - if tag_names is not None: - params["tagNames"] = tag_names - if search is not None: - params["search"] = search - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if size is not None: - params["size"] = size - - try: - response = requests.get( - endpoint, headers=headers, params=params, timeout=10 - ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def create_template(self, template_data: Dict[str, Any]) -> Dict[str, Any]: - """Create a new template. - - Args: - template_data: A dictionary containing the template details. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/template" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - "Content-Type": "application/json", - } - try: - response = requests.post( - endpoint, headers=headers, json=template_data, timeout=10 - ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def update_template( - self, template_id: str, template_data: Dict[str, Any] - ) -> Dict[str, Any]: - """Update an existing template. - - Args: - template_id: The ID of the template to update. - template_data: A dictionary containing the template details to update. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/template/{template_id}" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - "Content-Type": "application/json", - } - try: - response = requests.put( - endpoint, headers=headers, json=template_data, timeout=10 - ) - response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes - return response.json() - except requests.exceptions.HTTPError as http_err: - # Try to return the JSON error response from the API if available - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - # If the error response isn't JSON, re-raise the original HTTPError - raise http_err - except requests.exceptions.RequestException as req_err: - # For other network issues (e.g., connection error) - raise req_err - - def delete_template(self, template_id: str) -> Dict[str, Any]: - """Delete an existing template. - - Args: - template_id: The ID of the template to delete. - - Returns: - A dictionary containing the API response (e.g., a confirmation message). - """ - endpoint = f"{self.base_url}/template/{template_id}" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - try: - response = requests.delete(endpoint, headers=headers, timeout=10) - response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes - if response.status_code == 204: - return { - "status": "success", - "message": f"Template {template_id} deleted successfully.", - } - # For other successful responses (e.g., 200 OK with a body, though not expected for DELETE here) - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def publish_template(self, template_id: str) -> Dict[str, Any]: - """Publish an existing template. - - Args: - template_id: The ID of the template to publish. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/template/{template_id}/publish" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - try: - response = requests.patch(endpoint, headers=headers, timeout=10) - response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def create_channel_templates( - self, - template_id: str, - channel_templates: Dict[str, Any], - ) -> Dict[str, Any]: - """Create or update channel templates for a specific template. - - Args: - template_id: The ID of the template for which to create channel templates. - channel_templates: A dictionary where keys are channel names (e.g., "EMAIL", "SMS") - and values are the channel-specific template objects. - - Example: - { - "SMS": {"body": "...", "channel": "SMS", ...}, - "EMAIL": {"subject": "...", "body": "...", ...} - } - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/template/{template_id}/channel-templates" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - "Content-Type": "application/json", - } - try: - response = requests.post( - endpoint, headers=headers, json=channel_templates, timeout=10 - ) - response.raise_for_status() # Raises HTTPError for 4XX/5XX status codes - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - raise http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def get_channel_templates( - self, - version_id: str, - channel: Optional[str] = None, - search: Optional[str] = None, - sort: Optional[str] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> Dict[str, Any]: - """Fetch channel templates for a specific template version. - - Args: - version_id: The ID of the template version for which to fetch channel templates. - channel: Filter by channel type (e.g., "EMAIL", "SMS"). - search: Search term to filter channel templates. - sort: Sort by field. - page: Page number. - size: Page size. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.base_url}/template/versions/{version_id}/channel-templates" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "application/json", - } - params: Dict[str, Any] = {} - if channel is not None: - params["channel"] = channel - if search is not None: - params["search"] = search - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if size is not None: - params["size"] = size - - try: - response = requests.get( - endpoint, headers=headers, params=params, timeout=10 - ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - # If the error response isn't JSON, re-raise the original HTTPError - raise http_err - except requests.exceptions.RequestException as req_err: - # For other network issues (e.g., connection error) - raise req_err diff --git a/tests/test_client.py b/tests/test_client.py index 1d36e83..f81cf23 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,5 +23,5 @@ def test_siren_client_initialization(client): client._templates.api_key == "test_api_key" ), "Templates manager should have API key" assert ( - client._templates.base_url == f"{client.BASE_API_URL}/api/v1/public" - ), "Templates manager should construct its specific v1 base URL" + client._templates.base_url == client.BASE_API_URL + ), "Templates manager should use the base URL from client" diff --git a/tests/test_templates.py b/tests/test_templates.py index e3a5cc8..63f32e8 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,777 +1,613 @@ -"""Tests for template-related operations in the Siren API client.""" +"""Unit tests for the templates manager using BaseManager.""" -import os -import sys +from unittest.mock import Mock, patch import pytest -import requests -from requests_mock import Mocker as RequestsMocker -# Ensure the 'siren' package in the parent directory can be imported: -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from siren.exceptions import SirenAPIError, SirenSDKError +from siren.managers.templates import TemplatesManager +from siren.models.templates import CreatedTemplate, Template -# The 'client' fixture is automatically available from conftest.py +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" -def test_get_templates_success(client, requests_mock: RequestsMocker): - """Test successful retrieval of templates.""" - mock_response_data = { - "data": { - "content": [ - {"id": "tpl_1", "name": "Test Template 1"}, - {"id": "tpl_2", "name": "Test Template 2"}, +def mock_response(status_code: int, json_data: dict = None): + """Helper function to create a mock HTTP response.""" + mock_resp = Mock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_data if json_data is not None else {} + return mock_resp + + +class TestTemplatesManager: + """Tests for the TemplatesManager class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.manager = TemplatesManager(api_key=API_KEY, base_url=BASE_URL) + + @patch("siren.managers.base.requests.request") + def test_get_templates_success(self, mock_request): + """Test successful retrieval of templates.""" + # Mock API response based on user-provided response + mock_api_response = { + "data": [ + { + "id": "cacf1503-8283-42a8-b5fd-27d85054fb99", + "name": "test1", + "variables": [], + "tags": [], + "draftVersion": { + "id": "568a2903-056c-43ba-bc4f-cce420fb1ced", + "version": 1, + "status": "DRAFT", + "publishedAt": None, + }, + "templateVersions": [ + { + "id": "568a2903-056c-43ba-bc4f-cce420fb1ced", + "version": 1, + "status": "DRAFT", + "publishedAt": None, + } + ], + }, + { + "id": "11921404-4517-48b7-82ee-fcdcf8f9c03b", + "name": "jithu_test1", + "variables": [], + "tags": ["sampleTag", "tag2"], + "draftVersion": { + "id": "dd8d8a77-7fcf-4bd8-8d8b-89f1e5e8822a", + "version": 2, + "status": "DRAFT", + "publishedAt": None, + }, + "publishedVersion": { + "id": "9138125c-d242-4b17-ae0e-16ade9d06568", + "version": 1, + "status": "PUBLISHED_LATEST", + "publishedAt": "2025-06-05T10:39:53.780+00:00", + }, + "templateVersions": [ + { + "id": "9138125c-d242-4b17-ae0e-16ade9d06568", + "version": 1, + "status": "PUBLISHED_LATEST", + "publishedAt": "2025-06-05T10:39:53.780+00:00", + } + ], + }, ], - "totalElements": 2, + "error": None, + "meta": { + "last": "false", + "totalPages": "2", + "pageSize": "2", + "currentPage": "0", + "first": "true", + "totalElements": "3", + }, } - } - requests_mock.get( - f"{client._templates.base_url}/template", - json=mock_response_data, - status_code=200, - ) - - response = client.get_templates(page=0, size=10) - assert response == mock_response_data - assert len(response["data"]["content"]) == 2 - assert response["data"]["content"][0]["name"] == "Test Template 1" - - -def test_get_templates_http_error(client, requests_mock: RequestsMocker): - """Test handling of HTTP error when getting templates.""" - error_response_data = { - "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} - } - requests_mock.get( - f"{client._templates.base_url}/template", - json=error_response_data, - status_code=401, - ) - - response = client.get_templates() - assert response == error_response_data - - -def test_get_templates_network_error(client, requests_mock: RequestsMocker): - """Test handling of a network error when getting templates.""" - requests_mock.get( - f"{client._templates.base_url}/template", exc=requests.exceptions.ConnectTimeout - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.get_templates() - - -def test_get_templates_http_error_non_json_response( - client, requests_mock: RequestsMocker -): - """Test HTTP error with non-JSON response for get_templates.""" - requests_mock.get( - f"{client._templates.base_url}/template", - text="Service Unavailable - Not JSON", - status_code=503, - ) - - with pytest.raises(requests.exceptions.HTTPError) as exc_info: - client.get_templates() - - assert exc_info.value.response.status_code == 503 - assert exc_info.value.response.text == "Service Unavailable - Not JSON" - - -def test_create_template_success(client, requests_mock: RequestsMocker): - """Test successful creation of a template.""" - mock_request_payload = { - "name": "Test_Create_Template", - "description": "A test template for creation", - "tagNames": ["test", "creation"], - "variables": [{"name": "user_name", "defaultValue": "Guest"}], - "configurations": { - "EMAIL": { - "subject": "Welcome {{user_name}}!", - "channel": "EMAIL", - "body": "

Hello {{user_name}}, welcome!

", - "isRawHTML": True, - "isPlainText": False, - } - }, - } - mock_response_data = { - "data": { - "templateId": "tpl_abc123", - "templateName": "Test_Create_Template", - "draftVersionId": "ver_def456", - "channelTemplateList": [ - { - "id": "ct_email_789", + mock_request.return_value = mock_response(200, mock_api_response) + + # Call the method + result = self.manager.get_templates(page=0, size=2) + + # Verify result is List[Template] + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(template, Template) for template in result) + + # Verify first template + first_template = result[0] + assert first_template.id == "cacf1503-8283-42a8-b5fd-27d85054fb99" + assert first_template.name == "test1" + assert first_template.tags == [] + assert first_template.draft_version.status == "DRAFT" + assert first_template.published_version is None + + # Verify second template + second_template = result[1] + assert second_template.id == "11921404-4517-48b7-82ee-fcdcf8f9c03b" + assert second_template.name == "jithu_test1" + assert second_template.tags == ["sampleTag", "tag2"] + assert second_template.draft_version.status == "DRAFT" + assert second_template.published_version.status == "PUBLISHED_LATEST" + + # Verify request was made correctly with BaseManager + mock_request.assert_called_once_with( + method="GET", + url=f"{BASE_URL}/api/v1/public/template", + headers={"Authorization": f"Bearer {API_KEY}"}, + json=None, + params={"page": 0, "size": 2}, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_get_templates_with_all_params(self, mock_request): + """Test get_templates with all optional parameters.""" + mock_api_response = { + "data": [], + "error": None, + "meta": { + "last": "true", + "totalPages": "0", + "pageSize": "5", + "currentPage": "0", + "first": "true", + "totalElements": "0", + }, + } + mock_request.return_value = mock_response(200, mock_api_response) + + # Call with all parameters + result = self.manager.get_templates( + tag_names="test,example", search="template", sort="name,asc", page=1, size=5 + ) + + assert isinstance(result, list) + assert len(result) == 0 + + # Verify all parameters were passed correctly + mock_request.assert_called_once_with( + method="GET", + url=f"{BASE_URL}/api/v1/public/template", + headers={"Authorization": f"Bearer {API_KEY}"}, + json=None, + params={ + "tagNames": "test,example", + "search": "template", + "sort": "name,asc", + "page": 1, + "size": 5, + }, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_get_templates_api_error(self, mock_request): + """Test API error during template retrieval.""" + mock_api_error = { + "error": {"errorCode": "UNAUTHORIZED", "message": "Invalid API key"} + } + mock_request.return_value = mock_response(401, mock_api_error) + + with pytest.raises(SirenAPIError) as exc_info: + self.manager.get_templates() + + assert exc_info.value.error_code == "UNAUTHORIZED" + assert "Invalid API key" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_get_templates_network_error(self, mock_request): + """Test network error during template retrieval.""" + from requests.exceptions import ConnectionError + + mock_request.side_effect = ConnectionError("Connection failed") + + with pytest.raises(SirenSDKError) as exc_info: + self.manager.get_templates() + + assert "Connection failed" in exc_info.value.message + + @patch("siren.managers.base.requests.request") + def test_create_template_success(self, mock_request): + """Test successful template creation.""" + mock_api_response = { + "data": { + "templateId": "tpl_abc123", + "templateName": "Test_Create_Template", + "draftVersionId": "ver_def456", + "channelTemplateList": [ + { + "id": "ct_email_789", + "channel": "EMAIL", + "configuration": {"channel": "EMAIL"}, + "templateVersionId": "ver_def456", + } + ], + }, + "error": None, + } + mock_request.return_value = mock_response(200, mock_api_response) + + result = self.manager.create_template( + name="Test_Create_Template", + description="A test template", + tag_names=["test", "creation"], + variables=[{"name": "user_name", "defaultValue": "Guest"}], + configurations={ + "EMAIL": { + "subject": "Welcome {{user_name}}!", "channel": "EMAIL", - "configuration": {"channel": "EMAIL"}, - "templateVersionId": "ver_def456", + "body": "

Hello {{user_name}}, welcome!

", + "isRawHTML": True, + "isPlainText": False, } - ], - }, - "error": None, - "errors": None, - "meta": None, - } - requests_mock.post( - f"{client._templates.base_url}/template", - json=mock_response_data, - status_code=200, - ) - - response = client.create_template(mock_request_payload) - assert response == mock_response_data - assert requests_mock.last_request is not None - assert requests_mock.last_request.json() == mock_request_payload - - -def test_create_template_http_error(client, requests_mock: RequestsMocker): - """Test handling of HTTP error when creating a template.""" - mock_request_payload = {"name": "Invalid Template"} - error_response_data = { - "data": None, - "error": {"errorCode": "BAD_REQUEST", "message": "Bad request"}, - "errors": [ - { + }, + ) + + assert isinstance(result, CreatedTemplate) + assert result.template_id == "tpl_abc123" + assert result.template_name == "Test_Create_Template" + assert result.draft_version_id == "ver_def456" + assert len(result.channel_template_list) == 1 + assert result.channel_template_list[0].channel == "EMAIL" + + # Verify request with camelCase conversion + mock_request.assert_called_once_with( + method="POST", + url=f"{BASE_URL}/api/v1/public/template", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + }, + json={ + "name": "Test_Create_Template", + "description": "A test template", + "tagNames": ["test", "creation"], + "variables": [{"name": "user_name", "defaultValue": "Guest"}], + "configurations": { + "EMAIL": { + "subject": "Welcome {{user_name}}!", + "channel": "EMAIL", + "body": "

Hello {{user_name}}, welcome!

", + "isRawHTML": True, + "isPlainText": False, + } + }, + }, + params=None, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_create_template_api_error(self, mock_request): + """Test API error during template creation.""" + mock_api_error = { + "error": {"errorCode": "BAD_REQUEST", "message": "Bad request"} + } + mock_request.return_value = mock_response(400, mock_api_error) + + with pytest.raises(SirenAPIError) as exc_info: + self.manager.create_template(name="Invalid Template") + + assert exc_info.value.error_code == "BAD_REQUEST" + assert "Bad request" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_delete_template_success(self, mock_request): + """Test successful template deletion (204 No Content).""" + # Mock 204 response with empty body + mock_request.return_value = mock_response(204, "") + + template_id = "tpl_delete_123" + result = self.manager.delete_template(template_id) + + assert result is True + + # Verify request was made correctly + mock_request.assert_called_once_with( + method="DELETE", + url=f"{BASE_URL}/api/v1/public/template/{template_id}", + headers={"Authorization": f"Bearer {API_KEY}"}, + json=None, + params=None, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_delete_template_not_found(self, mock_request): + """Test template deletion with 404 error.""" + mock_api_error = { + "error": {"errorCode": "NOT_FOUND", "message": "Template not found"} + } + mock_request.return_value = mock_response(404, mock_api_error) + + template_id = "tpl_not_found" + + with pytest.raises(SirenAPIError) as exc_info: + self.manager.delete_template(template_id) + + assert exc_info.value.error_code == "NOT_FOUND" + assert "Template not found" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_update_template_success(self, mock_request): + """Test successful template update.""" + mock_api_response = { + "data": { + "id": "tpl_xyz789", + "name": "Updated_Test_Template", + "variables": [], + "tags": ["updated", "test"], + "draftVersion": { + "id": "ver_jkl012", + "version": 2, + "status": "DRAFT", + "publishedAt": None, + }, + "templateVersions": [], + }, + "error": None, + } + mock_request.return_value = mock_response(200, mock_api_response) + + template_id = "tpl_xyz789" + + result = self.manager.update_template( + template_id, + name="Updated_Test_Template", + description="An updated test template", + tag_names=["updated", "test"], + variables=[{"name": "user_name", "defaultValue": "Updated Guest"}], + ) + + assert isinstance(result, Template) + assert result.id == "tpl_xyz789" + assert result.name == "Updated_Test_Template" + + # Verify request with camelCase conversion + mock_request.assert_called_once_with( + method="PUT", + url=f"{BASE_URL}/api/v1/public/template/{template_id}", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + }, + json={ + "name": "Updated_Test_Template", + "description": "An updated test template", + "tagNames": ["updated", "test"], + "variables": [{"name": "user_name", "defaultValue": "Updated Guest"}], + }, + params=None, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_publish_template_success(self, mock_request): + """Test successful template publishing.""" + template_id = "tpl_pub_success" + mock_api_response = { + "data": { + "id": template_id, + "name": "Published Template", + "variables": [], + "tags": [], + "draftVersion": { + "id": "ver_draft_123", + "version": 2, + "status": "DRAFT", + "publishedAt": None, + }, + "publishedVersion": { + "id": "ver_pub_456", + "version": 1, + "status": "PUBLISHED_LATEST", + "publishedAt": "2025-01-15T10:00:00.000+00:00", + }, + "templateVersions": [], + }, + "error": None, + } + mock_request.return_value = mock_response(200, mock_api_response) + + result = self.manager.publish_template(template_id) + + assert isinstance(result, Template) + assert result.id == template_id + assert result.name == "Published Template" + assert result.published_version is not None + assert result.published_version.status == "PUBLISHED_LATEST" + + # Verify request was made correctly + mock_request.assert_called_once_with( + method="PATCH", + url=f"{BASE_URL}/api/v1/public/template/{template_id}/publish", + headers={"Authorization": f"Bearer {API_KEY}"}, + json=None, + params=None, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_publish_template_not_found(self, mock_request): + """Test template publishing with 404 error.""" + template_id = "tpl_not_found" + mock_api_error = { + "error": {"errorCode": "NOT_FOUND", "message": "Template not found"} + } + mock_request.return_value = mock_response(404, mock_api_error) + + with pytest.raises(SirenAPIError) as exc_info: + self.manager.publish_template(template_id) + + assert exc_info.value.error_code == "NOT_FOUND" + assert "Template not found" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_publish_template_bad_request(self, mock_request): + """Test template publishing with 400 error.""" + template_id = "tpl_bad_request" + mock_api_error = { + "error": { "errorCode": "BAD_REQUEST", - "message": "Name is too short or missing fields", + "message": "Template has no versions to publish", } - ], - "meta": None, - } - requests_mock.post( - f"{client._templates.base_url}/template", - json=error_response_data, - status_code=400, - ) - - response = client.create_template(mock_request_payload) - assert response == error_response_data - - -def test_create_template_network_error(client, requests_mock: RequestsMocker): - """Test handling of a network error when creating a template.""" - mock_request_payload = {"name": "Network Error Template"} - requests_mock.post( - f"{client._templates.base_url}/template", exc=requests.exceptions.ConnectTimeout - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.create_template(mock_request_payload) - - -def test_create_template_http_error_non_json_response( - client, requests_mock: RequestsMocker -): - """Test HTTP error with non-JSON response for create_template.""" - mock_request_payload = {"name": "Test Non-JSON Error"} - requests_mock.post( - f"{client._templates.base_url}/template", - text="Server Error - Not JSON", - status_code=500, - ) - - with pytest.raises(requests.exceptions.HTTPError) as exc_info: - client.create_template(mock_request_payload) - - assert exc_info.value.response.status_code == 500 - assert exc_info.value.response.text == "Server Error - Not JSON" - - -def test_update_template_success(client, requests_mock: RequestsMocker): - """Test successful update of a template.""" - template_id = "tpl_xyz789" - mock_request_payload = { - "name": "Updated_Test_Template", - "description": "An updated test template", - "tagNames": ["updated", "test"], - "variables": [{"name": "user_name", "defaultValue": "Updated Guest"}], - "configurations": { + } + mock_request.return_value = mock_response(400, mock_api_error) + + with pytest.raises(SirenAPIError) as exc_info: + self.manager.publish_template(template_id) + + assert exc_info.value.error_code == "BAD_REQUEST" + assert "Template has no versions to publish" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_create_channel_templates_success(self, mock_request): + """Test successful creation of channel templates.""" + mock_input_data = { + "SMS": { + "body": "Test SMS body for channel config", + "channel": "SMS", + "isFlash": False, + "isUnicode": False, + }, "EMAIL": { - "subject": "Updated Welcome {{user_name}}!", + "subject": "Test Email Subject for channel config", "channel": "EMAIL", - "body": "

Hello {{user_name}}, your details are updated!

", + "body": "

Test Email Body for channel config

", + "attachments": [], "isRawHTML": True, "isPlainText": False, - } - }, - } - mock_response_data = { - "data": { - "templateId": template_id, - "templateName": "Updated_Test_Template", - "draftVersionId": "ver_jkl012", - "channelTemplateList": [ + }, + } + # API returns a list of ChannelTemplate objects + mock_response_data = { + "data": [ + { + "channel": "SMS", + "configuration": { + "channel": "SMS", + "body": "Test SMS body for channel config", + "isFlash": False, + "isUnicode": False, + }, + }, { - "id": "ct_email_345", "channel": "EMAIL", - "configuration": {"channel": "EMAIL"}, - "templateVersionId": "ver_jkl012", - } + "configuration": { + "channel": "EMAIL", + "subject": "Test Email Subject for channel config", + "body": "

Test Email Body for channel config

", + "attachments": [], + "isRawHTML": True, + "isPlainText": False, + }, + }, ], - }, - "error": None, - "errors": None, - "meta": None, - } - requests_mock.put( - f"{client._templates.base_url}/template/{template_id}", - json=mock_response_data, - status_code=200, - ) - - response = client.update_template(template_id, mock_request_payload) - assert response == mock_response_data - assert requests_mock.last_request is not None - assert requests_mock.last_request.json() == mock_request_payload - assert requests_mock.last_request.method == "PUT" - - -def test_update_template_http_error(client, requests_mock: RequestsMocker): - """Test handling of HTTP error when updating a template.""" - template_id = "tpl_error400" - mock_request_payload = {"name": "Invalid Update"} - error_response_data = { - "data": None, - "error": {"errorCode": "BAD_REQUEST", "message": "Invalid data for update"}, - "errors": [ - { - "errorCode": "BAD_REQUEST", - "message": "Name is too short or some fields are invalid", - } - ], - "meta": None, - } - requests_mock.put( - f"{client._templates.base_url}/template/{template_id}", - json=error_response_data, - status_code=400, - ) - - response = client.update_template(template_id, mock_request_payload) - assert response == error_response_data - - -def test_update_template_network_error(client, requests_mock: RequestsMocker): - """Test handling of a network error when updating a template.""" - template_id = "tpl_network_err" - mock_request_payload = {"name": "Network Error Update"} - requests_mock.put( - f"{client._templates.base_url}/template/{template_id}", - exc=requests.exceptions.ConnectTimeout, - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.update_template(template_id, mock_request_payload) - - -def test_update_template_http_error_non_json_response( - client, requests_mock: RequestsMocker -): - """Test HTTP error with non-JSON response for update_template.""" - template_id = "tpl_non_json_error" - mock_request_payload = {"name": "Test Update Non-JSON Error"} - requests_mock.put( - f"{client._templates.base_url}/template/{template_id}", - text="Internal Server Error - Not JSON", - status_code=500, - ) - - with pytest.raises(requests.exceptions.HTTPError) as exc_info: - client.update_template(template_id, mock_request_payload) - - assert exc_info.value.response.status_code == 500 - assert exc_info.value.response.text == "Internal Server Error - Not JSON" - - -def test_delete_template_success(client, requests_mock: RequestsMocker): - """Test successful deletion of a template (204 No Content).""" - template_id = "tpl_todelete123" - expected_response_data = { - "status": "success", - "message": f"Template {template_id} deleted successfully.", - } - requests_mock.delete( - f"{client._templates.base_url}/template/{template_id}", - text="", # Empty body for 204 - status_code=204, - ) - - response = client.delete_template(template_id) - assert response == expected_response_data - assert requests_mock.last_request is not None - assert requests_mock.last_request.method == "DELETE" - - -def test_delete_template_not_found_error(client, requests_mock: RequestsMocker): - """Test handling of a 404 Not Found error when deleting a template.""" - template_id = "tpl_notfound404" - error_response_data = { - "data": None, - "error": {"errorCode": "NOT_FOUND", "message": "Template not found"}, - "errors": [ - { - "errorCode": "NOT_FOUND", - "message": f"Template with id {template_id} not found", - } - ], - "meta": None, - } - requests_mock.delete( - f"{client._templates.base_url}/template/{template_id}", - json=error_response_data, - status_code=404, - ) - - response = client.delete_template(template_id) - assert response == error_response_data - - -def test_delete_template_unauthorized_error(client, requests_mock: RequestsMocker): - """Test handling of a 401 Unauthorized error when deleting a template.""" - template_id = "tpl_unauth401" - error_response_data = { - "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} - } - requests_mock.delete( - f"{client._templates.base_url}/template/{template_id}", - json=error_response_data, - status_code=401, - ) - - response = client.delete_template(template_id) - assert response == error_response_data - - -def test_delete_template_network_error(client, requests_mock: RequestsMocker): - """Test handling of a network error when deleting a template.""" - template_id = "tpl_network_error" - requests_mock.delete( - f"{client._templates.base_url}/template/{template_id}", - exc=requests.exceptions.ConnectTimeout, - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.delete_template(template_id) - - -def test_delete_template_http_error_non_json_response( - client, requests_mock: RequestsMocker -): - """Test HTTP error with non-JSON response for delete_template.""" - template_id = "tpl_non_json_delete_error" - requests_mock.delete( - f"{client._templates.base_url}/template/{template_id}", - text="Gateway Timeout - Not JSON", - status_code=504, - ) - - with pytest.raises(requests.exceptions.HTTPError) as exc_info: - client.delete_template(template_id) - - assert exc_info.value.response.status_code == 504 - assert exc_info.value.response.text == "Gateway Timeout - Not JSON" - - -def test_publish_template_success(client, requests_mock: RequestsMocker): - """Test successful publishing of a template.""" - template_id = "tpl_pub_success" - mock_response_data = { - "data": { - "id": template_id, - "name": "Published Template", - "publishedVersion": {"status": "PUBLISHED_LATEST"}, - }, - "error": None, - } - requests_mock.patch( - f"{client._templates.base_url}/template/{template_id}/publish", - json=mock_response_data, - status_code=200, - ) - - response = client.publish_template(template_id) - assert response == mock_response_data - assert requests_mock.last_request is not None - assert requests_mock.last_request.method == "PATCH" - - -def test_publish_template_not_found_error(client, requests_mock: RequestsMocker): - """Test handling of a 404 Not Found error when publishing a template.""" - template_id = "tpl_pub_notfound" - error_response_data = { - "data": None, - "error": {"errorCode": "NOT_FOUND", "message": "Template not found"}, - "errors": [{"errorCode": "NOT_FOUND", "message": "Template not found"}], - "meta": None, - } - requests_mock.patch( - f"{client._templates.base_url}/template/{template_id}/publish", - json=error_response_data, - status_code=404, - ) - - response = client.publish_template(template_id) - assert response == error_response_data - - -def test_publish_template_unauthorized_error(client, requests_mock: RequestsMocker): - """Test handling of a 401 Unauthorized error when publishing a template.""" - template_id = "tpl_pub_unauth" - error_response_data = { - "data": None, - "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"}, - "errors": [{"errorCode": "UNAUTHORISED", "message": "Invalid API Key"}], - "meta": None, - } - requests_mock.patch( - f"{client._templates.base_url}/template/{template_id}/publish", - json=error_response_data, - status_code=401, - ) - - response = client.publish_template(template_id) - assert response == error_response_data - - -def test_publish_template_bad_request_error(client, requests_mock: RequestsMocker): - """Test handling of a 400 Bad Request error when publishing a template.""" - template_id = "tpl_pub_badreq" - error_response_data = { - "data": None, - "error": { - "errorCode": "BAD_REQUEST", - "message": "Template has no versions to publish", - }, - "errors": [ - { - "errorCode": "BAD_REQUEST", - "message": "Template has no versions to publish", - } - ], - "meta": None, - } - requests_mock.patch( - f"{client._templates.base_url}/template/{template_id}/publish", - json=error_response_data, - status_code=400, - ) - - response = client.publish_template(template_id) - assert response == error_response_data - - -def test_publish_template_network_error(client, requests_mock: RequestsMocker): - """Test handling of a network error when publishing a template.""" - template_id = "tpl_pub_network_err" - requests_mock.patch( - f"{client._templates.base_url}/template/{template_id}/publish", - exc=requests.exceptions.ConnectTimeout, - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.publish_template(template_id) - - -def test_publish_template_http_error_non_json_response( - client, requests_mock: RequestsMocker -): - """Test HTTP error with non-JSON response for publish_template.""" - template_id = "tpl_non_json_publish_error" - requests_mock.patch( - f"{client._templates.base_url}/template/{template_id}/publish", - text="Bad Gateway - Not JSON", - status_code=502, - ) - - with pytest.raises(requests.exceptions.HTTPError) as exc_info: - client.publish_template(template_id) - - assert exc_info.value.response.status_code == 502 - assert exc_info.value.response.text == "Bad Gateway - Not JSON" - - -def test_create_channel_templates_success(client, requests_mock: RequestsMocker): - """Test successful creation of channel templates.""" - template_id = "tpl_test123" - mock_request_payload = { - "SMS": { - "body": "Test SMS body for channel config", - "channel": "SMS", - "isFlash": False, - "isUnicode": False, - }, - "EMAIL": { - "subject": "Test Email Subject for channel config", - "channel": "EMAIL", - "body": "

Test Email Body for channel config

", - "attachments": [], - "isRawHTML": True, - "isPlainText": False, - }, - } - mock_response_data = { - "data": mock_request_payload, # Assuming API returns the created configs in 'data' - "error": None, - "errors": None, - "meta": None, - } - requests_mock.post( - f"{client._templates.base_url}/template/{template_id}/channel-templates", - json=mock_response_data, - status_code=200, - ) - - response = client.create_channel_templates(template_id, mock_request_payload) - assert response == mock_response_data - assert requests_mock.last_request is not None - assert requests_mock.last_request.json() == mock_request_payload - assert requests_mock.last_request.method == "POST" - - -def test_create_channel_templates_bad_request(client, requests_mock: RequestsMocker): - """Test handling of a 400 Bad Request error for channel templates.""" - template_id = "tpl_badreq400" - mock_request_payload = {"INVALID_CHANNEL": {"body": "invalid"}} - error_response_data = { - "data": None, - "error": { - "errorCode": "BAD_REQUEST", - "message": "Invalid channel configuration provided.", - }, - "errors": [ - { + "error": None, + "errors": None, + "meta": None, + } + mock_request.return_value = mock_response(200, mock_response_data) + + result = self.manager.create_channel_templates("template123", **mock_input_data) + + assert len(result) == 2 + assert result[0].channel == "SMS" + assert result[1].channel == "EMAIL" + mock_request.assert_called_once_with( + method="POST", + url=f"{BASE_URL}/api/v1/public/template/template123/channel-templates", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + }, + json=mock_input_data, + params=None, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_create_channel_templates_api_error(self, mock_request): + """Test API error during channel templates creation.""" + mock_api_error = { + "error": { "errorCode": "BAD_REQUEST", - "message": "Channel type INVALID_CHANNEL not supported.", - } - ], - "meta": None, - } - requests_mock.post( - f"{client._templates.base_url}/template/{template_id}/channel-templates", - json=error_response_data, - status_code=400, - ) - - response = client.create_channel_templates(template_id, mock_request_payload) - assert response == error_response_data - - -def test_create_channel_templates_unauthorized(client, requests_mock: RequestsMocker): - """Test handling of a 401 Unauthorized error for channel templates.""" - template_id = "tpl_unauth401" - mock_request_payload = {"SMS": {"body": "test"}} - error_response_data = { - "data": None, - "error": {"errorCode": "UNAUTHORISED", "message": "Authentication required."}, - "errors": [ - { - "errorCode": "UNAUTHORISED", - "message": "Valid API key is missing or invalid.", + "message": "Invalid channel configuration", } - ], - "meta": None, - } - requests_mock.post( - f"{client._templates.base_url}/template/{template_id}/channel-templates", - json=error_response_data, - status_code=401, - ) - - response = client.create_channel_templates(template_id, mock_request_payload) - assert response == error_response_data - - -def test_create_channel_templates_not_found(client, requests_mock: RequestsMocker): - """Test handling of a 404 Not Found error (template_id) for channel templates.""" - template_id = "tpl_notfound404" - mock_request_payload = {"SMS": {"body": "test"}} - error_response_data = { - "data": None, - "error": { - "errorCode": "NOT_FOUND", - "message": f"Template with id {template_id} not found.", - }, - "errors": [ - { - "errorCode": "NOT_FOUND", - "message": "The requested template does not exist.", - } - ], - "meta": None, - } - requests_mock.post( - f"{client._templates.base_url}/template/{template_id}/channel-templates", - json=error_response_data, - status_code=404, - ) - - response = client.create_channel_templates(template_id, mock_request_payload) - assert response == error_response_data - - -def test_create_channel_templates_network_error(client, requests_mock: RequestsMocker): - """Test handling of a network error for channel templates.""" - template_id = "tpl_network_error" - mock_request_payload = {"SMS": {"body": "test"}} - requests_mock.post( - f"{client._templates.base_url}/template/{template_id}/channel-templates", - exc=requests.exceptions.ConnectTimeout, - ) - - with pytest.raises(requests.exceptions.ConnectTimeout): - client.create_channel_templates(template_id, mock_request_payload) - - -def test_create_channel_templates_http_error_non_json_response(client, requests_mock): - """Test HTTP error with non-JSON response for channel templates.""" - template_id = "tpl_non_json_error" - mock_request_payload = { - "EMAIL": {"subject": "Test", "body": "Body", "channel": "EMAIL"} - } - requests_mock.post( - f"{client._templates.base_url}/template/{template_id}/channel-templates", - text="Internal Server Error", - status_code=500, - ) - - with pytest.raises(requests.exceptions.HTTPError) as excinfo: - client.create_channel_templates(template_id, mock_request_payload) - assert "500 Server Error" in str(excinfo.value) - assert ( - "Internal Server Error" in excinfo.value.response.text - ) - - -def test_get_channel_templates_success(client, requests_mock: RequestsMocker): - """Test successful retrieval of channel templates for a version.""" - version_id = "ver_123xyz" - mock_response_data = { - "data": { - "content": [ - {"channel": "SMS", "configuration": {"channel": "SMS"}}, - {"channel": "EMAIL", "configuration": {"channel": "EMAIL"}}, + } + mock_request.return_value = mock_response(400, mock_api_error) + + with pytest.raises(SirenAPIError) as exc_info: + self.manager.create_channel_templates( + "template123", SMS={"body": "test"}, INVALID_CHANNEL={"body": "invalid"} + ) + + assert exc_info.value.error_code == "BAD_REQUEST" + assert "Invalid channel configuration" in exc_info.value.api_message + + @patch("siren.managers.base.requests.request") + def test_get_channel_templates_success(self, mock_request): + """Test successful retrieval of channel templates.""" + mock_response_data = { + "data": [ + { + "channel": "SMS", + "configuration": { + "channel": "SMS", + "body": "Test SMS body", + "isFlash": False, + "isUnicode": False, + }, + }, + { + "channel": "EMAIL", + "configuration": { + "channel": "EMAIL", + "subject": "Test Email Subject", + "body": "

Test Email Body

", + "isRawHTML": True, + "isPlainText": False, + }, + }, + ], + "error": None, + "errors": None, + "meta": None, + } + mock_request.return_value = mock_response(200, mock_response_data) + + result = self.manager.get_channel_templates("version123") + + assert len(result) == 2 + assert result[0].channel == "SMS" + assert result[1].channel == "EMAIL" + mock_request.assert_called_once_with( + method="GET", + url=f"{BASE_URL}/api/v1/public/template/versions/version123/channel-templates", + headers={"Authorization": f"Bearer {API_KEY}"}, + json=None, + params={}, + timeout=10, + ) + + @patch("siren.managers.base.requests.request") + def test_get_channel_templates_with_params(self, mock_request): + """Test get channel templates with query parameters.""" + mock_response_data = { + "data": [ + { + "channel": "EMAIL", + "configuration": { + "channel": "EMAIL", + "subject": "Filtered Email", + "body": "

Filtered content

", + }, + }, ], - "totalElements": 2, + "error": None, + "errors": None, + "meta": None, } - } - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - json=mock_response_data, - status_code=200, - ) - - response = client.get_channel_templates(version_id=version_id) - assert response == mock_response_data - assert len(response["data"]["content"]) == 2 - assert requests_mock.last_request is not None - assert requests_mock.last_request.qs == {} - - -def test_get_channel_templates_success_with_params( - client, requests_mock: RequestsMocker -): - """Test successful retrieval of channel templates with query parameters.""" - version_id = "ver_456abc" - mock_response_data = { - "data": {"content": [{"channel": "PUSH", "configuration": {"channel": "PUSH"}}]} - } - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - json=mock_response_data, - status_code=200, - ) - - response = client.get_channel_templates( - version_id=version_id, - channel="PUSH", - search="config_detail", - sort="channel,asc", - page=1, - size=5, - ) - assert response == mock_response_data - assert requests_mock.last_request is not None - assert requests_mock.last_request.qs == { - "channel": ["push"], # Changed to lowercase - "search": ["config_detail"], - "sort": ["channel,asc"], - "page": ["1"], - "size": ["5"], - } - - -def test_get_channel_templates_bad_request_error(client, requests_mock: RequestsMocker): - """Test 400 Bad Request error for get_channel_templates.""" - version_id = "ver_invalid_format" - error_response_data = { - "error": {"errorCode": "BAD_REQUEST", "message": "Invalid version ID format"} - } - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - json=error_response_data, - status_code=400, - ) - response = client.get_channel_templates(version_id=version_id) - assert response == error_response_data - - -def test_get_channel_templates_unauthorized_error( - client, requests_mock: RequestsMocker -): - """Test 401 Unauthorized error for get_channel_templates.""" - version_id = "ver_789def" - error_response_data = { - "error": {"errorCode": "UNAUTHORISED", "message": "Invalid API Key"} - } - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - json=error_response_data, - status_code=401, - ) - response = client.get_channel_templates(version_id=version_id) - assert response == error_response_data - - -def test_get_channel_templates_not_found_error(client, requests_mock: RequestsMocker): - """Test 404 Not Found error for get_channel_templates.""" - version_id = "ver_not_exists" - error_response_data = { - "error": {"errorCode": "NOT_FOUND", "message": "Version not found"} - } - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - json=error_response_data, - status_code=404, - ) - response = client.get_channel_templates(version_id=version_id) - assert response == error_response_data - - -def test_get_channel_templates_network_error(client, requests_mock: RequestsMocker): - """Test network error for get_channel_templates.""" - version_id = "ver_network_issue" - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - exc=requests.exceptions.ConnectionError, - ) - with pytest.raises(requests.exceptions.ConnectionError): - client.get_channel_templates(version_id=version_id) - - -def test_get_channel_templates_http_error_non_json_response( - client, requests_mock: RequestsMocker -): - """Test HTTP error with non-JSON response for get_channel_templates.""" - version_id = "ver_html_error" - requests_mock.get( - f"{client._templates.base_url}/template/versions/{version_id}/channel-templates", - text="Internal Server Error", - status_code=500, - ) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: - client.get_channel_templates(version_id=version_id) - assert "500 Server Error" in str(excinfo.value) - assert ( - "Internal Server Error" in excinfo.value.response.text - ) + mock_request.return_value = mock_response(200, mock_response_data) + + result = self.manager.get_channel_templates( + "version123", channel="EMAIL", page=0, size=5 + ) + + assert len(result) == 1 + assert result[0].channel == "EMAIL" + mock_request.assert_called_once_with( + method="GET", + url=f"{BASE_URL}/api/v1/public/template/versions/version123/channel-templates", + headers={"Authorization": f"Bearer {API_KEY}"}, + json=None, + params={"channel": "EMAIL", "page": 0, "size": 5}, + timeout=10, + ) From 5f48746e29c8767dd119f13f25e08ca5433d9d6d Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 21:33:32 +0530 Subject: [PATCH 26/42] refactor: migrate workflow apis to use base manager --- .cursor/rules/general.mdc | 1 - coverage.xml | 431 +++++++++++++++++------------------- examples/workflows.py | 157 ++++--------- siren/client.py | 26 ++- siren/managers/__init__.py | 2 + siren/managers/workflows.py | 156 +++++++++++++ siren/models/workflows.py | 127 +++++++++++ siren/workflows.py | 191 ---------------- tests/test_workflows.py | 269 +++++++++++++--------- 9 files changed, 721 insertions(+), 639 deletions(-) create mode 100644 siren/managers/workflows.py create mode 100644 siren/models/workflows.py delete mode 100644 siren/workflows.py diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 81225e9..9c5a52f 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -18,7 +18,6 @@ Code Quality: - Use consistent type hints Organization: -- Group by functionality, not by type - Use logical file structure matching domain - Keep files focused on single concerns - Follow consistent naming conventions diff --git a/coverage.xml b/coverage.xml index a2fa9bb..ca0244a 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,7 +16,7 @@ - + @@ -30,12 +30,13 @@ - - - - + + + + - + + @@ -58,26 +59,26 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + @@ -100,137 +101,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -240,10 +116,11 @@ - + + - + @@ -268,15 +145,15 @@ - - + + - + @@ -289,17 +166,17 @@ - - + + - - + + - + @@ -307,20 +184,20 @@ - - - - - + + + + + - - + + - - + + - + @@ -329,50 +206,50 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - - + + - - + + - + - - + + - - + + - - - - + + + + - + - - - - - - + + + + + + - + @@ -380,39 +257,67 @@ - + - - + + - + - + - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -431,11 +336,11 @@ - + - + - + @@ -533,13 +438,13 @@ - - - - - - + + + + + + @@ -615,6 +520,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/workflows.py b/examples/workflows.py index b83ed2e..9051c93 100644 --- a/examples/workflows.py +++ b/examples/workflows.py @@ -1,137 +1,72 @@ -# examples/workflows.py -"""Example script for demonstrating Siren SDK workflow operations.""" +"""Example script demonstrating workflow methods using SirenClient.""" import os import sys -from siren import SirenClient +from siren.client import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError -# This allows running the script directly from the examples directory +# Allow running from examples directory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import json # For pretty printing the response -from requests.exceptions import HTTPError, RequestException # For handling API errors - -def run_trigger_workflow_example(client: SirenClient): - """Demonstrates triggering a workflow using the Siren SDK.""" - print("Attempting to trigger a workflow with data and notify parameters...") - workflow_name = "sampleWorkflow" +def trigger_workflow_example(client: SirenClient) -> None: + """Example of triggering a workflow.""" try: - data_payload = {"subject": "SDK Test OTP", "user_id": "12345"} - notify_payload = { - "notificationType": "email", - "recipient": "test_user@example.com", - "name": "Test User", - } - - response = client.trigger_workflow( - workflow_name=workflow_name, data=data_payload, notify=notify_payload + execution = client.trigger_workflow( + workflow_name="sampleWorkflow", + data={"subject": "Welcome"}, + notify={"email": "user@example.com"}, ) - print("Trigger Workflow Response (with data/notify):") - print(response) + print(f"Workflow triggered: {execution.request_id}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") - except Exception as e: - print(f"An error occurred while triggering workflow '{workflow_name}': {e}") - print("\nAttempting to trigger a workflow with only the workflow name...") - minimal_workflow_name = "another_workflow_name" +def trigger_bulk_workflow_example(client: SirenClient) -> None: + """Example of triggering a bulk workflow.""" try: - response_minimal = client.trigger_workflow(workflow_name=minimal_workflow_name) - print(f"Trigger Workflow Response (minimal for '{minimal_workflow_name}'):") - print(response_minimal) - - except Exception as e: - print( - f"An error occurred while triggering workflow '{minimal_workflow_name}': {e}" + bulk_execution = client.trigger_bulk_workflow( + workflow_name="sampleWorkflow", + notify=[{"email": "user1@example.com"}, {"email": "user2@example.com"}], + data={"template": "welcome"}, ) + print(f"Bulk workflow triggered: {bulk_execution.request_id}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") -def run_trigger_bulk_workflow_example(client: SirenClient): - """Demonstrates triggering workflows in bulk using the Siren SDK.""" - print("\n--- Running Bulk Trigger Workflow Example ---") - - # Example 1: Bulk trigger with common data and multiple notify objects - print( - "\nAttempting to trigger a bulk workflow with common data and multiple notifications..." - ) - bulk_workflow_name_1 = "sampleWorkflow" - common_data_payload = {"campaign_id": "summer_promo_2024"} - notify_list_1 = [ - { - "notificationType": "email", - "recipient": "user1@example.com", - "name": "User One", - "discount_code": "SUMMER10", - }, - { - "notificationType": "sms", - "recipient": "+15551234567", - "product_name": "New Gadget", - "tracking_link": "http://example.com/track/xyz123", - }, - ] - +def schedule_workflow_example(client: SirenClient) -> None: + """Example of scheduling a workflow.""" try: - response_1 = client.trigger_bulk_workflow( - workflow_name=bulk_workflow_name_1, - notify=notify_list_1, - data=common_data_payload, - ) - print( - f"Bulk Trigger Workflow Response (with data for '{bulk_workflow_name_1}'):" - ) - print(response_1) - except Exception as e: - print( - f"An error occurred while triggering bulk workflow '{bulk_workflow_name_1}': {e}" + schedule = client.schedule_workflow( + name="sampleWorkflow123", + schedule_time="21:31:00", + timezone_id="Asia/Kolkata", + start_date="2024-06-11", + workflow_type="DAILY", + workflow_id="acd59a55-1072-41a7-90d9-5554b21aef1b", + input_data={"type": "daily_summary"}, ) - - -def run_schedule_workflow_example(client: SirenClient): - """Demonstrates scheduling workflows using the Siren SDK.""" - print("\n--- Running Schedule Workflow Example ---") - timezone_id = "America/New_York" - schedule_name_once = "My One-Time Task via SDK Example" - schedule_time_once = "19:04:00" - start_date_once = "2025-06-09" - workflow_type_once = "ONCE" - workflow_id_once = "bcd59a55-1072-41a7-90d9-5554b21aef1b" - input_data_once = {"task_name": "sdk_once_example_processing", "details": "Urgent"} - - try: - response_once = client.schedule_workflow( - name=schedule_name_once, - schedule_time=schedule_time_once, - timezone_id=timezone_id, # Can use the same or a different one - start_date=start_date_once, - workflow_type=workflow_type_once, - workflow_id=workflow_id_once, - input_data=input_data_once, - # end_date is omitted for ONCE type - ) - print("Successfully scheduled ONCE workflow. Response:") - print(json.dumps(response_once, indent=2)) - except HTTPError as e: - print(f"HTTP Error scheduling ONCE workflow (Status {e.response.status_code}):") - try: - print(f"Error details: {json.dumps(e.response.json(), indent=2)}") - except json.JSONDecodeError: - print(f"Error details (non-JSON): {e.response.text}") - except RequestException as e: - print(f"Request Error scheduling ONCE workflow: {e}") - except Exception as e: - print(f"An unexpected error occurred: {e}") + print(f"Workflow scheduled: {schedule.id}") + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") if __name__ == "__main__": api_key = os.environ.get("SIREN_API_KEY") if not api_key: print("Error: SIREN_API_KEY environment variable not set.") - print("Please set it before running the example.") sys.exit(1) - siren_client = SirenClient(api_key=api_key) - # run_trigger_workflow_example(siren_client) - # run_trigger_bulk_workflow_example(siren_client) - run_schedule_workflow_example(siren_client) + client = SirenClient(api_key=api_key) + + # trigger_workflow_example(client) + # trigger_bulk_workflow_example(client) + # schedule_workflow_example(client) diff --git a/siren/client.py b/siren/client.py index 972ba16..b1b3656 100644 --- a/siren/client.py +++ b/siren/client.py @@ -8,11 +8,16 @@ from .managers.templates import TemplatesManager from .managers.users import UsersManager from .managers.webhooks import WebhooksManager +from .managers.workflows import WorkflowsManager from .models.messaging import ReplyData from .models.templates import ChannelTemplate, CreatedTemplate, Template from .models.user import User from .models.webhooks import WebhookConfig -from .workflows import WorkflowsManager +from .models.workflows import ( + BulkWorkflowExecutionData, + ScheduleData, + WorkflowExecutionData, +) class SirenClient: @@ -32,8 +37,7 @@ def __init__(self, api_key: str): api_key=self.api_key, base_url=self.BASE_API_URL ) self._workflows = WorkflowsManager( - api_key=self.api_key, - base_url=self.BASE_API_URL, # Note: WorkflowsManager uses /api/v2 internally + api_key=self.api_key, base_url=self.BASE_API_URL ) self._webhooks = WebhooksManager( api_key=self.api_key, base_url=self.BASE_API_URL @@ -170,7 +174,7 @@ def trigger_workflow( workflow_name: str, data: Optional[Dict[str, Any]] = None, notify: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + ) -> WorkflowExecutionData: """Triggers a workflow with the given name and payload. Args: @@ -179,7 +183,7 @@ def trigger_workflow( notify: Specific data for this workflow execution. Returns: - A dictionary containing the API response. + WorkflowExecutionData: Workflow execution details. """ return self._workflows.trigger_workflow( workflow_name=workflow_name, data=data, notify=notify @@ -190,7 +194,7 @@ def trigger_bulk_workflow( workflow_name: str, notify: List[Dict[str, Any]], data: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + ) -> BulkWorkflowExecutionData: """Triggers a workflow in bulk for multiple recipients/notifications. Args: @@ -200,7 +204,7 @@ def trigger_bulk_workflow( data: Common data that will be used across all workflow executions. Returns: - A dictionary containing the API response. + BulkWorkflowExecutionData: Bulk workflow execution details. """ return self._workflows.trigger_bulk_workflow( workflow_name=workflow_name, notify=notify, data=data @@ -216,9 +220,8 @@ def schedule_workflow( workflow_id: str, input_data: Dict[str, Any], end_date: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Schedules a workflow execution. + ) -> ScheduleData: + """Schedules a workflow execution. Args: name: Name of the schedule. @@ -229,6 +232,9 @@ def schedule_workflow( workflow_id: ID of the workflow to schedule. input_data: Input data for the workflow. end_date: Optional end date for the schedule in "YYYY-MM-DD" format. + + Returns: + ScheduleData: Schedule details. """ return self._workflows.schedule_workflow( name=name, diff --git a/siren/managers/__init__.py b/siren/managers/__init__.py index 6faeb6c..094d4a5 100644 --- a/siren/managers/__init__.py +++ b/siren/managers/__init__.py @@ -5,6 +5,7 @@ from .templates import TemplatesManager from .users import UsersManager from .webhooks import WebhooksManager +from .workflows import WorkflowsManager __all__ = [ "BaseManager", @@ -12,4 +13,5 @@ "UsersManager", "MessagingManager", "WebhooksManager", + "WorkflowsManager", ] diff --git a/siren/managers/workflows.py b/siren/managers/workflows.py new file mode 100644 index 0000000..74c6897 --- /dev/null +++ b/siren/managers/workflows.py @@ -0,0 +1,156 @@ +"""Workflows manager using BaseManager architecture.""" + +from typing import Any, Dict, List, Optional + +from ..models.workflows import ( + BulkWorkflowExecutionData, + ScheduleData, + ScheduleWorkflowRequest, + ScheduleWorkflowResponse, + TriggerBulkWorkflowRequest, + TriggerBulkWorkflowResponse, + TriggerWorkflowRequest, + TriggerWorkflowResponse, + WorkflowExecutionData, +) +from .base import BaseManager + + +class WorkflowsManager(BaseManager): + """Manager for workflow operations using BaseManager.""" + + def trigger_workflow( + self, + workflow_name: str, + data: Optional[Dict[str, Any]] = None, + notify: Optional[Dict[str, Any]] = None, + ) -> WorkflowExecutionData: + """Trigger a workflow with the given name and payload. + + Args: + workflow_name: The name of the workflow to execute. + data: Common data for all workflow executions. + notify: Specific data for this workflow execution. + + Returns: + WorkflowExecutionData: Workflow execution details. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + # Store original timeout for custom timeout handling + original_timeout = self.timeout + self.timeout = 10 + + try: + response = self._make_request( + method="POST", + endpoint="/api/v2/workflows/trigger", + request_model=TriggerWorkflowRequest, + response_model=TriggerWorkflowResponse, + data={ + "workflow_name": workflow_name, + "data": data, + "notify": notify, + }, + ) + finally: + # Restore original timeout + self.timeout = original_timeout + return response + + def trigger_bulk_workflow( + self, + workflow_name: str, + notify: List[Dict[str, Any]], + data: Optional[Dict[str, Any]] = None, + ) -> BulkWorkflowExecutionData: + """Trigger a workflow in bulk for multiple recipients/notifications. + + Args: + workflow_name: The name of the workflow to execute. + notify: A list of notification objects, each representing specific data + for a workflow execution. The workflow will be executed for + each element in this list. + data: Common data that will be used across all workflow executions. + + Returns: + BulkWorkflowExecutionData: Bulk workflow execution details. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + # Store original timeout for custom timeout handling + original_timeout = self.timeout + self.timeout = 20 # Increased timeout for bulk + + try: + response = self._make_request( + method="POST", + endpoint="/api/v2/workflows/trigger/bulk", + request_model=TriggerBulkWorkflowRequest, + response_model=TriggerBulkWorkflowResponse, + data={ + "workflow_name": workflow_name, + "notify": notify, + "data": data, + }, + ) + finally: + # Restore original timeout + self.timeout = original_timeout + return response + + def schedule_workflow( + self, + name: str, + schedule_time: str, + timezone_id: str, + start_date: str, + workflow_type: str, + workflow_id: str, + input_data: Dict[str, Any], + end_date: Optional[str] = None, + ) -> ScheduleData: + """Schedule a workflow execution. + + Args: + name: Name of the schedule. + schedule_time: Time for the schedule in "HH:MM:SS" format. + timezone_id: Timezone ID (e.g., "Asia/Kolkata"). + start_date: Start date for the schedule in "YYYY-MM-DD" format. + workflow_type: Type of schedule (e.g., "ONCE", "DAILY"). + workflow_id: ID of the workflow to schedule. + input_data: Input data for the workflow. + end_date: Optional end date for the schedule in "YYYY-MM-DD" format. + + Returns: + ScheduleData: Schedule details. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + # Handle ONCE type special case for end_date + if workflow_type == "ONCE" and end_date is None: + end_date = "" + + response = self._make_request( + method="POST", + endpoint="/api/v1/public/schedules", + request_model=ScheduleWorkflowRequest, + response_model=ScheduleWorkflowResponse, + data={ + "name": name, + "schedule_time": schedule_time, + "timezone_id": timezone_id, + "start_date": start_date, + "workflow_type": workflow_type, + "workflow_id": workflow_id, + "input_data": input_data, + "end_date": end_date, + }, + ) + return response diff --git a/siren/models/workflows.py b/siren/models/workflows.py new file mode 100644 index 0000000..6abc9af --- /dev/null +++ b/siren/models/workflows.py @@ -0,0 +1,127 @@ +"""Workflow-related data models for the Siren SDK.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +from .base import BaseAPIResponse + + +class TriggerWorkflowRequest(BaseModel): + """Request model for triggering a workflow.""" + + workflow_name: str = Field(..., alias="workflowName") + data: Optional[Dict[str, Any]] = None + notify: Optional[Dict[str, Any]] = None + + class Config: + """Pydantic config.""" + + populate_by_name = True + + +class TriggerBulkWorkflowRequest(BaseModel): + """Request model for triggering a bulk workflow.""" + + workflow_name: str = Field(..., alias="workflowName") + notify: List[Dict[str, Any]] + data: Optional[Dict[str, Any]] = None + + class Config: + """Pydantic config.""" + + populate_by_name = True + + +class ScheduleWorkflowRequest(BaseModel): + """Request model for scheduling a workflow.""" + + name: str + schedule_time: str = Field(..., alias="scheduleTime") + timezone_id: str = Field(..., alias="timezoneId") + start_date: str = Field(..., alias="startDate") + workflow_type: str = Field(..., alias="type") + workflow_id: str = Field(..., alias="workflowId") + input_data: Dict[str, Any] = Field(..., alias="inputData") + end_date: Optional[str] = Field(None, alias="endDate") + + class Config: + """Pydantic config.""" + + populate_by_name = True + + +class WorkflowExecutionData(BaseModel): + """Workflow execution response data.""" + + request_id: str = Field(..., alias="requestId") + workflow_execution_id: str = Field(..., alias="workflowExecutionId") + + class Config: + """Pydantic config.""" + + populate_by_name = True + + +class BulkWorkflowExecutionData(BaseModel): + """Bulk workflow execution response data.""" + + request_id: str = Field(..., alias="requestId") + workflow_execution_ids: List[str] = Field(..., alias="workflowExecutionIds") + + class Config: + """Pydantic config.""" + + populate_by_name = True + + +class ScheduleData(BaseModel): + """Schedule response data.""" + + id: str + created_at: Optional[str] = Field(None, alias="createdAt") + updated_at: Optional[str] = Field(None, alias="updatedAt") + deleted_at: Optional[str] = Field(None, alias="deletedAt") + created_by: Optional[str] = Field(None, alias="createdBy") + updated_by: Optional[str] = Field(None, alias="updatedBy") + deleted_by: Optional[str] = Field(None, alias="deletedBy") + project_environment_id: Optional[str] = Field(None, alias="projectEnvironmentId") + name: str + schedule_type: str = Field(..., alias="type") + input_data: Dict[str, Any] = Field(..., alias="inputData") + start_date: str = Field(..., alias="startDate") + end_date: Optional[str] = Field(None, alias="endDate") + schedule_time: str = Field(..., alias="scheduleTime") + timezone_id: str = Field(..., alias="timezoneId") + schedule_day: Optional[List[Any]] = Field(None, alias="scheduleDay") + monthly_schedule_option: Optional[Any] = Field(None, alias="monthlyScheduleOption") + no_of_occurrences: Optional[int] = Field(None, alias="noOfOccurrences") + scheduler_pattern: Optional[str] = Field(None, alias="schedulerPattern") + status: str + workflow: Optional[Any] = None + # For backward compatibility with test data + workflow_id: Optional[str] = Field(None, alias="workflowId") + + class Config: + """Pydantic config.""" + + populate_by_name = True + + +# Response models +class TriggerWorkflowResponse(BaseAPIResponse[WorkflowExecutionData]): + """Response model for trigger workflow.""" + + pass + + +class TriggerBulkWorkflowResponse(BaseAPIResponse[BulkWorkflowExecutionData]): + """Response model for trigger bulk workflow.""" + + pass + + +class ScheduleWorkflowResponse(BaseAPIResponse[ScheduleData]): + """Response model for schedule workflow.""" + + pass diff --git a/siren/workflows.py b/siren/workflows.py deleted file mode 100644 index e74a4f7..0000000 --- a/siren/workflows.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Workflow management for Siren SDK.""" - -from typing import Any, Dict, List, Optional - -import requests - - -class WorkflowsManager: - """Manages workflow-related operations for the Siren API.""" - - def __init__(self, base_url: str, api_key: str): - """Initializes the WorkflowsManager. - - Args: - base_url: The general base URL for the Siren API (e.g., 'https://api.trysiren.io'). - api_key: The API key for authentication. - """ - self.root_base_url = base_url # Store the root base URL - self.api_key = api_key - - def trigger_workflow( - self, - workflow_name: str, - data: Optional[Dict[str, Any]] = None, - notify: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - Triggers a workflow with the given name and payload. - - Args: - workflow_name: The name of the workflow to execute. - data: Common data for all workflow executions. - notify: Specific data for this workflow execution. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.root_base_url}/api/v2/workflows/trigger" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - payload: Dict[str, Any] = {"workflowName": workflow_name} - if data is not None: - payload["data"] = data - if notify is not None: - payload["notify"] = notify - - try: - response = requests.post( - endpoint, headers=headers, json=payload, timeout=10 - ) - response.raise_for_status() # Raises HTTPError for 4XX/5XX - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - # If the error response is not JSON, re-raise the HTTPError - # with the response text for better debugging. - new_err = requests.exceptions.HTTPError( - f"{http_err}\nResponse text: {http_err.response.text}", - response=http_err.response, - ) - raise new_err from http_err - except requests.exceptions.RequestException as req_err: - # For other request errors (e.g., connection issues) - raise req_err - - def trigger_bulk_workflow( - self, - workflow_name: str, - notify: List[ - Dict[str, Any] - ], # notify is a list of dicts and is required for bulk - data: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - Triggers a workflow in bulk for multiple recipients/notifications. - - Args: - workflow_name: The name of the workflow to execute. - notify: A list of notification objects, each representing specific data - for a workflow execution. The workflow will be executed for - each element in this list. - data: Common data that will be used across all workflow executions. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.root_base_url}/api/v2/workflows/trigger/bulk" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - payload: Dict[str, Any] = { - "workflowName": workflow_name, - "notify": notify, # notify is now a list - } - if data is not None: - payload["data"] = data - - try: - response = requests.post( - endpoint, - headers=headers, - json=payload, - timeout=20, # Increased timeout for bulk - ) - response.raise_for_status() # Raises HTTPError for 4XX/5XX - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - new_err = requests.exceptions.HTTPError( - f"{http_err}\nResponse text: {http_err.response.text}", - response=http_err.response, - ) - raise new_err from http_err - except requests.exceptions.RequestException as req_err: - raise req_err - - def schedule_workflow( - self, - name: str, - schedule_time: str, # Format: "HH:MM:SS" - timezone_id: str, # E.g., "Asia/Kolkata" - start_date: str, # Format: "YYYY-MM-DD" - workflow_type: str, # E.g., "ONCE", "DAILY", "WEEKLY" - workflow_id: str, - input_data: Dict[str, Any], - end_date: Optional[str] = None, # Format: "YYYY-MM-DD" - ) -> Dict[str, Any]: - """ - Schedules a workflow execution. - - Args: - name: Name of the schedule. - schedule_time: Time for the schedule in "HH:MM:SS" format. - timezone_id: Timezone ID (e.g., "Asia/Kolkata"). - start_date: Start date for the schedule in "YYYY-MM-DD" format. - workflow_type: Type of schedule (e.g., "ONCE", "DAILY"). - workflow_id: ID of the workflow to schedule. - input_data: Input data for the workflow. - end_date: Optional end date for the schedule in "YYYY-MM-DD" format. - - Returns: - A dictionary containing the API response. - """ - endpoint = f"{self.root_base_url}/api/v1/public/schedules" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - payload: Dict[str, Any] = { - "name": name, - "scheduleTime": schedule_time, - "timezoneId": timezone_id, - "startDate": start_date, - "type": workflow_type, - "workflowId": workflow_id, - "inputData": input_data, - } - if end_date is not None: - payload["endDate"] = end_date - elif ( - workflow_type == "ONCE" - ): # API expects an empty string if not provided for type ONCE - payload["endDate"] = "" - - try: - response = requests.post( - endpoint, headers=headers, json=payload, timeout=10 - ) - response.raise_for_status() # Raises HTTPError for 4XX/5XX - return response.json() - except requests.exceptions.HTTPError as http_err: - try: - return http_err.response.json() - except requests.exceptions.JSONDecodeError: - new_err = requests.exceptions.HTTPError( - f"{http_err}\nResponse text: {http_err.response.text}", - response=http_err.response, - ) - raise new_err from http_err - except requests.exceptions.RequestException as req_err: - raise req_err diff --git a/tests/test_workflows.py b/tests/test_workflows.py index be75b8b..959ec6a 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1,22 +1,27 @@ # tests/test_workflows.py -"""Tests for Siren SDK Workflow operations.""" +"""Test cases for workflows client.""" import pytest import requests from requests_mock import Mocker as RequestsMocker -from siren import SirenClient - -# Constants for testing -API_KEY = "test_api_key_workflow" # Use a distinct key for clarity if needed +from siren.client import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError +from siren.models.workflows import ( + BulkWorkflowExecutionData, + ScheduleData, + WorkflowExecutionData, +) + +API_KEY = "test_api_key_workflows" +MOCK_V2_BASE = "https://api.dev.trysiren.io/api/v2" +MOCK_V1_PUBLIC_BASE = "https://api.dev.trysiren.io/api/v1/public" WORKFLOW_NAME = "test_otp_workflow" -# Access BASE_API_URL as a class attribute of SirenClient -MOCK_V2_BASE = f"{SirenClient.BASE_API_URL}/api/v2" # Construct the v2 base for mocking @pytest.fixture def client() -> SirenClient: - """Provides a SirenClient instance for testing.""" + """Create a SirenClient instance for testing.""" return SirenClient(api_key=API_KEY) @@ -43,7 +48,11 @@ def test_trigger_workflow_success_with_all_params( workflow_name=WORKFLOW_NAME, data=request_data, notify=request_notify ) - assert response == expected_response + # Expect parsed model object + assert isinstance(response, WorkflowExecutionData) + assert response.request_id == "a1b2c3d4-e5f6-7890-1234-567890abcdef" + assert response.workflow_execution_id == "b2c3d4e5-f6a7-8901-2345-67890abcdef0" + history = requests_mock.request_history assert len(history) == 1 assert history[0].method == "POST" @@ -71,7 +80,11 @@ def test_trigger_workflow_success_minimal_params( response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) - assert response == expected_response + # Expect parsed model object + assert isinstance(response, WorkflowExecutionData) + assert response.request_id == "uuid1" + assert response.workflow_execution_id == "uuid2" + history = requests_mock.request_history assert len(history) == 1 assert history[0].json() == { @@ -79,9 +92,6 @@ def test_trigger_workflow_success_minimal_params( } # data and notify are optional -# Error handling tests (similar to test_templates.py) - - def test_trigger_workflow_http_400_error( client: SirenClient, requests_mock: RequestsMocker ): @@ -95,8 +105,11 @@ def test_trigger_workflow_http_400_error( mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post(mock_url, json=error_response, status_code=400) - response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) - assert response == error_response + with pytest.raises(SirenAPIError) as exc_info: + client.trigger_workflow(workflow_name=WORKFLOW_NAME) + + assert exc_info.value.status_code == 400 + assert "BAD_REQUEST" in str(exc_info.value) def test_trigger_workflow_http_401_error( @@ -107,8 +120,10 @@ def test_trigger_workflow_http_401_error( mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post(mock_url, json=error_response, status_code=401) - response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.trigger_workflow(workflow_name=WORKFLOW_NAME) + + assert exc_info.value.status_code == 401 def test_trigger_workflow_http_404_error( @@ -119,8 +134,10 @@ def test_trigger_workflow_http_404_error( mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post(mock_url, json=error_response, status_code=404) - response = client.trigger_workflow(workflow_name="non_existent_workflow") - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.trigger_workflow(workflow_name="non_existent_workflow") + + assert exc_info.value.status_code == 404 def test_trigger_workflow_network_error( @@ -132,11 +149,11 @@ def test_trigger_workflow_network_error( mock_url, exc=requests.exceptions.ConnectionError("Connection failed") ) - with pytest.raises( - requests.exceptions.RequestException - ): # More general than ConnectionError + with pytest.raises(SirenSDKError) as exc_info: client.trigger_workflow(workflow_name=WORKFLOW_NAME) + assert "Network or connection error" in str(exc_info.value) + def test_trigger_workflow_http_error_non_json_response( client: SirenClient, requests_mock: RequestsMocker @@ -146,13 +163,11 @@ def test_trigger_workflow_http_error_non_json_response( non_json_error_text = "Service Unavailable" requests_mock.post(mock_url, text=non_json_error_text, status_code=503) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: + with pytest.raises(SirenSDKError) as exc_info: client.trigger_workflow(workflow_name=WORKFLOW_NAME) - # Check if the original error text is part of the raised exception's message - assert non_json_error_text in str(excinfo.value) - assert excinfo.value.response is not None - assert excinfo.value.response.status_code == 503 + assert "API response was not valid JSON" in str(exc_info.value) + assert exc_info.value.status_code == 503 # --- Tests for trigger_bulk_workflow --- # @@ -191,7 +206,14 @@ def test_trigger_bulk_workflow_success_with_all_params( data=request_data, ) - assert response == expected_response + # Expect parsed model object + assert isinstance(response, BulkWorkflowExecutionData) + assert response.request_id == "d4e5f6a7-b8c9-d0e1-f2a3-b4c5d6e7f8a9" + assert response.workflow_execution_ids == [ + "e5f6a7b8-c9d0-e1f2-a3b4-c5d6e7f8a9b0", + "f6a7b8c9-d0e1-f2a3-b4c5-d6e7f8a9b0c1", + ] + history = requests_mock.request_history assert len(history) == 1 assert history[0].method == "POST" @@ -227,13 +249,17 @@ def test_trigger_bulk_workflow_success_minimal_params( workflow_name=BULK_WORKFLOW_NAME, notify=request_notify_list ) - assert response == expected_response + # Expect parsed model object + assert isinstance(response, BulkWorkflowExecutionData) + assert response.request_id == "uuid_bulk_req" + assert response.workflow_execution_ids == ["uuid_bulk_exec1"] + history = requests_mock.request_history assert len(history) == 1 assert history[0].json() == { "workflowName": BULK_WORKFLOW_NAME, "notify": request_notify_list, - } + } # data is optional def test_trigger_bulk_workflow_http_400_error( @@ -242,18 +268,28 @@ def test_trigger_bulk_workflow_http_400_error( """Test trigger_bulk_workflow handles HTTP 400 Bad Request error.""" error_response = { "data": None, - "error": {"errorCode": "BAD_REQUEST", "message": "Invalid notify payload"}, - "errors": [{"errorCode": "BAD_REQUEST", "message": "Invalid notify payload"}], + "error": { + "errorCode": "INVALID_NOTIFICATION_LIST", + "message": "Invalid notification list", + }, + "errors": [ + { + "errorCode": "INVALID_NOTIFICATION_LIST", + "message": "Invalid notification list", + } + ], "meta": None, } mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post(mock_url, json=error_response, status_code=400) - response = client.trigger_bulk_workflow( - workflow_name=BULK_WORKFLOW_NAME, - notify=[{}], # Example invalid notify - ) - assert response == error_response + with pytest.raises(SirenAPIError) as exc_info: + client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, notify=[{"invalid": "data"}] + ) + + assert exc_info.value.status_code == 400 + assert "INVALID_NOTIFICATION_LIST" in str(exc_info.value) def test_trigger_bulk_workflow_http_401_error( @@ -264,25 +300,30 @@ def test_trigger_bulk_workflow_http_401_error( mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post(mock_url, json=error_response, status_code=401) - response = client.trigger_bulk_workflow( - workflow_name=BULK_WORKFLOW_NAME, notify=[{"recipient": "test@example.com"}] - ) - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.trigger_bulk_workflow( + workflow_name=BULK_WORKFLOW_NAME, + notify=[{"notificationType": "email", "recipient": "test@test.com"}], + ) + + assert exc_info.value.status_code == 401 def test_trigger_bulk_workflow_http_404_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow handles HTTP 404 Not Found error for the workflow.""" + """Test trigger_bulk_workflow handles HTTP 404 Not Found error.""" error_response = {"detail": "Workflow not found."} mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post(mock_url, json=error_response, status_code=404) - response = client.trigger_bulk_workflow( - workflow_name="non_existent_bulk_workflow", - notify=[{"recipient": "test@example.com"}], - ) - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.trigger_bulk_workflow( + workflow_name="non_existent_bulk_workflow", + notify=[{"notificationType": "email", "recipient": "test@test.com"}], + ) + + assert exc_info.value.status_code == 404 def test_trigger_bulk_workflow_network_error( @@ -294,46 +335,45 @@ def test_trigger_bulk_workflow_network_error( mock_url, exc=requests.exceptions.ConnectionError("Bulk connection failed") ) - with pytest.raises(requests.exceptions.RequestException): + with pytest.raises(SirenSDKError) as exc_info: client.trigger_bulk_workflow( workflow_name=BULK_WORKFLOW_NAME, - notify=[{"recipient": "test@example.com"}], + notify=[{"notificationType": "email", "recipient": "test@test.com"}], ) + assert "Network or connection error" in str(exc_info.value) + def test_trigger_bulk_workflow_http_error_non_json_response( client: SirenClient, requests_mock: RequestsMocker ): """Test trigger_bulk_workflow handles HTTP error with non-JSON response.""" mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" - non_json_error_text = "Bulk Service Unavailable" + non_json_error_text = "Service Unavailable" requests_mock.post(mock_url, text=non_json_error_text, status_code=503) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: + with pytest.raises(SirenSDKError) as exc_info: client.trigger_bulk_workflow( workflow_name=BULK_WORKFLOW_NAME, - notify=[{"recipient": "test@example.com"}], + notify=[{"notificationType": "email", "recipient": "test@test.com"}], ) - assert non_json_error_text in str(excinfo.value) - assert excinfo.value.response is not None - assert excinfo.value.response.status_code == 503 + assert "API response was not valid JSON" in str(exc_info.value) + assert exc_info.value.status_code == 503 # --- Tests for schedule_workflow --- # SCHEDULE_NAME = "Test Schedule" SCHEDULE_TIME = "10:00:00" -TIMEZONE_ID = "America/New_York" -START_DATE = "2025-12-01" -END_DATE_SPECIFIED = "2025-12-31" -WORKFLOW_TYPE_ONCE = "ONCE" +TIMEZONE_ID = "Asia/Kolkata" +START_DATE = "2024-01-15" +END_DATE_SPECIFIED = "2024-01-31" WORKFLOW_TYPE_DAILY = "DAILY" -SCHEDULE_WORKFLOW_ID = "wf_schedule_abc123" +WORKFLOW_TYPE_ONCE = "ONCE" +SCHEDULE_WORKFLOW_ID = "wf_abc123def456" INPUT_DATA = {"param1": "value1", "param2": 123} -MOCK_V1_PUBLIC_BASE = f"{SirenClient.BASE_API_URL}/api/v1/public" - def test_schedule_workflow_success_all_params( client: SirenClient, requests_mock: RequestsMocker @@ -370,7 +410,19 @@ def test_schedule_workflow_success_all_params( end_date=END_DATE_SPECIFIED, ) - assert response == expected_api_response + # Expect parsed model object + assert isinstance(response, ScheduleData) + assert response.id == "sch_12345" + assert response.name == SCHEDULE_NAME + assert response.schedule_type == WORKFLOW_TYPE_DAILY + assert response.start_date == START_DATE + assert response.end_date == END_DATE_SPECIFIED + assert response.schedule_time == SCHEDULE_TIME + assert response.timezone_id == TIMEZONE_ID + assert response.workflow_id == SCHEDULE_WORKFLOW_ID + assert response.input_data == INPUT_DATA + assert response.status == "ACTIVE" + history = requests_mock.request_history assert len(history) == 1 assert history[0].method == "POST" @@ -423,7 +475,19 @@ def test_schedule_workflow_success_once_no_end_date( end_date=None, # Explicitly None ) - assert response == expected_api_response + # Expect parsed model object + assert isinstance(response, ScheduleData) + assert response.id == "sch_67890" + assert response.name == SCHEDULE_NAME + assert response.schedule_type == WORKFLOW_TYPE_ONCE + assert response.start_date == START_DATE + assert response.end_date == START_DATE + assert response.schedule_time == SCHEDULE_TIME + assert response.timezone_id == TIMEZONE_ID + assert response.workflow_id == SCHEDULE_WORKFLOW_ID + assert response.input_data == INPUT_DATA + assert response.status == "ACTIVE" + history = requests_mock.request_history assert len(history) == 1 assert history[0].json() == { @@ -451,16 +515,18 @@ def test_schedule_workflow_http_400_error( mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=error_response, status_code=400) - response = client.schedule_workflow( - name=SCHEDULE_NAME, - schedule_time="invalid-time", # Intentionally invalid - timezone_id=TIMEZONE_ID, - start_date=START_DATE, - workflow_type=WORKFLOW_TYPE_ONCE, - workflow_id=SCHEDULE_WORKFLOW_ID, - input_data=INPUT_DATA, - ) - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time="invalid-time", # Intentionally invalid + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + ) + + assert exc_info.value.status_code == 400 def test_schedule_workflow_http_401_error( @@ -471,16 +537,18 @@ def test_schedule_workflow_http_401_error( mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=error_response, status_code=401) - response = client.schedule_workflow( - name=SCHEDULE_NAME, - schedule_time=SCHEDULE_TIME, - timezone_id=TIMEZONE_ID, - start_date=START_DATE, - workflow_type=WORKFLOW_TYPE_ONCE, - workflow_id=SCHEDULE_WORKFLOW_ID, - input_data=INPUT_DATA, - ) - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id=SCHEDULE_WORKFLOW_ID, + input_data=INPUT_DATA, + ) + + assert exc_info.value.status_code == 401 def test_schedule_workflow_http_404_error( @@ -491,16 +559,18 @@ def test_schedule_workflow_http_404_error( mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=error_response, status_code=404) - response = client.schedule_workflow( - name=SCHEDULE_NAME, - schedule_time=SCHEDULE_TIME, - timezone_id=TIMEZONE_ID, - start_date=START_DATE, - workflow_type=WORKFLOW_TYPE_ONCE, - workflow_id="wf_non_existent_xyz789", - input_data=INPUT_DATA, - ) - assert response == error_response + with pytest.raises(SirenSDKError) as exc_info: + client.schedule_workflow( + name=SCHEDULE_NAME, + schedule_time=SCHEDULE_TIME, + timezone_id=TIMEZONE_ID, + start_date=START_DATE, + workflow_type=WORKFLOW_TYPE_ONCE, + workflow_id="wf_non_existent_xyz789", + input_data=INPUT_DATA, + ) + + assert exc_info.value.status_code == 404 def test_schedule_workflow_network_error( @@ -512,7 +582,7 @@ def test_schedule_workflow_network_error( mock_url, exc=requests.exceptions.ConnectionError("Connection failed") ) - with pytest.raises(requests.exceptions.RequestException): + with pytest.raises(SirenSDKError) as exc_info: client.schedule_workflow( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, @@ -523,6 +593,8 @@ def test_schedule_workflow_network_error( input_data=INPUT_DATA, ) + assert "Network or connection error" in str(exc_info.value) + def test_schedule_workflow_http_error_non_json_response( client: SirenClient, requests_mock: RequestsMocker @@ -532,7 +604,7 @@ def test_schedule_workflow_http_error_non_json_response( non_json_error_text = "Gateway Timeout" requests_mock.post(mock_url, text=non_json_error_text, status_code=504) - with pytest.raises(requests.exceptions.HTTPError) as excinfo: + with pytest.raises(SirenSDKError) as exc_info: client.schedule_workflow( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, @@ -543,6 +615,5 @@ def test_schedule_workflow_http_error_non_json_response( input_data=INPUT_DATA, ) - assert non_json_error_text in str(excinfo.value) - assert excinfo.value.response is not None - assert excinfo.value.response.status_code == 504 + assert "API response was not valid JSON" in str(exc_info.value) + assert exc_info.value.status_code == 504 From bd0f0c847cd5fef465d91f17d0cfca6cf37216d7 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 11 Jun 2025 21:45:14 +0530 Subject: [PATCH 27/42] doc: update Readme --- README.md | 556 +++++------------------------------------------------- 1 file changed, 44 insertions(+), 512 deletions(-) diff --git a/README.md b/README.md index f9042ab..9440dbf 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,12 @@ This is the official Python SDK for the [Siren notification platform](https://do ## Table of Contents -- [Siren AI Python SDK (`siren-ai`)](#siren-ai-python-sdk-siren-ai) - - [Table of Contents](#table-of-contents) - - [Installation](#installation) - - [Basic Usage](#basic-usage) - - [Features](#features) - - [`get_templates()`](#get_templates) - - [`create_template()`](#create_template) - - [`update_template()`](#update_template) - - [`delete_template()`](#delete_template) - - [`publish_template()`](#publish_template) - - [`create_channel_templates()`](#create_channel_templates) - - [`get_channel_templates()`](#get_channel_templates) - - [`send_message()`](#send_message) - - [`get_replies()`](#get_replies) - - [`get_message_status()`](#get_message_status) - - [`configure_notifications_webhook()`](#configure_notifications_webhook) - - [`configure_inbound_message_webhook()`](#configure_inbound_message_webhook) - - [`trigger_workflow()`](#trigger_workflow) - - [`trigger_bulk_workflow()`](#trigger_bulk_workflow) - - [`schedule_workflow()`](#schedule_workflow) - - [`add_user()`](#add_user) - - [`update_user()`](#update_user) - - [`delete_user()`](#delete_user) - - [Getting Started for Package Developers](#getting-started-for-package-developers) - - [Prerequisites](#prerequisites) - - [Setup Steps](#setup-steps) - - [Code Style \& Linting](#code-style--linting) - - [Running Tests](#running-tests) - - [Submitting Changes](#submitting-changes) - - [Future Enhancements](#future-enhancements) +- [Installation](#installation) +- [Basic Usage](#basic-usage) +- [SDK Methods](#sdk-methods) +- [Examples](#examples) +- [For Package Developers](#getting-started-for-package-developers) +- [Future Enhancements](#future-enhancements) ## Installation @@ -46,495 +22,57 @@ pip install siren-ai ```python from siren import SirenClient -# Initialize the client by passing your API key: +# Initialize the client client = SirenClient(api_key="YOUR_SIREN_API_KEY") -# Example: Get templates -# Get the first 5 templates -templates_response = client.get_templates(page=0, size=5) -print(templates_response) -``` - -## Features - -The Siren-AI Python SDK provides an interface to interact with the Siren API. - -### `get_templates()` - -Retrieves a list of notification templates. - -**Parameters:** -* Supports optional filtering (`tag_names`, `search`), sorting (`sort`), and pagination (`page`, `size`). Refer to the official Siren API documentation for detailed parameter usage. - -**Example:** -```python -# Get 5 templates, sorted by name -templates_response = client.get_templates(size=5, sort="name,asc") -print(templates_response) -``` - -### `create_template()` - -Creates a new notification template. - -**Parameters:** -* `template_data` (Dict[str, Any]): A dictionary representing the template structure. Key fields include `name`, `configurations`, etc. For the detailed payload structure, please refer to the official Siren API documentation. - -**Example:** -```python -new_template_payload = { - "name": "SDK_Quick_Template", - "configurations": { - "EMAIL": { - "subject": "Quick Test", - "body": "

Hello via SDK!

" - } - } -} -created_template_response = client.create_template(new_template_payload) -print(created_template_response) -``` - -### `update_template()` - -Updates an existing notification template. - -**Parameters:** -* `template_id` (str): The ID of the template to update. -* `template_data` (Dict[str, Any]): A dictionary containing the template fields to update. For the detailed payload structure, please refer to the official Siren API documentation. - -**Example:** -```python -existing_template_id = "YOUR_EXISTING_TEMPLATE_ID" -update_payload = { - "name": "Updated SDK Template Name", - "description": "This template was updated via the SDK.", - "tagNames": ["sdk-updated"] -} - -updated_template_response = client.update_template(existing_template_id, update_payload) -print(updated_template_response) - -``` - -### `delete_template()` - -Deletes an existing notification template. - -**Parameters:** -* `template_id` (str): The ID of the template to delete. - -**Example:** -```python -template_id_to_delete = "YOUR_TEMPLATE_ID_TO_DELETE" - -delete_response = client.delete_template(template_id_to_delete) -print(delete_response) - -``` - -### `publish_template()` - -Publishes an existing notification template, making its latest draft version live. - -**Parameters:** -* `template_id` (str): The ID of the template to publish. - -**Example:** -```python -template_id_to_publish = "YOUR_TEMPLATE_ID_TO_PUBLISH" - -publish_response = client.publish_template(template_id_to_publish) -print(publish_response) -``` - -### `create_channel_templates()` - -Creates or updates channel-specific templates for a given template ID. This method allows you to define different content and settings for various notification channels (e.g., EMAIL, SMS) associated with a single parent template. - -**Parameters:** -* `template_id` (str): The ID of the template for which to create/update channel templates. -* `channel_templates` (Dict[str, Any]): A dictionary where keys are channel names (e.g., "EMAIL", "SMS") and values are the channel-specific template objects. Each object should conform to the structure expected by the Siren API for that channel. - -**Example:** -```python -template_id = "YOUR_TEMPLATE_ID" -channel_templates_payload = { - "SMS": { - "body": "New SMS content via SDK for {{variable_name}}", - "channel": "SMS", - "isFlash": False - }, - "EMAIL": { - "subject": "Channel Config Update for {{variable_name}}", - "body": "

Updated email body for channel config.

", - "channel": "EMAIL" - } -} - -response = client.create_channel_templates(template_id, channel_templates_payload) -print(response) -``` - -### `get_channel_templates()` - -Retrieves the channel templates associated with a specific template version ID. - -**Parameters:** -* `version_id` (str): The ID of the template version for which to fetch channel templates. -* Optional query parameters: - * `channel` (str): Filter by a specific channel (e.g., "EMAIL", "SMS"). - * `search` (str): Search term to filter channel templates. - * `sort` (str): Sort order (e.g., "channel,asc"). - * `page` (int): Page number for pagination. - * `size` (int): Number of items per page. - -**Example:** -```python -# Replace with an actual template version ID -template_version_id = "YOUR_TEMPLATE_VERSION_ID" - -# Get all channel templates for a version -channel_templates_response = client.get_channel_templates(version_id=template_version_id) -print(channel_templates_response) - -# Get SMS channel templates for a version, first page, 5 items -sms_channel_templates = client.get_channel_templates( - version_id=template_version_id, - channel="SMS", - page=0, - size=5 +# Send a message +message_id = client.send_message( + template_name="welcome_email", + channel="EMAIL", + recipient_type="direct", + recipient_value="user@example.com", + template_variables={"user_name": "John Doe"} ) -print(sms_channel_templates) +print(f"Message sent! ID: {message_id}") ``` -### `send_message()` - -Sends a message using a specified template to a recipient via a chosen channel. - -**Parameters:** -* `template_name` (str): The name of the template to use. -* `channel` (str): The channel through which to send the message (e.g., "EMAIL", "SLACK", "SMS"). -* `recipient_type` (str): The type of recipient identifier (e.g., "direct", "user_id"). -* `recipient_value` (str): The actual value of the recipient identifier (e.g., "recipient@example.com", "U123XYZ", "+15551234567"). -* `template_variables` (Optional[Dict[str, Any]]): A dictionary of variables to be interpolated into the template. Defaults to `None`. +## SDK Methods -**Example:** -```python -# IMPORTANT: Replace with your actual template name, channel, and recipient details. -template_name = "your_template_name_here" -channel = "EMAIL" -recipient_type = "direct" -recipient_value = "recipient@example.com" - -# Optional: Provide template variables if your template requires them -template_variables = { - "user_name": "Alex Doe", - "order_id": "ORD98765" -} - -response = client.send_message( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - template_variables=template_variables # Pass None or omit if no variables -) -print(response) - -if response and response.get("data") and response.get("data", {}).get("notificationId"): - print(f"Message sent successfully! Notification ID: {response['data']['notificationId']}") -else: - print(f"Failed to send message. Error: {response.get('error', 'Unknown error')}") -``` - -### `get_replies()` - -Retrieves the replies for a specific message ID. - -**Parameters:** -* `message_id` (str): The ID of the message for which to retrieve replies. - -**Example:** -```python -# IMPORTANT: Replace with an actual message ID -message_id_to_check = "YOUR_MESSAGE_ID" - -replies_response = client.get_replies(message_id=message_id_to_check) -print(replies_response) -``` - -### `get_message_status()` - -Retrieves the status of a specific message ID (e.g., "SENT", "DELIVERED", "FAILED"). - -**Parameters:** -* `message_id` (str): The ID of the message for which to retrieve the status. - -**Example:** -```python -# IMPORTANT: Replace with an actual message ID -message_id_to_check = "YOUR_MESSAGE_ID" - -status_response = client.get_message_status(message_id=message_id_to_check) -print(f"Message status: {status_response['data']['status']}") -``` - -### `configure_notifications_webhook()` - -Configures the webhook URL for receiving status updates and other notifications from Siren. - -**Parameters:** -* `url` (str): The URL that Siren will send POST requests to for notifications. - -**Example:** -```python -webhook_url = "https://your-service.com/siren/notifications" -response = client.configure_notifications_webhook(url=webhook_url) -print(response) -``` - -### `configure_inbound_message_webhook()` - -Configures the webhook URL for receiving inbound messages forwarded by Siren. - -**Parameters:** -* `url` (str): The URL that Siren will send POST requests to for inbound messages. - -**Example:** -```python -webhook_url = "https://your-service.com/siren/inbound" -response = client.configure_inbound_message_webhook(url=webhook_url) -print(response) -``` - -### `trigger_workflow()` - -Triggers a specified workflow with the given data and notification payloads. - -**Parameters:** -* `workflow_name` (str): The name of the workflow to be executed. -* `data` (Optional[Dict[str, Any]]): Common data that will be used across all workflow executions. Defaults to `None`. -* `notify` (Optional[Dict[str, Any]]): Specific data for this particular workflow execution. Defaults to `None`. - -**Example:** -```python -workflow_to_trigger = "otp_workflow" -data_payload = { - "subject": "Your One-Time Password", - "user_id": "user_12345" -} -notify_payload = { - "notificationType": "email", - "recipient": "customer@example.com", - "name": "John Doe" -} - -trigger_response = client.trigger_workflow( - workflow_name=workflow_to_trigger, - data=data_payload, - notify=notify_payload -) -print(trigger_response) - -# Example: Triggering a workflow with only the name -minimal_trigger_response = client.trigger_workflow(workflow_name="simple_workflow") -print(minimal_trigger_response) -``` - -### `trigger_bulk_workflow()` - -Triggers a specified workflow in bulk for multiple recipients/notifications, with common data applied to all and specific data for each notification. - -**Parameters:** -* `workflow_name` (str): The name of the workflow to be executed. -* `notify` (List[Dict[str, Any]]): A list of notification objects. Your workflow will be executed for each object in this list. Each object contains specific data for that particular workflow execution. -* `data` (Optional[Dict[str, Any]]): Common data that will be used across all workflow executions. Defaults to `None`. - -**Example:** -```python -workflow_to_trigger_bulk = "onboarding_sequence" -common_payload = { - "campaign_source": "webinar_signup_2024" -} -individual_notifications = [ - { - "notificationType": "email", - "recipient": "user_a@example.com", - "name": "Alex", - "join_date": "2024-06-01" - }, - { - "notificationType": "sms", - "recipient": "+15550001111", - "segment": "trial_user" - }, - { - "notificationType": "email", - "recipient": "user_b@example.com", - "name": "Beth", - "join_date": "2024-06-02" - } -] - -bulk_response = client.trigger_bulk_workflow( - workflow_name=workflow_to_trigger_bulk, - notify=individual_notifications, - data=common_payload -) -print(bulk_response) - -# Example: Bulk triggering with only notify list (no common data) -minimal_bulk_response = client.trigger_bulk_workflow( - workflow_name="simple_bulk_actions", - notify=[ - {"action": "activate_feature_x", "user_id": "user_c@example.com"}, - {"action": "send_survey_y", "user_id": "user_d@example.com"} - ] -) -print(minimal_bulk_response) -``` - -### `schedule_workflow()` - -Schedules a workflow to run at a future time, either once or on a recurring basis. - -**Parameters:** -* `name` (str): A descriptive name for the schedule. -* `schedule_time` (str): The time of day for the schedule to run (e.g., "09:00:00"). -* `timezone_id` (str): The IANA timezone ID for the schedule (e.g., "America/New_York"). -* `start_date` (str): The date when the schedule should start (e.g., "2025-08-01"). -* `workflow_type` (str): The type of recurrence (e.g., "ONCE", "DAILY", "WEEKLY", "MONTHLY"). -* `workflow_id` (str): The ID of the workflow to schedule. -* `input_data` (Dict[str, Any]): The input data for the workflow. -* `end_date` (Optional[str]): The date when the schedule should end (inclusive). Required for recurring schedules other than "ONCE". Defaults to `None`. - -**Example:** -```python -# Schedule a workflow to run daily -daily_schedule_response = client.schedule_workflow( - name="Daily Report Generation", - schedule_time="09:00:00", - timezone_id="America/New_York", - start_date="2025-08-01", - workflow_type="DAILY", - workflow_id="YOUR_WORKFLOW_ID", - input_data={"report_type": "sales_summary"}, - end_date="2025-08-31" -) -print(daily_schedule_response) - -# Schedule a workflow to run once -once_schedule_response = client.schedule_workflow( - name="One-Time Data Processing", - schedule_time="15:30:00", - timezone_id="America/New_York", - start_date="2025-09-15", - workflow_type="ONCE", - workflow_id="YOUR_OTHER_WORKFLOW_ID", - input_data={"task_id": "process_batch_xyz"} - # end_date is not provided for "ONCE" type -) -print(once_schedule_response) -``` - -### `add_user()` - -Creates a new user or updates an existing user if a user with the given `unique_id` already exists. - -**Parameters:** -* `unique_id` (str): The unique identifier for the user. This is a required field. -* `first_name` (Optional[str]): The user's first name. -* `last_name` (Optional[str]): The user's last name. -* `reference_id` (Optional[str]): An external reference ID for the user. -* `whatsapp` (Optional[str]): The user's WhatsApp number (e.g., "+14155552671"). -* `active_channels` (Optional[List[str]]): A list of channels the user is active on (e.g., `["EMAIL", "SMS", "WHATSAPP"]`). -* `active` (Optional[bool]): Boolean indicating if the user is active. Defaults to `True` if not specified by the API. -* `email` (Optional[str]): The user's email address. -* `phone` (Optional[str]): The user's phone number (e.g., "+14155552671"). -* `attributes` (Optional[Dict[str, Any]]): A dictionary of additional custom attributes for the user. - -**Example:** -```python -# Add a new user -new_user_payload = { - "unique_id": "sdk_user_123", - "first_name": "SDK", - "last_name": "TestUser", - "email": "sdk.testuser@example.com", - "active_channels": ["EMAIL"], - "attributes": {"source": "python_sdk_example"} -} -response = client.add_user(**new_user_payload) -print(response) - -# Update an existing user (e.g., add a phone number) -update_user_payload = { - "unique_id": "sdk_user_123", # Same unique_id - "phone": "+15551234567" -} -response = client.add_user(**update_user_payload) -print(response) -``` - -### `update_user()` - -Updates an existing user's information. - -**Parameters:** -* `unique_id` (str): The unique identifier of the user to update. This is a required field and identifies which user to update. -* `first_name` (Optional[str]): The user's first name. -* `last_name` (Optional[str]): The user's last name. -* `reference_id` (Optional[str]): An external reference ID for the user. -* `whatsapp` (Optional[str]): The user's WhatsApp number (e.g., "+14155552671"). -* `active_channels` (Optional[List[str]]): A list of channels the user is active on (e.g., `["EMAIL", "SMS", "WHATSAPP"]`). -* `active` (Optional[bool]): Boolean indicating if the user is active. -* `email` (Optional[str]): The user's email address. -* `phone` (Optional[str]): The user's phone number (e.g., "+14155552671"). -* `attributes` (Optional[Dict[str, Any]]): A dictionary of additional custom attributes for the user. - -**Example:** -```python -# Update user information -update_response = client.update_user( - unique_id="sdk_user_123", - first_name="Jane", - last_name="Smith", - email="jane.smith@example.com", - active_channels=["EMAIL", "SLACK"], - attributes={"department": "Engineering", "role": "Developer"} -) -print(update_response) +The Siren-AI Python SDK provides an interface to interact with the Siren API. -# Update only specific fields -partial_update_response = client.update_user( - unique_id="sdk_user_123", - phone="+15551234567", - active=True -) -print(partial_update_response) -``` +**Templates** +- **`get_templates()`** - Retrieves a list of notification templates with optional filtering, sorting, and pagination +- **`create_template()`** - Creates a new notification template +- **`update_template()`** - Updates an existing notification template +- **`delete_template()`** - Deletes an existing notification template +- **`publish_template()`** - Publishes a template, making its latest draft version live +- **`create_channel_templates()`** - Creates or updates channel-specific templates (EMAIL, SMS, etc.) +- **`get_channel_templates()`** - Retrieves channel templates for a specific template version -### `delete_user()` +**Messaging** +- **`send_message()`** - Sends a message using a template to a recipient via a chosen channel +- **`get_replies()`** - Retrieves replies for a specific message ID +- **`get_message_status()`** - Retrieves the status of a specific message (SENT, DELIVERED, FAILED, etc.) -Deletes an existing user. +**Workflows** +- **`trigger_workflow()`** - Triggers a workflow with given data and notification payloads +- **`trigger_bulk_workflow()`** - Triggers a workflow in bulk for multiple recipients +- **`schedule_workflow()`** - Schedules a workflow to run at a future time (once or recurring) -**Parameters:** -* `unique_id` (str): The unique identifier of the user to delete. This is a required field. +**Webhooks** +- **`configure_notifications_webhook()`** - Configures webhook URL for receiving status updates +- **`configure_inbound_message_webhook()`** - Configures webhook URL for receiving inbound messages -**Returns:** -* `bool`: Returns `True` if the user was successfully deleted. +**Users** +- **`add_user()`** - Creates a new user or updates existing user with given unique_id +- **`update_user()`** - Updates an existing user's information +- **`delete_user()`** - Deletes an existing user -**Example:** -```python -# Delete a user -deleted = client.delete_user("sdk_user_123") -print(f"User deleted: {deleted}") # True -``` +## Examples -## Getting Started for Package Developers +For detailed usage examples of all SDK methods, see the [examples](./examples/) folder. -This guide will help you set up your environment to contribute to the `siren-ai` SDK. +## For Package Developers ### Prerequisites @@ -596,10 +134,4 @@ This will execute all tests defined in the `tests/` directory. * Create a feature branch for your changes. * Commit your changes (pre-commit hooks will run). -* Push your branch and open a Pull Request against the main repository branch. - -## Future Enhancements - -- Expand SDK for full Siren API endpoint coverage. -- Implement typed response models (e.g., Pydantic) for robust data handling. -- Introduce custom SDK exceptions for improved error diagnostics. +* Push your branch and open a Pull Request against the `develop` repository branch. From 83756455c85eed217ad98b7ece919e8c9eeac5b7 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 11:25:48 +0530 Subject: [PATCH 28/42] chore: handle env selection --- .cursor/rules/project_context.mdc | 3 +- README.md | 13 +- coverage.xml | 283 +++++++++++++++--------------- siren/client.py | 44 +++-- tests/conftest.py | 2 +- tests/test_client.py | 72 +++++++- tests/test_users.py | 2 +- tests/test_webhooks.py | 4 +- tests/test_workflows.py | 2 +- 9 files changed, 261 insertions(+), 164 deletions(-) diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index 9b36c7f..a50f1d1 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -66,7 +66,7 @@ siren-ai/ - **Authentication**: Bearer token in `Authorization` header - **Status Handling**: Explicit `if status_code == 200` checks instead of `response.ok` - **API Versioning**: Templates/Users/Messaging/Webhooks use `/api/v1/public/`, Workflows use `/api/v2/` -- **Base URL**: `https://api.dev.trysiren.io` (TODO: auto-select based on API key type) +- **Environment Support**: Production (`https://api.trysiren.io`) default, dev (`https://api.dev.trysiren.io`) via `env="dev"` or `SIREN_ENV=dev` ## Tech Stack @@ -96,7 +96,6 @@ siren-ai/ **Architecture Enhancements**: - Migrate WorkflowsManager to BaseManager pattern -- Auto-select API URL based on API key type (dev/prod) or environment variable - Add retry logic for transient network failures - Add request/response logging capabilities diff --git a/README.md b/README.md index 9440dbf..5561e7c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Siren AI Python SDK (`siren-ai`) +# Siren Python SDK This is the official Python SDK for the [Siren notification platform](https://docs.trysiren.io). @@ -34,6 +34,9 @@ message_id = client.send_message( template_variables={"user_name": "John Doe"} ) print(f"Message sent! ID: {message_id}") + +# to specify env +client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") ``` ## SDK Methods @@ -74,6 +77,13 @@ For detailed usage examples of all SDK methods, see the [examples](./examples/) ## For Package Developers +### Environment Configuration + +For testing the SDK, set these environment variables: + +- **`SIREN_API_KEY`**: Your API key from the Siren dashboard +- **`SIREN_ENV`**: Set to `dev` for development/testing (defaults to `prod`) + ### Prerequisites * Git @@ -114,6 +124,7 @@ For detailed usage examples of all SDK methods, see the [examples](./examples/) ``` You are now ready to contribute to the `siren-ai` SDK! + Try `$ python examples/webhooks.py` ### Code Style & Linting diff --git a/coverage.xml b/coverage.xml index ca0244a..13c0397 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,12 +16,12 @@ - + - - + + @@ -31,51 +31,58 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + @@ -106,7 +113,7 @@ - + @@ -120,7 +127,7 @@ - + @@ -145,15 +152,15 @@ - - + + - + @@ -172,11 +179,11 @@ - - + + - + @@ -184,20 +191,20 @@ - - - - - + + + + + - - + + - - + + - + @@ -206,50 +213,50 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - - + + - - + + - + - - + + - - + + - - - - + + + + - + - - - - - - + + + + + + - + @@ -257,28 +264,28 @@ - + - - + + - + - + - - - + + + - - - + + + @@ -531,45 +538,35 @@ - - - - + + + - - - + + - - + + - - + - - - - - - + + + + + - - - - - - @@ -581,15 +578,25 @@ + + + + + - + + - - + + + + + + diff --git a/siren/client.py b/siren/client.py index b1b3656..86a52c5 100644 --- a/siren/client.py +++ b/siren/client.py @@ -1,6 +1,7 @@ """Siren API client implementation.""" -from typing import Any, Dict, List, Optional +import os +from typing import Any, Dict, List, Literal, Optional from pydantic import EmailStr @@ -23,29 +24,38 @@ class SirenClient: """Client for interacting with the Siren API.""" - # TODO: Implement logic to select API URL based on API key type (dev/prod) or environment variable - BASE_API_URL = "https://api.dev.trysiren.io" # General base URL + # Environment-specific API URLs + API_URLS = { + "dev": "https://api.dev.trysiren.io", + "prod": "https://api.trysiren.io", + } - def __init__(self, api_key: str): + def __init__(self, api_key: str, env: Optional[Literal["dev", "prod"]] = None): """Initialize the SirenClient. Args: api_key: The API key for authentication. + env: Environment to use ('dev' or 'prod'). + If not provided, defaults to 'prod' or uses SIREN_ENV environment variable. """ self.api_key = api_key - self._templates = TemplatesManager( - api_key=self.api_key, base_url=self.BASE_API_URL - ) - self._workflows = WorkflowsManager( - api_key=self.api_key, base_url=self.BASE_API_URL - ) - self._webhooks = WebhooksManager( - api_key=self.api_key, base_url=self.BASE_API_URL - ) - self._messaging = MessagingManager( - api_key=self.api_key, base_url=self.BASE_API_URL - ) - self._users = UsersManager(api_key=self.api_key, base_url=self.BASE_API_URL) + + # Determine environment and base URL + if env is None: + env = os.getenv("SIREN_ENV", "prod") + + if env not in self.API_URLS: + raise ValueError( + f"Invalid environment '{env}'. Must be one of: {list(self.API_URLS.keys())}" + ) + + self.env = env + self.base_url = self.API_URLS[env] + self._templates = TemplatesManager(api_key=self.api_key, base_url=self.base_url) + self._workflows = WorkflowsManager(api_key=self.api_key, base_url=self.base_url) + self._webhooks = WebhooksManager(api_key=self.api_key, base_url=self.base_url) + self._messaging = MessagingManager(api_key=self.api_key, base_url=self.base_url) + self._users = UsersManager(api_key=self.api_key, base_url=self.base_url) def get_templates( self, diff --git a/tests/conftest.py b/tests/conftest.py index 3d599b8..477ad2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,4 +14,4 @@ @pytest.fixture def client(): """Provides a SirenClient instance for testing, using a dummy API key.""" - return SirenClient(api_key="test_api_key") + return SirenClient(api_key="test_api_key", env="dev") diff --git a/tests/test_client.py b/tests/test_client.py index f81cf23..0fc990d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,6 +6,8 @@ # Ensure the 'siren' package in the parent directory can be imported: sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from siren.client import SirenClient + # The 'client' fixture is automatically available from conftest.py @@ -23,5 +25,73 @@ def test_siren_client_initialization(client): client._templates.api_key == "test_api_key" ), "Templates manager should have API key" assert ( - client._templates.base_url == client.BASE_API_URL + client._templates.base_url == client.base_url ), "Templates manager should use the base URL from client" + + +def test_siren_client_default_environment(): + """Test that SirenClient defaults to 'prod' environment.""" + # Ensure SIREN_ENV is not set for this test + original_env = os.environ.get("SIREN_ENV") + if "SIREN_ENV" in os.environ: + del os.environ["SIREN_ENV"] + + try: + client = SirenClient(api_key="test_key") + assert client.env == "prod" + assert client.base_url == "https://api.trysiren.io" + finally: + # Restore original environment variable if it existed + if original_env is not None: + os.environ["SIREN_ENV"] = original_env + + +def test_siren_client_explicit_environment(): + """Test that SirenClient uses explicit environment parameter.""" + # Test dev environment + client_dev = SirenClient(api_key="test_key", env="dev") + assert client_dev.env == "dev" + assert client_dev.base_url == "https://api.dev.trysiren.io" + + # Test prod environment + client_prod = SirenClient(api_key="test_key", env="prod") + assert client_prod.env == "prod" + assert client_prod.base_url == "https://api.trysiren.io" + + +def test_siren_client_environment_variable(): + """Test that SirenClient uses SIREN_ENV environment variable.""" + # Set environment variable + os.environ["SIREN_ENV"] = "dev" + + try: + client = SirenClient(api_key="test_key") + assert client.env == "dev" + assert client.base_url == "https://api.dev.trysiren.io" + finally: + # Clean up + del os.environ["SIREN_ENV"] + + +def test_siren_client_explicit_env_overrides_env_var(): + """Test that explicit env parameter overrides environment variable.""" + # Set environment variable to dev + os.environ["SIREN_ENV"] = "dev" + + try: + # But explicitly pass prod + client = SirenClient(api_key="test_key", env="prod") + assert client.env == "prod" + assert client.base_url == "https://api.trysiren.io" + finally: + # Clean up + del os.environ["SIREN_ENV"] + + +def test_siren_client_invalid_environment(): + """Test that SirenClient raises error for invalid environment.""" + try: + SirenClient(api_key="test_key", env="invalid") + assert False, "Should have raised ValueError for invalid environment" + except ValueError as e: + assert "Invalid environment 'invalid'" in str(e) diff --git a/tests/test_users.py b/tests/test_users.py index 7194b65..32059b3 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -27,7 +27,7 @@ def users_manager(): @pytest.fixture def siren_client(): """Fixture to create a SirenClient instance.""" - return SirenClient(api_key=MOCK_API_KEY) + return SirenClient(api_key=MOCK_API_KEY, env="dev") def mock_response( diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 868216f..bcdbe35 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -171,7 +171,7 @@ def test_webhook_network_error(self, mock_request, method_name: str): def test_siren_client_configure_notifications_webhook(): """Test SirenClient.configure_notifications_webhook calls WebhooksManager correctly.""" - client = SirenClient(api_key=API_KEY) + client = SirenClient(api_key=API_KEY, env="dev") with patch.object( client._webhooks, "configure_notifications_webhook" @@ -190,7 +190,7 @@ def test_siren_client_configure_notifications_webhook(): def test_siren_client_configure_inbound_message_webhook(): """Test SirenClient.configure_inbound_message_webhook calls WebhooksManager correctly.""" - client = SirenClient(api_key=API_KEY) + client = SirenClient(api_key=API_KEY, env="dev") with patch.object( client._webhooks, "configure_inbound_message_webhook" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 959ec6a..2157552 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -22,7 +22,7 @@ @pytest.fixture def client() -> SirenClient: """Create a SirenClient instance for testing.""" - return SirenClient(api_key=API_KEY) + return SirenClient(api_key=API_KEY, env="dev") def test_trigger_workflow_success_with_all_params( From 8b93b4249bed360b0fce32287823b62dff4bff33 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 11:27:31 +0530 Subject: [PATCH 29/42] update doc --- .cursor/rules/project_context.mdc | 1 - 1 file changed, 1 deletion(-) diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index a50f1d1..1ae9088 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -95,7 +95,6 @@ siren-ai/ ## TODO / Future Areas **Architecture Enhancements**: -- Migrate WorkflowsManager to BaseManager pattern - Add retry logic for transient network failures - Add request/response logging capabilities From 25b002fd62aec036d6f28bd8b5da19350f2d4bec Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 12:59:22 +0530 Subject: [PATCH 30/42] chore: group methods using namespaces --- .cursor/rules/project_context.mdc | 51 +-- coverage.xml | 85 ++--- examples/messages.py | 6 +- examples/templates.py | 14 +- examples/users.py | 6 +- examples/webhooks.py | 4 +- examples/workflows.py | 6 +- siren/client.py | 450 ++--------------------- siren/clients/__init__.py | 17 + siren/{managers => clients}/base.py | 8 +- siren/{managers => clients}/messaging.py | 12 +- siren/{managers => clients}/templates.py | 48 +-- siren/{managers => clients}/users.py | 14 +- siren/{managers => clients}/webhooks.py | 12 +- siren/{managers => clients}/workflows.py | 14 +- siren/managers/__init__.py | 17 - tests/test_client.py | 75 +++- tests/test_messaging.py | 40 +- tests/test_templates.py | 76 ++-- tests/test_users.py | 138 ++++--- tests/test_webhooks.py | 52 ++- tests/test_workflows.py | 80 ++-- 22 files changed, 439 insertions(+), 786 deletions(-) create mode 100644 siren/clients/__init__.py rename siren/{managers => clients}/base.py (96%) rename siren/{managers => clients}/messaging.py (91%) rename siren/{managers => clients}/templates.py (93%) rename siren/{managers => clients}/users.py (85%) rename siren/{managers => clients}/webhooks.py (82%) rename siren/{managers => clients}/workflows.py (95%) delete mode 100644 siren/managers/__init__.py diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index 1ae9088..10a9c58 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -7,15 +7,15 @@ alwaysApply: false ## Project Summary -Official Python SDK for the [Siren notification platform](mdc:https:/docs.trysiren.io). Provides type-safe interface for managing templates, workflows, users, messaging, and webhooks. Built with Pydantic validation, structured error handling, and modular manager-based architecture. +Official Python SDK for the [Siren notification platform](https://docs.trysiren.io). Provides type-safe interface for managing templates, workflows, users, messaging, and webhooks. Built with Pydantic validation, structured error handling, and modular client-based architecture. ## Key Features / Functional Modules -- **Templates** - Create, update, delete, publish templates and channel configurations → `siren/managers/templates.py` -- **Users** - Add, update, delete users → `siren/managers/users.py` -- **Messaging** - Send messages, get replies, track status → `siren/managers/messaging.py` -- **Webhooks** - Configure notification and inbound webhooks → `siren/managers/webhooks.py` -- **Workflows** - Trigger single/bulk workflows and scheduling → `siren/workflows.py` +- **Templates** - Create, update, delete, publish templates and channel configurations → `siren/clients/templates.py` +- **Users** - Add, update, delete users → `siren/clients/users.py` +- **Messaging** - Send messages, get replies, track status → `siren/clients/messaging.py` +- **Webhooks** - Configure notification and inbound webhooks → `siren/clients/webhooks.py` +- **Workflows** - Trigger single/bulk workflows and scheduling → `siren/clients/workflows.py` - **Client** - Unified API entry point → `siren/client.py` ## Codebase Structure Overview @@ -25,20 +25,21 @@ siren-ai/ ├── siren/ # Main SDK package │ ├── client.py # Main SirenClient - unified API entry point │ ├── exceptions.py # Custom exception classes (SirenAPIError, SirenSDKError) -│ ├── workflows.py # WorkflowsManager - workflow operations (legacy direct HTTP) -│ ├── managers/ # BaseManager implementations (core pattern) -│ │ ├── base.py # BaseManager - shared HTTP/error handling -│ │ ├── templates.py # TemplatesManager - template operations -│ │ ├── users.py # UsersManager - user management -│ │ ├── messaging.py # MessagingManager - message operations -│ │ └── webhooks.py # WebhooksManager - webhook configuration +│ ├── clients/ # Domain client implementations (core pattern) +│ │ ├── base.py # BaseClient - shared HTTP/error handling +│ │ ├── templates.py # TemplateClient - template operations +│ │ ├── users.py # UserClient - user management +│ │ ├── messaging.py # MessageClient - message operations +│ │ ├── webhooks.py # WebhookClient - webhook configuration +│ │ └── workflows.py # WorkflowClient - workflow operations │ └── models/ # Pydantic data models │ ├── base.py # Base response models and common patterns │ ├── templates.py # Template-specific models │ ├── user.py # User-specific models │ ├── messaging.py # Messaging models -│ └── webhooks.py # Webhook models -├── tests/ # Comprehensive test suite with ~95% coverage +│ ├── webhooks.py # Webhook models +│ └── workflows.py # Workflow models +├── tests/ # Comprehensive test suite with ~92% coverage ├── examples/ # Usage examples for each module ├── pyproject.toml # Project configuration, dependencies, tools └── README.md # Installation, usage, and API documentation @@ -47,19 +48,19 @@ siren-ai/ ## Architecture & Data Flow **Layered Architecture**: -- **Client** (`SirenClient`) - Thin facade delegating to managers -- **Managers** (`TemplatesManager`, `UsersManager`, etc.) - Domain-specific API handlers, inherit from `BaseManager` for unified HTTP/error handling +- **Client** (`SirenClient`) - Thin facade delegating to domain clients +- **Domain Clients** (`TemplateClient`, `UserClient`, etc.) - Domain-specific API handlers, inherit from `BaseClient` for unified HTTP/error handling - **Models** (Pydantic) - Request/response validation, field aliasing (snake_case ↔ camelCase) - **Exceptions** - `SirenAPIError` (API errors: 400/401/404) vs `SirenSDKError` (SDK issues: network/validation) -**BaseManager Pattern** (Core Architecture): -- All managers inherit from `BaseManager` for consistent HTTP handling +**BaseClient Pattern** (Core Architecture): +- All domain clients inherit from `BaseClient` for consistent HTTP handling - Requires both `request_model` and `response_model` for JSON operations - Automatic Pydantic validation, error handling, and response parsing - Common patterns: `DeleteResponse[None]` for 204 responses, flexible models with optional fields -**Request Flow**: Client → Manager → HTTP Request → API → Response → Model → Client -- Managers prepare requests with Pydantic validation → HTTP to Siren API → Responses parsed through models → Errors become structured exceptions +**Request Flow**: Client → Domain Client → HTTP Request → API → Response → Model → Client +- Domain clients prepare requests with Pydantic validation → HTTP to Siren API → Responses parsed through models → Errors become structured exceptions **Implementation Details**: - **HTTP Client**: `requests` library with 10s timeout (hardcoded, TODO: make configurable) @@ -76,21 +77,21 @@ siren-ai/ ## Testing **Strategy**: `requests-mock` with realistic API data -**Organization**: One test file per manager, shared `client` fixture +**Organization**: One test file per domain client, shared `client` fixture **Philosophy**: SDK testing focuses on request formatting, response parsing, error propagation - not API business logic ## Key Files - **`siren/client.py`** - Main client interface -- **`siren/managers/base.py`** - BaseManager with unified HTTP/error handling (core pattern) -- **`siren/managers/templates.py`** - Most complex manager, full BaseManager patterns +- **`siren/clients/base.py`** - BaseClient with unified HTTP/error handling (core pattern) +- **`siren/clients/templates.py`** - Most complex domain client, full BaseClient patterns - **`siren/models/base.py`** - Core models and error handling - **`siren/exceptions.py`** - Exception patterns ## Gotchas **Field Serialization**: Always use `by_alias=True` when calling `model_dump()` -**BaseManager Requirements**: Both request_model and response_model needed for JSON operations +**BaseClient Requirements**: Both request_model and response_model needed for JSON operations ## TODO / Future Areas diff --git a/coverage.xml b/coverage.xml index 13c0397..a3f85ba 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,7 +16,7 @@ - + @@ -35,54 +35,33 @@ + + + - - + - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -113,9 +92,9 @@ - + - + @@ -127,7 +106,7 @@ - + @@ -183,7 +162,7 @@ - + @@ -204,7 +183,7 @@ - + @@ -227,9 +206,9 @@ - - - + + + @@ -256,7 +235,7 @@ - + @@ -272,7 +251,7 @@ - + @@ -288,7 +267,7 @@ - + diff --git a/examples/messages.py b/examples/messages.py index f41c243..18aa85f 100644 --- a/examples/messages.py +++ b/examples/messages.py @@ -13,7 +13,7 @@ def send_message_example(client: SirenClient) -> str: """Example of sending a message.""" try: - message_id = client.send_message( + message_id = client.message.send( template_name="sampleTemplate", channel="SLACK", recipient_type="direct", @@ -31,7 +31,7 @@ def send_message_example(client: SirenClient) -> str: def get_message_status_example(client: SirenClient, message_id: str) -> None: """Example of getting message status.""" try: - status = client.get_message_status(message_id=message_id) + status = client.message.get_status(message_id=message_id) print(f"Message status: {status}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") @@ -42,7 +42,7 @@ def get_message_status_example(client: SirenClient, message_id: str) -> None: def get_replies_example(client: SirenClient, message_id: str) -> None: """Example of getting message replies.""" try: - replies = client.get_replies(message_id=message_id) + replies = client.message.get_replies(message_id=message_id) print(f"Found {len(replies)} replies:") for i, reply in enumerate(replies): print(f" Reply {i + 1}: {reply.text} (User: {reply.user})") diff --git a/examples/templates.py b/examples/templates.py index fcab153..a0fc195 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -13,7 +13,7 @@ def get_templates_example(client: SirenClient) -> None: """Example of getting templates.""" try: - templates = client.get_templates(page=0, size=2) + templates = client.template.get(page=0, size=2) print(f"Retrieved {len(templates)} templates") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") @@ -28,7 +28,7 @@ def create_template_example(client: SirenClient) -> str: timestamp = int(time.time()) try: - created = client.create_template( + created = client.template.create( name=f"SDK_Example_Template_{timestamp}", description="Test template from SDK", tag_names=["sdk-test", "example"], @@ -56,7 +56,7 @@ def create_template_example(client: SirenClient) -> str: def update_template_example(client: SirenClient, template_id: str) -> None: """Example of updating a template.""" try: - updated = client.update_template( + updated = client.template.update( template_id, name="Updated_SDK_Example", description="Updated description from SDK", @@ -72,7 +72,7 @@ def update_template_example(client: SirenClient, template_id: str) -> None: def publish_template_example(client: SirenClient, template_id: str): """Example of publishing a template.""" try: - published = client.publish_template(template_id) + published = client.template.publish(template_id) print(f"Published template: {published.id}") return published except SirenAPIError as e: @@ -86,7 +86,7 @@ def publish_template_example(client: SirenClient, template_id: str): def create_channel_templates_example(client: SirenClient, template_id: str) -> None: """Example of creating channel templates for a template.""" try: - result = client.create_channel_templates( + result = client.template.create_channel_templates( template_id, SMS={ "body": "Hello {{user_name}}! This is from SDK.", @@ -112,7 +112,7 @@ def create_channel_templates_example(client: SirenClient, template_id: str) -> N def get_channel_templates_example(client: SirenClient, version_id: str) -> None: """Example of getting channel templates for a template version.""" try: - result = client.get_channel_templates(version_id, page=0, size=5) + result = client.template.get_channel_templates(version_id, page=0, size=5) print(f"Retrieved {len(result)} channel templates") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") @@ -123,7 +123,7 @@ def get_channel_templates_example(client: SirenClient, version_id: str) -> None: def delete_template_example(client: SirenClient, template_id: str) -> None: """Example of deleting a template.""" try: - result = client.delete_template(template_id) + result = client.template.delete(template_id) if result: print(f"Successfully deleted template: {template_id}") else: diff --git a/examples/users.py b/examples/users.py index 1322df6..1b92426 100644 --- a/examples/users.py +++ b/examples/users.py @@ -22,7 +22,7 @@ def add_user_example(client: SirenClient) -> str: } try: - created_user = client.add_user(**user) + created_user = client.user.add(**user) print(f"Created user: {created_user.id}") return created_user.unique_id except SirenAPIError as e: @@ -34,7 +34,7 @@ def add_user_example(client: SirenClient) -> str: def update_user_example(client: SirenClient, unique_id: str) -> None: """Example of updating a user.""" try: - updated_user = client.update_user( + updated_user = client.user.update( unique_id, first_name="Jane", last_name="Smith", @@ -51,7 +51,7 @@ def update_user_example(client: SirenClient, unique_id: str) -> None: def delete_user_example(client: SirenClient, unique_id: str) -> None: """Example of deleting a user.""" try: - deleted = client.delete_user(unique_id) + deleted = client.user.delete(unique_id) print(f"Deleted user: {deleted}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") diff --git a/examples/webhooks.py b/examples/webhooks.py index 890168a..f1b6de4 100644 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -12,7 +12,7 @@ def configure_notifications_webhook_example(client: SirenClient, webhook_url: str): """Example of configuring the notifications webhook.""" try: - webhook_config = client.configure_notifications_webhook(url=webhook_url) + webhook_config = client.webhook.configure_notifications(url=webhook_url) print( f"Notifications webhook configured: {webhook_config.url} (key: {webhook_config.verification_key})" ) @@ -26,7 +26,7 @@ def configure_notifications_webhook_example(client: SirenClient, webhook_url: st def configure_inbound_message_webhook_example(client: SirenClient, webhook_url: str): """Example of configuring the inbound message webhook.""" try: - webhook_config = client.configure_inbound_message_webhook(url=webhook_url) + webhook_config = client.webhook.configure_inbound(url=webhook_url) print( f"Inbound webhook configured: {webhook_config.url} (key: {webhook_config.verification_key})" ) diff --git a/examples/workflows.py b/examples/workflows.py index 9051c93..a3efe6e 100644 --- a/examples/workflows.py +++ b/examples/workflows.py @@ -13,7 +13,7 @@ def trigger_workflow_example(client: SirenClient) -> None: """Example of triggering a workflow.""" try: - execution = client.trigger_workflow( + execution = client.workflow.trigger( workflow_name="sampleWorkflow", data={"subject": "Welcome"}, notify={"email": "user@example.com"}, @@ -28,7 +28,7 @@ def trigger_workflow_example(client: SirenClient) -> None: def trigger_bulk_workflow_example(client: SirenClient) -> None: """Example of triggering a bulk workflow.""" try: - bulk_execution = client.trigger_bulk_workflow( + bulk_execution = client.workflow.trigger_bulk( workflow_name="sampleWorkflow", notify=[{"email": "user1@example.com"}, {"email": "user2@example.com"}], data={"template": "welcome"}, @@ -43,7 +43,7 @@ def trigger_bulk_workflow_example(client: SirenClient) -> None: def schedule_workflow_example(client: SirenClient) -> None: """Example of scheduling a workflow.""" try: - schedule = client.schedule_workflow( + schedule = client.workflow.schedule( name="sampleWorkflow123", schedule_time="21:31:00", timezone_id="Asia/Kolkata", diff --git a/siren/client.py b/siren/client.py index 86a52c5..c3a6155 100644 --- a/siren/client.py +++ b/siren/client.py @@ -1,24 +1,13 @@ """Siren API client implementation.""" import os -from typing import Any, Dict, List, Literal, Optional +from typing import Literal, Optional -from pydantic import EmailStr - -from .managers.messaging import MessagingManager -from .managers.templates import TemplatesManager -from .managers.users import UsersManager -from .managers.webhooks import WebhooksManager -from .managers.workflows import WorkflowsManager -from .models.messaging import ReplyData -from .models.templates import ChannelTemplate, CreatedTemplate, Template -from .models.user import User -from .models.webhooks import WebhookConfig -from .models.workflows import ( - BulkWorkflowExecutionData, - ScheduleData, - WorkflowExecutionData, -) +from .clients.messaging import MessageClient +from .clients.templates import TemplateClient +from .clients.users import UserClient +from .clients.webhooks import WebhookClient +from .clients.workflows import WorkflowClient class SirenClient: @@ -51,402 +40,41 @@ def __init__(self, api_key: str, env: Optional[Literal["dev", "prod"]] = None): self.env = env self.base_url = self.API_URLS[env] - self._templates = TemplatesManager(api_key=self.api_key, base_url=self.base_url) - self._workflows = WorkflowsManager(api_key=self.api_key, base_url=self.base_url) - self._webhooks = WebhooksManager(api_key=self.api_key, base_url=self.base_url) - self._messaging = MessagingManager(api_key=self.api_key, base_url=self.base_url) - self._users = UsersManager(api_key=self.api_key, base_url=self.base_url) - - def get_templates( - self, - tag_names: Optional[str] = None, - search: Optional[str] = None, - sort: Optional[str] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> List[Template]: - """Fetch templates. - - Args: - tag_names: Filter by tag names. - search: Search by field. - sort: Sort by field. - page: Page number. - size: Page size. - - Returns: - List[Template]: A list of Template models. - """ - return self._templates.get_templates( - tag_names=tag_names, - search=search, - sort=sort, - page=page, - size=size, - ) - - def create_template(self, **template_data) -> CreatedTemplate: - """Create a new template. - - Args: - **template_data: Template attributes (name, description, tag_names, variables, configurations). - - Returns: - CreatedTemplate: A CreatedTemplate model representing the created template. - """ - return self._templates.create_template(**template_data) - - def update_template(self, template_id: str, **template_data) -> Template: - """Update an existing template. - - Args: - template_id: The ID of the template to update. - **template_data: Template attributes to update (name, description, tag_names, variables). - - Returns: - Template: A Template model representing the updated template. - """ - return self._templates.update_template(template_id, **template_data) - - def delete_template(self, template_id: str) -> bool: - """Delete an existing template. - - Args: - template_id: The ID of the template to delete. - - Returns: - bool: True if deletion was successful. - """ - return self._templates.delete_template(template_id) - - def publish_template(self, template_id: str) -> Template: - """Publish an existing template. - - Args: - template_id: The ID of the template to publish. - - Returns: - Template: A Template model representing the published template. - """ - return self._templates.publish_template(template_id) - - def create_channel_templates( - self, template_id: str, **channel_templates_data - ) -> List[ChannelTemplate]: - """Create or update channel templates for a specific template. - - Args: - template_id: The ID of the template for which to create channel templates. - **channel_templates_data: Channel templates configuration where keys are - channel names (e.g., "EMAIL", "SMS") and values - are the channel-specific template objects. - - Returns: - List[ChannelTemplate]: List of created channel template objects. - """ - return self._templates.create_channel_templates( - template_id, **channel_templates_data - ) - - def get_channel_templates( - self, - version_id: str, - channel: Optional[str] = None, - search: Optional[str] = None, - sort: Optional[str] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> List[ChannelTemplate]: - """Get channel templates for a specific template version. - - Args: - version_id: The ID of the template version for which to fetch channel templates. - channel: Filter by channel type (e.g., "EMAIL", "SMS"). - search: Search by field. - sort: Sort by field. - page: Page number. - size: Page size. - - Returns: - List[ChannelTemplate]: List of channel template objects. - """ - return self._templates.get_channel_templates( - version_id=version_id, - channel=channel, - search=search, - sort=sort, - page=page, - size=size, - ) - - def trigger_workflow( - self, - workflow_name: str, - data: Optional[Dict[str, Any]] = None, - notify: Optional[Dict[str, Any]] = None, - ) -> WorkflowExecutionData: - """Triggers a workflow with the given name and payload. - - Args: - workflow_name: The name of the workflow to execute. - data: Common data for all workflow executions. - notify: Specific data for this workflow execution. - - Returns: - WorkflowExecutionData: Workflow execution details. - """ - return self._workflows.trigger_workflow( - workflow_name=workflow_name, data=data, notify=notify - ) - - def trigger_bulk_workflow( - self, - workflow_name: str, - notify: List[Dict[str, Any]], - data: Optional[Dict[str, Any]] = None, - ) -> BulkWorkflowExecutionData: - """Triggers a workflow in bulk for multiple recipients/notifications. - - Args: - workflow_name: The name of the workflow to execute. - notify: A list of notification objects, each representing specific data - for a workflow execution. - data: Common data that will be used across all workflow executions. - - Returns: - BulkWorkflowExecutionData: Bulk workflow execution details. - """ - return self._workflows.trigger_bulk_workflow( - workflow_name=workflow_name, notify=notify, data=data - ) - - def schedule_workflow( - self, - name: str, - schedule_time: str, - timezone_id: str, - start_date: str, - workflow_type: str, - workflow_id: str, - input_data: Dict[str, Any], - end_date: Optional[str] = None, - ) -> ScheduleData: - """Schedules a workflow execution. - - Args: - name: Name of the schedule. - schedule_time: Time for the schedule in "HH:MM:SS" format. - timezone_id: Timezone ID (e.g., "Asia/Kolkata"). - start_date: Start date for the schedule in "YYYY-MM-DD" format. - workflow_type: Type of schedule (e.g., "ONCE", "DAILY"). - workflow_id: ID of the workflow to schedule. - input_data: Input data for the workflow. - end_date: Optional end date for the schedule in "YYYY-MM-DD" format. - - Returns: - ScheduleData: Schedule details. - """ - return self._workflows.schedule_workflow( - name=name, - schedule_time=schedule_time, - timezone_id=timezone_id, - start_date=start_date, - workflow_type=workflow_type, - workflow_id=workflow_id, - input_data=input_data, - end_date=end_date, + self._template_client = TemplateClient( + api_key=self.api_key, base_url=self.base_url ) - - def add_user( - self, - unique_id: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - reference_id: Optional[str] = None, - whatsapp: Optional[str] = None, - active_channels: Optional[List[str]] = None, - active: Optional[bool] = None, - email: Optional[EmailStr] = None, - phone: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - **kwargs, - ) -> User: - """ - Creates a user. - - Args: - unique_id: Unique identifier for the user. - first_name: The first name of the user. - last_name: The last name of the user. - reference_id: Reference ID for the user. - whatsapp: WhatsApp number for the user. - active_channels: List of active channels for the user. - active: Whether the user is active. - email: Email address of the user. - phone: Phone number of the user. - attributes: Additional custom attributes for the user. - **kwargs: Additional user data. - - Returns: - User: A User model representing the created or updated user. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - return self._users.add_user( - unique_id=unique_id, - first_name=first_name, - last_name=last_name, - reference_id=reference_id, - whatsapp=whatsapp, - active_channels=active_channels, - active=active, - email=email, - phone=phone, - attributes=attributes, - **kwargs, + self._workflow_client = WorkflowClient( + api_key=self.api_key, base_url=self.base_url ) - - def update_user( - self, - unique_id: str, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - reference_id: Optional[str] = None, - whatsapp: Optional[str] = None, - active_channels: Optional[List[str]] = None, - active: Optional[bool] = None, - email: Optional[EmailStr] = None, - phone: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - **kwargs, - ) -> User: - """ - Updates a user. - - Args: - unique_id: The unique ID of the user to update. - first_name: The first name of the user. - last_name: The last name of the user. - reference_id: Reference ID for the user. - whatsapp: WhatsApp number for the user. - active_channels: List of active channels for the user. - active: Whether the user is active. - email: Email address of the user. - phone: Phone number of the user. - attributes: Additional custom attributes for the user. - **kwargs: Additional user data. - - Returns: - User: A User model representing the updated user. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - return self._users.update_user( - unique_id=unique_id, - first_name=first_name, - last_name=last_name, - reference_id=reference_id, - whatsapp=whatsapp, - active_channels=active_channels, - active=active, - email=email, - phone=phone, - attributes=attributes, - **kwargs, + self._message_client = MessageClient( + api_key=self.api_key, base_url=self.base_url ) - - def delete_user(self, unique_id: str) -> bool: - """ - Deletes a user. - - Args: - unique_id: The unique ID of the user to delete. - - Returns: - bool: True if the user was successfully deleted. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - return self._users.delete_user(unique_id) - - def send_message( - self, - template_name: str, - channel: str, - recipient_type: str, - recipient_value: str, - template_variables: Optional[Dict[str, Any]] = None, - ) -> str: - """Send a message using a specific template. - - Args: - template_name: The name of the template to use. - channel: The channel to send the message through (e.g., "SLACK", "EMAIL"). - recipient_type: The type of recipient (e.g., "direct"). - recipient_value: The identifier for the recipient (e.g., Slack user ID, email address). - template_variables: A dictionary of variables to populate the template. - - Returns: - The message ID of the sent message. - """ - return self._messaging.send_message( - template_name=template_name, - channel=channel, - recipient_type=recipient_type, - recipient_value=recipient_value, - template_variables=template_variables, + self._user_client = UserClient(api_key=self.api_key, base_url=self.base_url) + self._webhook_client = WebhookClient( + api_key=self.api_key, base_url=self.base_url ) - def get_replies(self, message_id: str) -> List[ReplyData]: - """Retrieve replies for a specific message. - - Args: - message_id: The ID of the message for which to retrieve replies. - - Returns: - A list of reply objects containing message details. - """ - return self._messaging.get_replies(message_id=message_id) - - def get_message_status(self, message_id: str) -> str: - """Retrieve the status of a specific message. - - Args: - message_id: The ID of the message for which to retrieve the status. - - Returns: - The status of the message (e.g., "DELIVERED", "PENDING"). - """ - return self._messaging.get_message_status(message_id=message_id) - - # Webhook Management - - def configure_notifications_webhook(self, url: str) -> WebhookConfig: - """ - Configure the webhook for outgoing notifications. - - Args: - url: The URL to be configured for the notifications webhook. - - Returns: - The webhook configuration object with URL, headers, and verification key. - """ - return self._webhooks.configure_notifications_webhook(url=url) - - def configure_inbound_message_webhook(self, url: str) -> WebhookConfig: - """ - Configure the webhook for inbound messages. - - Args: - url: The URL to be configured for the inbound message webhook. - - Returns: - The webhook configuration object with URL, headers, and verification key. - """ - return self._webhooks.configure_inbound_message_webhook(url=url) + @property + def template(self) -> TemplateClient: + """Access to template operations.""" + return self._template_client + + @property + def workflow(self) -> WorkflowClient: + """Access to workflow operations.""" + return self._workflow_client + + @property + def message(self) -> MessageClient: + """Access to message operations.""" + return self._message_client + + @property + def user(self) -> UserClient: + """Access to user operations.""" + return self._user_client + + @property + def webhook(self) -> WebhookClient: + """Access to webhook operations.""" + return self._webhook_client diff --git a/siren/clients/__init__.py b/siren/clients/__init__.py new file mode 100644 index 0000000..739e8e2 --- /dev/null +++ b/siren/clients/__init__.py @@ -0,0 +1,17 @@ +"""Client classes for the Siren SDK.""" + +from .base import BaseClient +from .messaging import MessageClient +from .templates import TemplateClient +from .users import UserClient +from .webhooks import WebhookClient +from .workflows import WorkflowClient + +__all__ = [ + "BaseClient", + "TemplateClient", + "UserClient", + "MessageClient", + "WebhookClient", + "WorkflowClient", +] diff --git a/siren/managers/base.py b/siren/clients/base.py similarity index 96% rename from siren/managers/base.py rename to siren/clients/base.py index ef1a234..ada7930 100644 --- a/siren/managers/base.py +++ b/siren/clients/base.py @@ -1,4 +1,4 @@ -"""Base manager class for all Siren API managers.""" +"""Base client class for all Siren API clients.""" from typing import Any, Dict, Optional, Type, Union @@ -8,11 +8,11 @@ from ..exceptions import SirenAPIError, SirenSDKError -class BaseManager: - """Base class for all API managers with common HTTP handling.""" +class BaseClient: + """Base class for all API clients with common HTTP handling.""" def __init__(self, api_key: str, base_url: str, timeout: int = 10): - """Initialize the BaseManager. + """Initialize the BaseClient. Args: api_key: The API key for authentication. diff --git a/siren/managers/messaging.py b/siren/clients/messaging.py similarity index 91% rename from siren/managers/messaging.py rename to siren/clients/messaging.py index f1a9952..47b960d 100644 --- a/siren/managers/messaging.py +++ b/siren/clients/messaging.py @@ -1,4 +1,4 @@ -"""Manages messaging-related API interactions for the Siren SDK.""" +"""Messaging client for the Siren SDK.""" from typing import Any, Dict, List, Optional @@ -9,13 +9,13 @@ SendMessageRequest, SendMessageResponse, ) -from .base import BaseManager +from .base import BaseClient -class MessagingManager(BaseManager): - """Manages direct message sending operations.""" +class MessageClient(BaseClient): + """Client for direct message operations.""" - def send_message( + def send( self, template_name: str, channel: str, @@ -56,7 +56,7 @@ def send_message( ) return response.message_id - def get_message_status(self, message_id: str) -> str: + def get_status(self, message_id: str) -> str: """Retrieve the status of a specific message. Args: diff --git a/siren/managers/templates.py b/siren/clients/templates.py similarity index 93% rename from siren/managers/templates.py rename to siren/clients/templates.py index e0056ee..9958399 100644 --- a/siren/managers/templates.py +++ b/siren/clients/templates.py @@ -1,4 +1,4 @@ -"""New templates manager using BaseManager architecture.""" +"""New templates client using BaseClient architecture.""" from typing import List, Optional @@ -17,13 +17,13 @@ UpdateTemplateRequest, UpdateTemplateResponse, ) -from .base import BaseManager +from .base import BaseClient -class TemplatesManager(BaseManager): - """Manager for template operations using BaseManager.""" +class TemplateClient(BaseClient): + """Client for template operations.""" - def get_templates( + def get( self, tag_names: Optional[str] = None, search: Optional[str] = None, @@ -67,52 +67,52 @@ def get_templates( ) return response - def update_template(self, template_id: str, **template_data) -> Template: - """Update an existing template. + def create(self, **template_data) -> CreatedTemplate: + """Create a new template. Args: - template_id: The ID of the template to update. - **template_data: Template attributes matching the UpdateTemplateRequest model fields. + **template_data: Template attributes matching the CreateTemplateRequest model fields. Returns: - Template: A Template model representing the updated template. + CreatedTemplate: A CreatedTemplate model representing the created template. Raises: SirenAPIError: If the API returns an error response. SirenSDKError: If there's an SDK-level issue (network, parsing, etc). """ response = self._make_request( - method="PUT", - endpoint=f"/api/v1/public/template/{template_id}", - request_model=UpdateTemplateRequest, - response_model=UpdateTemplateResponse, + method="POST", + endpoint="/api/v1/public/template", + request_model=CreateTemplateRequest, + response_model=CreateTemplateResponse, data=template_data, ) return response - def create_template(self, **template_data) -> CreatedTemplate: - """Create a new template. + def update(self, template_id: str, **template_data) -> Template: + """Update an existing template. Args: - **template_data: Template attributes matching the CreateTemplateRequest model fields. + template_id: The ID of the template to update. + **template_data: Template attributes matching the UpdateTemplateRequest model fields. Returns: - CreatedTemplate: A CreatedTemplate model representing the created template. + Template: A Template model representing the updated template. Raises: SirenAPIError: If the API returns an error response. SirenSDKError: If there's an SDK-level issue (network, parsing, etc). """ response = self._make_request( - method="POST", - endpoint="/api/v1/public/template", - request_model=CreateTemplateRequest, - response_model=CreateTemplateResponse, + method="PUT", + endpoint=f"/api/v1/public/template/{template_id}", + request_model=UpdateTemplateRequest, + response_model=UpdateTemplateResponse, data=template_data, ) return response - def delete_template(self, template_id: str) -> bool: + def delete(self, template_id: str) -> bool: """Delete a template. Args: @@ -132,7 +132,7 @@ def delete_template(self, template_id: str) -> bool: expected_status=204, ) - def publish_template(self, template_id: str) -> Template: + def publish(self, template_id: str) -> Template: """Publish a template. Args: diff --git a/siren/managers/users.py b/siren/clients/users.py similarity index 85% rename from siren/managers/users.py rename to siren/clients/users.py index e5bbbbc..58f216d 100644 --- a/siren/managers/users.py +++ b/siren/clients/users.py @@ -1,14 +1,14 @@ -"""Manages user-related operations for the Siren API client.""" +"""User client for the Siren API.""" from ..models.base import DeleteResponse from ..models.user import User, UserAPIResponse, UserRequest -from .base import BaseManager +from .base import BaseClient -class UsersManager(BaseManager): - """Manages user-related operations for the Siren API.""" +class UserClient(BaseClient): + """Client for user-related operations.""" - def add_user(self, **user_data) -> User: + def add(self, **user_data) -> User: """Create a user. Args: @@ -29,7 +29,7 @@ def add_user(self, **user_data) -> User: data=user_data, ) - def update_user(self, unique_id: str, **user_data) -> User: + def update(self, unique_id: str, **user_data) -> User: """Update a user. Args: @@ -52,7 +52,7 @@ def update_user(self, unique_id: str, **user_data) -> User: data=user_data, ) - def delete_user(self, unique_id: str) -> bool: + def delete(self, unique_id: str) -> bool: """Delete a user. Args: diff --git a/siren/managers/webhooks.py b/siren/clients/webhooks.py similarity index 82% rename from siren/managers/webhooks.py rename to siren/clients/webhooks.py index f0e4ecc..fd06f53 100644 --- a/siren/managers/webhooks.py +++ b/siren/clients/webhooks.py @@ -1,4 +1,4 @@ -"""Manages webhook-related API interactions for the Siren SDK.""" +"""Webhook client for the Siren API.""" from ..models.webhooks import ( InboundWebhookRequest, @@ -6,13 +6,13 @@ WebhookConfig, WebhookResponse, ) -from .base import BaseManager +from .base import BaseClient -class WebhooksManager(BaseManager): - """Manages webhook configuration operations.""" +class WebhookClient(BaseClient): + """Client for webhook configuration operations.""" - def configure_notifications_webhook(self, url: str) -> WebhookConfig: + def configure_notifications(self, url: str) -> WebhookConfig: """Configure the webhook for notifications. Args: @@ -36,7 +36,7 @@ def configure_notifications_webhook(self, url: str) -> WebhookConfig: ) return response.webhook_config - def configure_inbound_message_webhook(self, url: str) -> WebhookConfig: + def configure_inbound(self, url: str) -> WebhookConfig: """Configure the webhook for inbound messages. Args: diff --git a/siren/managers/workflows.py b/siren/clients/workflows.py similarity index 95% rename from siren/managers/workflows.py rename to siren/clients/workflows.py index 74c6897..c1ad68b 100644 --- a/siren/managers/workflows.py +++ b/siren/clients/workflows.py @@ -1,4 +1,4 @@ -"""Workflows manager using BaseManager architecture.""" +"""Workflows client using BaseClient architecture.""" from typing import Any, Dict, List, Optional @@ -13,13 +13,13 @@ TriggerWorkflowResponse, WorkflowExecutionData, ) -from .base import BaseManager +from .base import BaseClient -class WorkflowsManager(BaseManager): - """Manager for workflow operations using BaseManager.""" +class WorkflowClient(BaseClient): + """Client for workflow operations using BaseClient.""" - def trigger_workflow( + def trigger( self, workflow_name: str, data: Optional[Dict[str, Any]] = None, @@ -60,7 +60,7 @@ def trigger_workflow( self.timeout = original_timeout return response - def trigger_bulk_workflow( + def trigger_bulk( self, workflow_name: str, notify: List[Dict[str, Any]], @@ -103,7 +103,7 @@ def trigger_bulk_workflow( self.timeout = original_timeout return response - def schedule_workflow( + def schedule( self, name: str, schedule_time: str, diff --git a/siren/managers/__init__.py b/siren/managers/__init__.py deleted file mode 100644 index 094d4a5..0000000 --- a/siren/managers/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Manager classes for the Siren SDK.""" - -from .base import BaseManager -from .messaging import MessagingManager -from .templates import TemplatesManager -from .users import UsersManager -from .webhooks import WebhooksManager -from .workflows import WorkflowsManager - -__all__ = [ - "BaseManager", - "TemplatesManager", - "UsersManager", - "MessagingManager", - "WebhooksManager", - "WorkflowsManager", -] diff --git a/tests/test_client.py b/tests/test_client.py index 0fc990d..221c8a5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,19 +14,76 @@ def test_siren_client_initialization(client): """Test that the SirenClient initializes correctly.""" assert client.api_key == "test_api_key", "API key should be set on initialization" + assert hasattr(client, "template"), "Client should have a template property" + assert hasattr(client.template, "get"), "Template client should have a get method" assert hasattr( - client, "_templates" - ), "Client should have an internal _templates manager attribute" - assert hasattr(client, "get_templates"), "Client should have a get_templates method" + client.template, "create" + ), "Template client should have a create method" + assert ( + client.template.api_key == "test_api_key" + ), "Template client should have API key" + assert ( + client.template.base_url == client.base_url + ), "Template client should use the base URL from client" + + # Test workflow property + assert hasattr(client, "workflow"), "Client should have a workflow property" + assert hasattr( + client.workflow, "trigger" + ), "Workflow client should have a trigger method" + assert hasattr( + client.workflow, "trigger_bulk" + ), "Workflow client should have a trigger_bulk method" + assert hasattr( + client.workflow, "schedule" + ), "Workflow client should have a schedule method" + assert ( + client.workflow.api_key == "test_api_key" + ), "Workflow client should have API key" + assert ( + client.workflow.base_url == client.base_url + ), "Workflow client should use the base URL from client" + + # Test message property + assert hasattr(client, "message"), "Client should have a message property" + assert hasattr(client.message, "send"), "Message client should have a send method" + assert hasattr( + client.message, "get_replies" + ), "Message client should have a get_replies method" + assert hasattr( + client.message, "get_status" + ), "Message client should have a get_status method" + assert ( + client.message.api_key == "test_api_key" + ), "Message client should have API key" + assert ( + client.message.base_url == client.base_url + ), "Message client should use the base URL from client" + + # Test user property + assert hasattr(client, "user"), "Client should have a user property" + assert hasattr(client.user, "add"), "User client should have an add method" + assert hasattr(client.user, "update"), "User client should have an update method" + assert hasattr(client.user, "delete"), "User client should have a delete method" + assert client.user.api_key == "test_api_key", "User client should have API key" + assert ( + client.user.base_url == client.base_url + ), "User client should use the base URL from client" + + # Test webhook property + assert hasattr(client, "webhook"), "Client should have a webhook property" + assert hasattr( + client.webhook, "configure_notifications" + ), "Webhook client should have a configure_notifications method" assert hasattr( - client, "create_template" - ), "Client should have a create_template method" + client.webhook, "configure_inbound" + ), "Webhook client should have a configure_inbound method" assert ( - client._templates.api_key == "test_api_key" - ), "Templates manager should have API key" + client.webhook.api_key == "test_api_key" + ), "Webhook client should have API key" assert ( - client._templates.base_url == client.base_url - ), "Templates manager should use the base URL from client" + client.webhook.base_url == client.base_url + ), "Webhook client should use the base URL from client" def test_siren_client_default_environment(): diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 4296583..23f1ece 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -1,26 +1,26 @@ -"""Unit tests for the updated messaging manager using BaseManager.""" +"""Unit tests for the messaging client using BaseClient.""" from unittest.mock import Mock, patch import pytest +from siren.clients.messaging import MessageClient from siren.exceptions import SirenAPIError, SirenSDKError -from siren.managers.messaging import MessagingManager API_KEY = "test_api_key" BASE_URL = "https://api.dev.trysiren.io" -class TestMessagingManager: - """Tests for MessagingManager with BaseManager.""" +class TestMessageClient: + """Tests for MessageClient with BaseClient.""" def setup_method(self): """Set up test fixtures.""" - self.manager = MessagingManager(api_key=API_KEY, base_url=BASE_URL) + self.client = MessageClient(api_key=API_KEY, base_url=BASE_URL) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_send_message_success(self, mock_request): - """Test successful message sending with new BaseManager.""" + """Test successful message sending with new BaseClient.""" # Mock successful API response mock_response = Mock() mock_response.status_code = 200 @@ -31,7 +31,7 @@ def test_send_message_success(self, mock_request): mock_request.return_value = mock_response # Call the method - result = self.manager.send_message( + result = self.client.send( template_name="test_template", channel="SLACK", recipient_type="direct", @@ -56,7 +56,7 @@ def test_send_message_success(self, mock_request): assert payload["templateVariables"]["name"] == "John" assert payload["template"]["name"] == "test_template" - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_message_status_success(self, mock_request): """Test successful message status retrieval.""" # Mock successful API response @@ -69,12 +69,12 @@ def test_get_message_status_success(self, mock_request): mock_request.return_value = mock_response # Call the method - result = self.manager.get_message_status("test_msg_123") + result = self.client.get_status("test_msg_123") # Verify result assert result == "DELIVERED" - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_replies_success(self, mock_request): """Test successful replies retrieval.""" # Mock successful API response @@ -100,7 +100,7 @@ def test_get_replies_success(self, mock_request): mock_request.return_value = mock_response # Call the method - result = self.manager.get_replies("test_msg_123") + result = self.client.get_replies("test_msg_123") # Verify result assert len(result) == 2 @@ -109,7 +109,7 @@ def test_get_replies_success(self, mock_request): assert result[1].text == "Reply 2" assert result[1].user == "U456" - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_api_error_handling(self, mock_request): """Test that API errors are properly handled.""" # Mock API error response @@ -123,7 +123,7 @@ def test_api_error_handling(self, mock_request): # Should raise SirenAPIError with pytest.raises(SirenAPIError) as exc_info: - self.manager.send_message( + self.client.send( template_name="nonexistent", channel="SLACK", recipient_type="direct", @@ -133,7 +133,7 @@ def test_api_error_handling(self, mock_request): assert exc_info.value.error_code == "NOT_FOUND" assert "Template not found" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_send_message_without_template_variables(self, mock_request): """Test sending message without template variables.""" # Mock successful API response @@ -146,7 +146,7 @@ def test_send_message_without_template_variables(self, mock_request): mock_request.return_value = mock_response # Call the method without template_variables - result = self.manager.send_message( + result = self.client.send( template_name="simple_template", channel="EMAIL", recipient_type="direct", @@ -160,7 +160,7 @@ def test_send_message_without_template_variables(self, mock_request): payload = mock_request.call_args[1]["json"] assert "templateVariables" not in payload - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_replies_empty_list(self, mock_request): """Test get_replies when no replies exist.""" # Mock successful API response with empty list @@ -170,13 +170,13 @@ def test_get_replies_empty_list(self, mock_request): mock_request.return_value = mock_response # Call the method - result = self.manager.get_replies("test_msg_no_replies") + result = self.client.get_replies("test_msg_no_replies") # Verify result assert result == [] assert len(result) == 0 - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_network_error_handling(self, mock_request): """Test handling of network errors.""" # Mock network error @@ -184,7 +184,7 @@ def test_network_error_handling(self, mock_request): # Should raise SirenSDKError with pytest.raises(SirenSDKError) as exc_info: - self.manager.send_message( + self.client.send( template_name="test", channel="SLACK", recipient_type="direct", diff --git a/tests/test_templates.py b/tests/test_templates.py index 63f32e8..07fdfd6 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,11 +1,11 @@ -"""Unit tests for the templates manager using BaseManager.""" +"""Unit tests for the templates client using BaseClient.""" from unittest.mock import Mock, patch import pytest +from siren.clients.templates import TemplateClient from siren.exceptions import SirenAPIError, SirenSDKError -from siren.managers.templates import TemplatesManager from siren.models.templates import CreatedTemplate, Template API_KEY = "test_api_key" @@ -20,14 +20,14 @@ def mock_response(status_code: int, json_data: dict = None): return mock_resp -class TestTemplatesManager: - """Tests for the TemplatesManager class.""" +class TestTemplateClient: + """Tests for the TemplateClient class.""" def setup_method(self): """Set up test fixtures.""" - self.manager = TemplatesManager(api_key=API_KEY, base_url=BASE_URL) + self.client = TemplateClient(api_key=API_KEY, base_url=BASE_URL) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_templates_success(self, mock_request): """Test successful retrieval of templates.""" # Mock API response based on user-provided response @@ -93,7 +93,7 @@ def test_get_templates_success(self, mock_request): mock_request.return_value = mock_response(200, mock_api_response) # Call the method - result = self.manager.get_templates(page=0, size=2) + result = self.client.get(page=0, size=2) # Verify result is List[Template] assert isinstance(result, list) @@ -116,7 +116,7 @@ def test_get_templates_success(self, mock_request): assert second_template.draft_version.status == "DRAFT" assert second_template.published_version.status == "PUBLISHED_LATEST" - # Verify request was made correctly with BaseManager + # Verify request was made correctly with BaseClient mock_request.assert_called_once_with( method="GET", url=f"{BASE_URL}/api/v1/public/template", @@ -126,7 +126,7 @@ def test_get_templates_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_templates_with_all_params(self, mock_request): """Test get_templates with all optional parameters.""" mock_api_response = { @@ -144,7 +144,7 @@ def test_get_templates_with_all_params(self, mock_request): mock_request.return_value = mock_response(200, mock_api_response) # Call with all parameters - result = self.manager.get_templates( + result = self.client.get( tag_names="test,example", search="template", sort="name,asc", page=1, size=5 ) @@ -167,7 +167,7 @@ def test_get_templates_with_all_params(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_templates_api_error(self, mock_request): """Test API error during template retrieval.""" mock_api_error = { @@ -176,12 +176,12 @@ def test_get_templates_api_error(self, mock_request): mock_request.return_value = mock_response(401, mock_api_error) with pytest.raises(SirenAPIError) as exc_info: - self.manager.get_templates() + self.client.get() assert exc_info.value.error_code == "UNAUTHORIZED" assert "Invalid API key" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_templates_network_error(self, mock_request): """Test network error during template retrieval.""" from requests.exceptions import ConnectionError @@ -189,11 +189,11 @@ def test_get_templates_network_error(self, mock_request): mock_request.side_effect = ConnectionError("Connection failed") with pytest.raises(SirenSDKError) as exc_info: - self.manager.get_templates() + self.client.get() assert "Connection failed" in exc_info.value.message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_create_template_success(self, mock_request): """Test successful template creation.""" mock_api_response = { @@ -214,7 +214,7 @@ def test_create_template_success(self, mock_request): } mock_request.return_value = mock_response(200, mock_api_response) - result = self.manager.create_template( + result = self.client.create( name="Test_Create_Template", description="A test template", tag_names=["test", "creation"], @@ -264,7 +264,7 @@ def test_create_template_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_create_template_api_error(self, mock_request): """Test API error during template creation.""" mock_api_error = { @@ -273,19 +273,19 @@ def test_create_template_api_error(self, mock_request): mock_request.return_value = mock_response(400, mock_api_error) with pytest.raises(SirenAPIError) as exc_info: - self.manager.create_template(name="Invalid Template") + self.client.create(name="Invalid Template") assert exc_info.value.error_code == "BAD_REQUEST" assert "Bad request" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_delete_template_success(self, mock_request): """Test successful template deletion (204 No Content).""" # Mock 204 response with empty body mock_request.return_value = mock_response(204, "") template_id = "tpl_delete_123" - result = self.manager.delete_template(template_id) + result = self.client.delete(template_id) assert result is True @@ -299,7 +299,7 @@ def test_delete_template_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_delete_template_not_found(self, mock_request): """Test template deletion with 404 error.""" mock_api_error = { @@ -310,12 +310,12 @@ def test_delete_template_not_found(self, mock_request): template_id = "tpl_not_found" with pytest.raises(SirenAPIError) as exc_info: - self.manager.delete_template(template_id) + self.client.delete(template_id) assert exc_info.value.error_code == "NOT_FOUND" assert "Template not found" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_update_template_success(self, mock_request): """Test successful template update.""" mock_api_response = { @@ -338,7 +338,7 @@ def test_update_template_success(self, mock_request): template_id = "tpl_xyz789" - result = self.manager.update_template( + result = self.client.update( template_id, name="Updated_Test_Template", description="An updated test template", @@ -368,7 +368,7 @@ def test_update_template_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_publish_template_success(self, mock_request): """Test successful template publishing.""" template_id = "tpl_pub_success" @@ -396,7 +396,7 @@ def test_publish_template_success(self, mock_request): } mock_request.return_value = mock_response(200, mock_api_response) - result = self.manager.publish_template(template_id) + result = self.client.publish(template_id) assert isinstance(result, Template) assert result.id == template_id @@ -414,7 +414,7 @@ def test_publish_template_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_publish_template_not_found(self, mock_request): """Test template publishing with 404 error.""" template_id = "tpl_not_found" @@ -424,12 +424,12 @@ def test_publish_template_not_found(self, mock_request): mock_request.return_value = mock_response(404, mock_api_error) with pytest.raises(SirenAPIError) as exc_info: - self.manager.publish_template(template_id) + self.client.publish(template_id) assert exc_info.value.error_code == "NOT_FOUND" assert "Template not found" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_publish_template_bad_request(self, mock_request): """Test template publishing with 400 error.""" template_id = "tpl_bad_request" @@ -442,12 +442,12 @@ def test_publish_template_bad_request(self, mock_request): mock_request.return_value = mock_response(400, mock_api_error) with pytest.raises(SirenAPIError) as exc_info: - self.manager.publish_template(template_id) + self.client.publish(template_id) assert exc_info.value.error_code == "BAD_REQUEST" assert "Template has no versions to publish" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_create_channel_templates_success(self, mock_request): """Test successful creation of channel templates.""" mock_input_data = { @@ -496,7 +496,7 @@ def test_create_channel_templates_success(self, mock_request): } mock_request.return_value = mock_response(200, mock_response_data) - result = self.manager.create_channel_templates("template123", **mock_input_data) + result = self.client.create_channel_templates("template123", **mock_input_data) assert len(result) == 2 assert result[0].channel == "SMS" @@ -513,7 +513,7 @@ def test_create_channel_templates_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_create_channel_templates_api_error(self, mock_request): """Test API error during channel templates creation.""" mock_api_error = { @@ -525,14 +525,14 @@ def test_create_channel_templates_api_error(self, mock_request): mock_request.return_value = mock_response(400, mock_api_error) with pytest.raises(SirenAPIError) as exc_info: - self.manager.create_channel_templates( + self.client.create_channel_templates( "template123", SMS={"body": "test"}, INVALID_CHANNEL={"body": "invalid"} ) assert exc_info.value.error_code == "BAD_REQUEST" assert "Invalid channel configuration" in exc_info.value.api_message - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_channel_templates_success(self, mock_request): """Test successful retrieval of channel templates.""" mock_response_data = { @@ -563,7 +563,7 @@ def test_get_channel_templates_success(self, mock_request): } mock_request.return_value = mock_response(200, mock_response_data) - result = self.manager.get_channel_templates("version123") + result = self.client.get_channel_templates("version123") assert len(result) == 2 assert result[0].channel == "SMS" @@ -577,7 +577,7 @@ def test_get_channel_templates_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_get_channel_templates_with_params(self, mock_request): """Test get channel templates with query parameters.""" mock_response_data = { @@ -597,7 +597,7 @@ def test_get_channel_templates_with_params(self, mock_request): } mock_request.return_value = mock_response(200, mock_response_data) - result = self.manager.get_channel_templates( + result = self.client.get_channel_templates( "version123", channel="EMAIL", page=0, size=5 ) diff --git a/tests/test_users.py b/tests/test_users.py index 32059b3..1e22ddf 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -8,8 +8,8 @@ import requests from siren.client import SirenClient +from siren.clients.users import UserClient from siren.exceptions import SirenAPIError, SirenSDKError -from siren.managers.users import UsersManager from siren.models.user import User # Test constants @@ -19,9 +19,9 @@ @pytest.fixture -def users_manager(): - """Fixture to create a UsersManager instance.""" - return UsersManager(api_key=MOCK_API_KEY, base_url=MOCK_BASE_URL) +def user_client(): + """Fixture to create a UserClient instance.""" + return UserClient(api_key=MOCK_API_KEY, base_url=MOCK_BASE_URL) @pytest.fixture @@ -46,11 +46,11 @@ def mock_response( return mock_resp -class TestUsersManager: - """Tests for the UsersManager class.""" +class TestUserClient: + """Tests for the UserClient class.""" - @patch("siren.managers.base.requests.request") - def test_add_user_success(self, mock_request, users_manager: UsersManager): + @patch("siren.clients.base.requests.request") + def test_add_user_success(self, mock_request, user_client: UserClient): """Test successful user creation/update returns a User model instance.""" # Mock API response with all possible user fields mock_api_json_response = { @@ -83,7 +83,7 @@ def test_add_user_success(self, mock_request, users_manager: UsersManager): "active_channels": ["EMAIL"], "attributes": {"custom_field": "value1"}, } - response = users_manager.add_user(**payload) + response = user_client.add(**payload) # Expected API request with camelCase keys expected_headers = { @@ -124,9 +124,9 @@ def test_add_user_success(self, mock_request, users_manager: UsersManager): assert response.phone is None assert response.avatar_url is None - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_add_user_api_error_returns_json( - self, mock_request, users_manager: UsersManager + self, mock_request, user_client: UserClient ): """Test API error (400, 401, 404) with JSON body raises SirenAPIError.""" # Mock API error response with validation details @@ -149,7 +149,7 @@ def test_add_user_api_error_returns_json( mock_request.return_value = err_response_obj with pytest.raises(SirenAPIError) as excinfo: - users_manager.add_user(unique_id=MOCK_USER_ID) + user_client.add(unique_id=MOCK_USER_ID) # Verify error details assert excinfo.value.status_code == status_code @@ -160,10 +160,8 @@ def test_add_user_api_error_returns_json( == mock_api_error_payload["error"]["details"] ) - @patch("siren.managers.base.requests.request") - def test_add_user_http_error_no_json( - self, mock_request, users_manager: UsersManager - ): + @patch("siren.clients.base.requests.request") + def test_add_user_http_error_no_json(self, mock_request, user_client: UserClient): """Test API error (500) without JSON body raises SirenSDKError.""" # Mock non-JSON error response status_code = 500 @@ -176,7 +174,7 @@ def test_add_user_http_error_no_json( mock_request.return_value = err_response_obj with pytest.raises(SirenSDKError) as excinfo: - users_manager.add_user(unique_id=MOCK_USER_ID) + user_client.add(unique_id=MOCK_USER_ID) assert isinstance( excinfo.value.original_exception, requests.exceptions.JSONDecodeError @@ -184,10 +182,8 @@ def test_add_user_http_error_no_json( assert "API response was not valid JSON" in excinfo.value.message assert error_text in excinfo.value.message - @patch("siren.managers.base.requests.request") - def test_add_user_request_exception( - self, mock_request, users_manager: UsersManager - ): + @patch("siren.clients.base.requests.request") + def test_add_user_request_exception(self, mock_request, user_client: UserClient): """Test handling of requests.exceptions.RequestException (e.g., network error) raises SirenSDKError.""" # Mock network error original_exception = requests.exceptions.ConnectionError( @@ -196,7 +192,7 @@ def test_add_user_request_exception( mock_request.side_effect = original_exception with pytest.raises(SirenSDKError) as excinfo: - users_manager.add_user(unique_id=MOCK_USER_ID) + user_client.add(unique_id=MOCK_USER_ID) assert excinfo.value.original_exception == original_exception assert isinstance( @@ -204,8 +200,8 @@ def test_add_user_request_exception( ) assert "Network or connection error" in excinfo.value.message - @patch("siren.managers.base.requests.request") - def test_update_user_success(self, mock_request, users_manager: UsersManager): + @patch("siren.clients.base.requests.request") + def test_update_user_success(self, mock_request, user_client: UserClient): """Test successful user update returns a User model instance.""" # Mock API response mock_api_json_response = { @@ -239,7 +235,7 @@ def test_update_user_success(self, mock_request, users_manager: UsersManager): "reference_id": "020", "whatsapp": "+919632323154", } - response = users_manager.update_user(MOCK_USER_ID, **payload) + response = user_client.update(MOCK_USER_ID, **payload) # Expected API request with camelCase keys expected_headers = { @@ -277,9 +273,9 @@ def test_update_user_success(self, mock_request, users_manager: UsersManager): assert response.whatsapp == "+919632323154" assert response.updated_at == "2023-01-02T12:00:00Z" - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_update_user_api_error_returns_json( - self, mock_request, users_manager: UsersManager + self, mock_request, user_client: UserClient ): """Test API error (400, 401, 404) with JSON body raises SirenAPIError.""" # Mock API error response @@ -295,28 +291,24 @@ def test_update_user_api_error_returns_json( mock_request.return_value = err_response_obj with pytest.raises(SirenAPIError) as excinfo: - users_manager.update_user(MOCK_USER_ID, first_name="Jane") + user_client.update(MOCK_USER_ID, first_name="Jane") # Verify error details assert excinfo.value.status_code == status_code assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] - @patch("siren.managers.base.requests.request") - def test_update_user_validation_error( - self, mock_request, users_manager: UsersManager - ): + @patch("siren.clients.base.requests.request") + def test_update_user_validation_error(self, mock_request, user_client: UserClient): """Test invalid parameters raise SirenSDKError.""" with pytest.raises(SirenSDKError) as excinfo: - users_manager.update_user(MOCK_USER_ID, email="invalid-email") + user_client.update(MOCK_USER_ID, email="invalid-email") assert "Invalid parameters" in excinfo.value.message mock_request.assert_not_called() - @patch("siren.managers.base.requests.request") - def test_update_user_request_exception( - self, mock_request, users_manager: UsersManager - ): + @patch("siren.clients.base.requests.request") + def test_update_user_request_exception(self, mock_request, user_client: UserClient): """Test handling of requests.exceptions.RequestException raises SirenSDKError.""" # Mock network error original_exception = requests.exceptions.ConnectionError( @@ -325,18 +317,18 @@ def test_update_user_request_exception( mock_request.side_effect = original_exception with pytest.raises(SirenSDKError) as excinfo: - users_manager.update_user(MOCK_USER_ID, first_name="Jane") + user_client.update(MOCK_USER_ID, first_name="Jane") assert excinfo.value.original_exception == original_exception assert "Network or connection error" in excinfo.value.message - @patch("siren.managers.base.requests.request") - def test_delete_user_success(self, mock_request, users_manager: UsersManager): + @patch("siren.clients.base.requests.request") + def test_delete_user_success(self, mock_request, user_client: UserClient): """Test successful user deletion returns True.""" # Mock API response for 204 No Content mock_request.return_value = mock_response(204) - response = users_manager.delete_user(MOCK_USER_ID) + response = user_client.delete(MOCK_USER_ID) # Expected API request expected_headers = { @@ -354,8 +346,8 @@ def test_delete_user_success(self, mock_request, users_manager: UsersManager): # Verify response assert response is True - @patch("siren.managers.base.requests.request") - def test_delete_user_not_found(self, mock_request, users_manager: UsersManager): + @patch("siren.clients.base.requests.request") + def test_delete_user_not_found(self, mock_request, user_client: UserClient): """Test API error (404) raises SirenAPIError.""" # Mock API error response mock_api_error_payload = { @@ -370,17 +362,15 @@ def test_delete_user_not_found(self, mock_request, users_manager: UsersManager): mock_request.return_value = err_response_obj with pytest.raises(SirenAPIError) as excinfo: - users_manager.delete_user(MOCK_USER_ID) + user_client.delete(MOCK_USER_ID) # Verify error details assert excinfo.value.status_code == status_code assert excinfo.value.api_message == mock_api_error_payload["error"]["message"] assert excinfo.value.error_code == mock_api_error_payload["error"]["errorCode"] - @patch("siren.managers.base.requests.request") - def test_delete_user_request_exception( - self, mock_request, users_manager: UsersManager - ): + @patch("siren.clients.base.requests.request") + def test_delete_user_request_exception(self, mock_request, user_client: UserClient): """Test handling of requests.exceptions.RequestException raises SirenSDKError.""" # Mock network error original_exception = requests.exceptions.ConnectionError( @@ -389,7 +379,7 @@ def test_delete_user_request_exception( mock_request.side_effect = original_exception with pytest.raises(SirenSDKError) as excinfo: - users_manager.delete_user(MOCK_USER_ID) + user_client.delete(MOCK_USER_ID) assert excinfo.value.original_exception == original_exception assert "Network or connection error" in excinfo.value.message @@ -398,11 +388,11 @@ def test_delete_user_request_exception( class TestSirenClientUsers: """Tests for user management methods exposed on SirenClient.""" - @patch("siren.client.UsersManager.add_user") - def test_client_add_user_delegates_to_manager( - self, mock_manager_add_user, siren_client: SirenClient + @patch("siren.client.UserClient.add") + def test_client_add_user_delegates_to_client( + self, mock_client_add_user, siren_client: SirenClient ): - """Test that SirenClient.add_user correctly delegates to UsersManager.add_user.""" + """Test that SirenClient.user.add correctly delegates to UserClient.add.""" # Test data payload = { "unique_id": "client_user_001", @@ -436,13 +426,13 @@ def test_client_add_user_delegates_to_manager( line=None, customData=None, ) - mock_manager_add_user.return_value = mock_user_instance + mock_client_add_user.return_value = mock_user_instance - response = siren_client.add_user(**payload) + response = siren_client.user.add(**payload) # Verify delegation - mock_manager_add_user.assert_called_once() - call_args = mock_manager_add_user.call_args[1] + mock_client_add_user.assert_called_once() + call_args = mock_client_add_user.call_args[1] assert call_args["unique_id"] == payload["unique_id"] assert call_args["first_name"] == payload["first_name"] assert call_args["last_name"] == payload["last_name"] @@ -450,11 +440,11 @@ def test_client_add_user_delegates_to_manager( assert call_args["attributes"] == payload["attributes"] assert response == mock_user_instance - @patch("siren.client.UsersManager.update_user") - def test_client_update_user_delegates_to_manager( - self, mock_manager_update_user, siren_client: SirenClient + @patch("siren.client.UserClient.update") + def test_client_update_user_delegates_to_client( + self, mock_client_update_user, siren_client: SirenClient ): - """Test that SirenClient.update_user correctly delegates to UsersManager.update_user.""" + """Test that SirenClient.user.update correctly delegates to UserClient.update.""" # Test data unique_id = "client_user_001" payload = { @@ -495,34 +485,36 @@ def test_client_update_user_delegates_to_manager( line="line_user_id_123", customData={"custom_field": "custom_value"}, ) - mock_manager_update_user.return_value = mock_user_instance + mock_client_update_user.return_value = mock_user_instance - response = siren_client.update_user(unique_id, **payload) + response = siren_client.user.update(unique_id, **payload) # Verify delegation - mock_manager_update_user.assert_called_once() - call_args = mock_manager_update_user.call_args + mock_client_update_user.assert_called_once() + call_args = mock_client_update_user.call_args + # Check positional args + args = call_args[0] call_kwargs = call_args[1] - assert call_kwargs["unique_id"] == unique_id + assert args[0] == unique_id # unique_id is first positional argument assert call_kwargs["first_name"] == payload["first_name"] assert call_kwargs["last_name"] == payload["last_name"] assert call_kwargs["email"] == payload["email"] assert call_kwargs["attributes"] == payload["attributes"] assert response == mock_user_instance - @patch("siren.client.UsersManager.delete_user") - def test_client_delete_user_delegates_to_manager( - self, mock_manager_delete_user, siren_client: SirenClient + @patch("siren.client.UserClient.delete") + def test_client_delete_user_delegates_to_client( + self, mock_client_delete_user, siren_client: SirenClient ): - """Test that SirenClient.delete_user correctly delegates to UsersManager.delete_user.""" + """Test that SirenClient.user.delete correctly delegates to UserClient.delete.""" # Test data unique_id = "client_user_001" # Mock response - mock_manager_delete_user.return_value = True + mock_client_delete_user.return_value = True - response = siren_client.delete_user(unique_id) + response = siren_client.user.delete(unique_id) # Verify delegation - mock_manager_delete_user.assert_called_once_with(unique_id) + mock_client_delete_user.assert_called_once_with(unique_id) assert response is True diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index bcdbe35..b8fbc0d 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,12 +1,12 @@ -"""Unit tests for the webhooks manager using BaseManager.""" +"""Unit tests for the webhook client using BaseClient.""" from unittest.mock import Mock, patch import pytest from siren.client import SirenClient +from siren.clients.webhooks import WebhookClient from siren.exceptions import SirenAPIError, SirenSDKError -from siren.managers.webhooks import WebhooksManager from siren.models.webhooks import WebhookConfig API_KEY = "test_api_key" @@ -22,14 +22,14 @@ def mock_response(status_code: int, json_data: dict = None): return mock_resp -class TestWebhooksManager: - """Tests for the WebhooksManager class.""" +class TestWebhookClient: + """Tests for the WebhookClient class.""" def setup_method(self): """Set up test fixtures.""" - self.manager = WebhooksManager(api_key=API_KEY, base_url=BASE_URL) + self.client = WebhookClient(api_key=API_KEY, base_url=BASE_URL) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_configure_notifications_webhook_success(self, mock_request): """Test successful configuration of notifications webhook.""" # Mock successful API response @@ -47,7 +47,7 @@ def test_configure_notifications_webhook_success(self, mock_request): mock_request.return_value = mock_response(200, mock_api_response) # Call the method - result = self.manager.configure_notifications_webhook(url=WEBHOOK_URL) + result = self.client.configure_notifications(url=WEBHOOK_URL) # Verify result is WebhookConfig object assert isinstance(result, WebhookConfig) @@ -55,7 +55,7 @@ def test_configure_notifications_webhook_success(self, mock_request): assert result.verification_key == "test_key_123" assert result.headers == [] - # Verify request was made correctly with BaseManager + # Verify request was made correctly with BaseClient mock_request.assert_called_once_with( method="PUT", url=f"{BASE_URL}/api/v1/public/webhooks", @@ -68,7 +68,7 @@ def test_configure_notifications_webhook_success(self, mock_request): timeout=10, ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_configure_inbound_message_webhook_success(self, mock_request): """Test successful configuration of inbound message webhook.""" # Mock successful API response @@ -86,7 +86,7 @@ def test_configure_inbound_message_webhook_success(self, mock_request): mock_request.return_value = mock_response(200, mock_api_response) # Call the method - result = self.manager.configure_inbound_message_webhook(url=WEBHOOK_URL) + result = self.client.configure_inbound(url=WEBHOOK_URL) # Verify result is WebhookConfig object assert isinstance(result, WebhookConfig) @@ -94,7 +94,7 @@ def test_configure_inbound_message_webhook_success(self, mock_request): assert result.verification_key == "test_key_456" assert result.headers == [] - # Verify request was made correctly with BaseManager + # Verify request was made correctly with BaseClient mock_request.assert_called_once_with( method="PUT", url=f"{BASE_URL}/api/v1/public/webhooks", @@ -110,11 +110,11 @@ def test_configure_inbound_message_webhook_success(self, mock_request): @pytest.mark.parametrize( "method_name,config_key", [ - ("configure_notifications_webhook", "webhookConfig"), - ("configure_inbound_message_webhook", "inboundWebhookConfig"), + ("configure_notifications", "webhookConfig"), + ("configure_inbound", "inboundWebhookConfig"), ], ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_webhook_api_error(self, mock_request, method_name: str, config_key: str): """Test API error during webhook configuration.""" # Mock API error response @@ -127,7 +127,7 @@ def test_webhook_api_error(self, mock_request, method_name: str, config_key: str } mock_request.return_value = mock_response(400, mock_api_error) - method_to_call = getattr(self.manager, method_name) + method_to_call = getattr(self.client, method_name) with pytest.raises(SirenAPIError) as exc_info: method_to_call(url=WEBHOOK_URL) @@ -151,9 +151,9 @@ def test_webhook_api_error(self, mock_request, method_name: str, config_key: str @pytest.mark.parametrize( "method_name", - ["configure_notifications_webhook", "configure_inbound_message_webhook"], + ["configure_notifications", "configure_inbound"], ) - @patch("siren.managers.base.requests.request") + @patch("siren.clients.base.requests.request") def test_webhook_network_error(self, mock_request, method_name: str): """Test network error during webhook configuration.""" from requests.exceptions import ConnectionError @@ -161,7 +161,7 @@ def test_webhook_network_error(self, mock_request, method_name: str): # Mock network error mock_request.side_effect = ConnectionError("Connection failed") - method_to_call = getattr(self.manager, method_name) + method_to_call = getattr(self.client, method_name) with pytest.raises(SirenSDKError) as exc_info: method_to_call(url=WEBHOOK_URL) @@ -170,38 +170,34 @@ def test_webhook_network_error(self, mock_request, method_name: str): def test_siren_client_configure_notifications_webhook(): - """Test SirenClient.configure_notifications_webhook calls WebhooksManager correctly.""" + """Test SirenClient.webhook.configure_notifications calls WebhookClient correctly.""" client = SirenClient(api_key=API_KEY, env="dev") - with patch.object( - client._webhooks, "configure_notifications_webhook" - ) as mock_method: + with patch.object(client._webhook_client, "configure_notifications") as mock_method: # Create WebhookConfig using model_validate to handle aliases properly mock_config = WebhookConfig.model_validate( {"url": WEBHOOK_URL, "headers": [], "verificationKey": "test_key_123"} ) mock_method.return_value = mock_config - result = client.configure_notifications_webhook(url=WEBHOOK_URL) + result = client.webhook.configure_notifications(url=WEBHOOK_URL) mock_method.assert_called_once_with(url=WEBHOOK_URL) assert result == mock_config def test_siren_client_configure_inbound_message_webhook(): - """Test SirenClient.configure_inbound_message_webhook calls WebhooksManager correctly.""" + """Test SirenClient.webhook.configure_inbound calls WebhookClient correctly.""" client = SirenClient(api_key=API_KEY, env="dev") - with patch.object( - client._webhooks, "configure_inbound_message_webhook" - ) as mock_method: + with patch.object(client._webhook_client, "configure_inbound") as mock_method: # Create WebhookConfig using model_validate to handle aliases properly mock_config = WebhookConfig.model_validate( {"url": WEBHOOK_URL, "headers": [], "verificationKey": "test_key_456"} ) mock_method.return_value = mock_config - result = client.configure_inbound_message_webhook(url=WEBHOOK_URL) + result = client.webhook.configure_inbound(url=WEBHOOK_URL) mock_method.assert_called_once_with(url=WEBHOOK_URL) assert result == mock_config diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 2157552..8fae765 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -28,7 +28,7 @@ def client() -> SirenClient: def test_trigger_workflow_success_with_all_params( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow with all parameters successfully.""" + """Test trigger with all parameters successfully.""" request_data = {"subject": "otp verification"} request_notify = {"notificationType": "email", "recipient": "example@example.com"} expected_response = { @@ -44,7 +44,7 @@ def test_trigger_workflow_success_with_all_params( requests_mock.post(mock_url, json=expected_response, status_code=200) - response = client.trigger_workflow( + response = client.workflow.trigger( workflow_name=WORKFLOW_NAME, data=request_data, notify=request_notify ) @@ -68,7 +68,7 @@ def test_trigger_workflow_success_with_all_params( def test_trigger_workflow_success_minimal_params( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow with only workflow_name successfully.""" + """Test trigger with only workflow_name successfully.""" expected_response = { "data": {"requestId": "uuid1", "workflowExecutionId": "uuid2"}, "error": None, @@ -78,7 +78,7 @@ def test_trigger_workflow_success_minimal_params( mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post(mock_url, json=expected_response, status_code=200) - response = client.trigger_workflow(workflow_name=WORKFLOW_NAME) + response = client.workflow.trigger(workflow_name=WORKFLOW_NAME) # Expect parsed model object assert isinstance(response, WorkflowExecutionData) @@ -95,7 +95,7 @@ def test_trigger_workflow_success_minimal_params( def test_trigger_workflow_http_400_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow handles HTTP 400 Bad Request error.""" + """Test trigger handles HTTP 400 Bad Request error.""" error_response = { "data": None, "error": {"errorCode": "BAD_REQUEST", "message": "Bad request"}, @@ -106,7 +106,7 @@ def test_trigger_workflow_http_400_error( requests_mock.post(mock_url, json=error_response, status_code=400) with pytest.raises(SirenAPIError) as exc_info: - client.trigger_workflow(workflow_name=WORKFLOW_NAME) + client.workflow.trigger(workflow_name=WORKFLOW_NAME) assert exc_info.value.status_code == 400 assert "BAD_REQUEST" in str(exc_info.value) @@ -115,13 +115,13 @@ def test_trigger_workflow_http_400_error( def test_trigger_workflow_http_401_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow handles HTTP 401 Unauthorized error.""" + """Test trigger handles HTTP 401 Unauthorized error.""" error_response = {"detail": "Authentication credentials were not provided."} mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post(mock_url, json=error_response, status_code=401) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_workflow(workflow_name=WORKFLOW_NAME) + client.workflow.trigger(workflow_name=WORKFLOW_NAME) assert exc_info.value.status_code == 401 @@ -129,13 +129,13 @@ def test_trigger_workflow_http_401_error( def test_trigger_workflow_http_404_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow handles HTTP 404 Not Found error.""" + """Test trigger handles HTTP 404 Not Found error.""" error_response = {"detail": "Not found."} mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post(mock_url, json=error_response, status_code=404) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_workflow(workflow_name="non_existent_workflow") + client.workflow.trigger(workflow_name="non_existent_workflow") assert exc_info.value.status_code == 404 @@ -143,14 +143,14 @@ def test_trigger_workflow_http_404_error( def test_trigger_workflow_network_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow handles a network error.""" + """Test trigger handles a network error.""" mock_url = f"{MOCK_V2_BASE}/workflows/trigger" requests_mock.post( mock_url, exc=requests.exceptions.ConnectionError("Connection failed") ) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_workflow(workflow_name=WORKFLOW_NAME) + client.workflow.trigger(workflow_name=WORKFLOW_NAME) assert "Network or connection error" in str(exc_info.value) @@ -158,13 +158,13 @@ def test_trigger_workflow_network_error( def test_trigger_workflow_http_error_non_json_response( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_workflow handles HTTP error with non-JSON response.""" + """Test trigger handles HTTP error with non-JSON response.""" mock_url = f"{MOCK_V2_BASE}/workflows/trigger" non_json_error_text = "Service Unavailable" requests_mock.post(mock_url, text=non_json_error_text, status_code=503) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_workflow(workflow_name=WORKFLOW_NAME) + client.workflow.trigger(workflow_name=WORKFLOW_NAME) assert "API response was not valid JSON" in str(exc_info.value) assert exc_info.value.status_code == 503 @@ -178,7 +178,7 @@ def test_trigger_workflow_http_error_non_json_response( def test_trigger_bulk_workflow_success_with_all_params( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow with all parameters successfully.""" + """Test trigger_bulk with all parameters successfully.""" request_notify_list = [ {"notificationType": "email", "recipient": "bulk1@example.com"}, {"notificationType": "sms", "recipient": "+12345678901"}, @@ -200,7 +200,7 @@ def test_trigger_bulk_workflow_success_with_all_params( requests_mock.post(mock_url, json=expected_response, status_code=200) - response = client.trigger_bulk_workflow( + response = client.workflow.trigger_bulk( workflow_name=BULK_WORKFLOW_NAME, notify=request_notify_list, data=request_data, @@ -229,7 +229,7 @@ def test_trigger_bulk_workflow_success_with_all_params( def test_trigger_bulk_workflow_success_minimal_params( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow with minimal parameters (workflow_name, notify) successfully.""" + """Test trigger_bulk with minimal parameters (workflow_name, notify) successfully.""" request_notify_list = [ {"notificationType": "email", "recipient": "minimal_bulk@example.com"} ] @@ -245,7 +245,7 @@ def test_trigger_bulk_workflow_success_minimal_params( mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post(mock_url, json=expected_response, status_code=200) - response = client.trigger_bulk_workflow( + response = client.workflow.trigger_bulk( workflow_name=BULK_WORKFLOW_NAME, notify=request_notify_list ) @@ -284,7 +284,7 @@ def test_trigger_bulk_workflow_http_400_error( requests_mock.post(mock_url, json=error_response, status_code=400) with pytest.raises(SirenAPIError) as exc_info: - client.trigger_bulk_workflow( + client.workflow.trigger_bulk( workflow_name=BULK_WORKFLOW_NAME, notify=[{"invalid": "data"}] ) @@ -295,13 +295,13 @@ def test_trigger_bulk_workflow_http_400_error( def test_trigger_bulk_workflow_http_401_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow handles HTTP 401 Unauthorized error.""" + """Test trigger_bulk handles HTTP 401 Unauthorized error.""" error_response = {"detail": "Authentication credentials were not provided."} mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post(mock_url, json=error_response, status_code=401) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_bulk_workflow( + client.workflow.trigger_bulk( workflow_name=BULK_WORKFLOW_NAME, notify=[{"notificationType": "email", "recipient": "test@test.com"}], ) @@ -312,13 +312,13 @@ def test_trigger_bulk_workflow_http_401_error( def test_trigger_bulk_workflow_http_404_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow handles HTTP 404 Not Found error.""" + """Test trigger_bulk handles HTTP 404 Not Found error.""" error_response = {"detail": "Workflow not found."} mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post(mock_url, json=error_response, status_code=404) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_bulk_workflow( + client.workflow.trigger_bulk( workflow_name="non_existent_bulk_workflow", notify=[{"notificationType": "email", "recipient": "test@test.com"}], ) @@ -329,14 +329,14 @@ def test_trigger_bulk_workflow_http_404_error( def test_trigger_bulk_workflow_network_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow handles a network error.""" + """Test trigger_bulk handles a network error.""" mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" requests_mock.post( mock_url, exc=requests.exceptions.ConnectionError("Bulk connection failed") ) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_bulk_workflow( + client.workflow.trigger_bulk( workflow_name=BULK_WORKFLOW_NAME, notify=[{"notificationType": "email", "recipient": "test@test.com"}], ) @@ -347,13 +347,13 @@ def test_trigger_bulk_workflow_network_error( def test_trigger_bulk_workflow_http_error_non_json_response( client: SirenClient, requests_mock: RequestsMocker ): - """Test trigger_bulk_workflow handles HTTP error with non-JSON response.""" + """Test trigger_bulk handles HTTP error with non-JSON response.""" mock_url = f"{MOCK_V2_BASE}/workflows/trigger/bulk" non_json_error_text = "Service Unavailable" requests_mock.post(mock_url, text=non_json_error_text, status_code=503) with pytest.raises(SirenSDKError) as exc_info: - client.trigger_bulk_workflow( + client.workflow.trigger_bulk( workflow_name=BULK_WORKFLOW_NAME, notify=[{"notificationType": "email", "recipient": "test@test.com"}], ) @@ -378,7 +378,7 @@ def test_trigger_bulk_workflow_http_error_non_json_response( def test_schedule_workflow_success_all_params( client: SirenClient, requests_mock: RequestsMocker ): - """Test schedule_workflow with all parameters successfully.""" + """Test schedule with all parameters successfully.""" expected_api_response = { "data": { "id": "sch_12345", @@ -399,7 +399,7 @@ def test_schedule_workflow_success_all_params( mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=expected_api_response, status_code=200) - response = client.schedule_workflow( + response = client.workflow.schedule( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, timezone_id=TIMEZONE_ID, @@ -443,7 +443,7 @@ def test_schedule_workflow_success_all_params( def test_schedule_workflow_success_once_no_end_date( client: SirenClient, requests_mock: RequestsMocker ): - """Test schedule_workflow for ONCE type with no end_date successfully.""" + """Test schedule for ONCE type with no end_date successfully.""" expected_api_response = { "data": { "id": "sch_67890", @@ -464,7 +464,7 @@ def test_schedule_workflow_success_once_no_end_date( mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=expected_api_response, status_code=200) - response = client.schedule_workflow( + response = client.workflow.schedule( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, timezone_id=TIMEZONE_ID, @@ -516,7 +516,7 @@ def test_schedule_workflow_http_400_error( requests_mock.post(mock_url, json=error_response, status_code=400) with pytest.raises(SirenSDKError) as exc_info: - client.schedule_workflow( + client.workflow.schedule( name=SCHEDULE_NAME, schedule_time="invalid-time", # Intentionally invalid timezone_id=TIMEZONE_ID, @@ -532,13 +532,13 @@ def test_schedule_workflow_http_400_error( def test_schedule_workflow_http_401_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test schedule_workflow handles HTTP 401 Unauthorized error.""" + """Test schedule handles HTTP 401 Unauthorized error.""" error_response = {"detail": "Authentication credentials were not provided."} mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=error_response, status_code=401) with pytest.raises(SirenSDKError) as exc_info: - client.schedule_workflow( + client.workflow.schedule( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, timezone_id=TIMEZONE_ID, @@ -554,13 +554,13 @@ def test_schedule_workflow_http_401_error( def test_schedule_workflow_http_404_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test schedule_workflow handles HTTP 404 Not Found error (e.g., bad workflowId).""" + """Test schedule handles HTTP 404 Not Found error (e.g., bad workflowId).""" error_response = {"detail": "Workflow not found."} mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post(mock_url, json=error_response, status_code=404) with pytest.raises(SirenSDKError) as exc_info: - client.schedule_workflow( + client.workflow.schedule( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, timezone_id=TIMEZONE_ID, @@ -576,14 +576,14 @@ def test_schedule_workflow_http_404_error( def test_schedule_workflow_network_error( client: SirenClient, requests_mock: RequestsMocker ): - """Test schedule_workflow handles a network error.""" + """Test schedule handles a network error.""" mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" requests_mock.post( mock_url, exc=requests.exceptions.ConnectionError("Connection failed") ) with pytest.raises(SirenSDKError) as exc_info: - client.schedule_workflow( + client.workflow.schedule( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, timezone_id=TIMEZONE_ID, @@ -599,13 +599,13 @@ def test_schedule_workflow_network_error( def test_schedule_workflow_http_error_non_json_response( client: SirenClient, requests_mock: RequestsMocker ): - """Test schedule_workflow handles HTTP error with non-JSON response.""" + """Test schedule handles HTTP error with non-JSON response.""" mock_url = f"{MOCK_V1_PUBLIC_BASE}/schedules" non_json_error_text = "Gateway Timeout" requests_mock.post(mock_url, text=non_json_error_text, status_code=504) with pytest.raises(SirenSDKError) as exc_info: - client.schedule_workflow( + client.workflow.schedule( name=SCHEDULE_NAME, schedule_time=SCHEDULE_TIME, timezone_id=TIMEZONE_ID, From 460c2cf06908b96058573672e75c1bcc599a5fd4 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 13:15:36 +0530 Subject: [PATCH 31/42] refactor: split channel template methods into different namespace --- README.md | 64 ++++++++-------- coverage.xml | 86 +++++++++++++-------- examples/templates.py | 117 ++++++++++++----------------- siren/client.py | 9 +++ siren/clients/__init__.py | 2 + siren/clients/channel_templates.py | 88 ++++++++++++++++++++++ 6 files changed, 234 insertions(+), 132 deletions(-) create mode 100644 siren/clients/channel_templates.py diff --git a/README.md b/README.md index 5561e7c..46590c6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ from siren import SirenClient client = SirenClient(api_key="YOUR_SIREN_API_KEY") # Send a message -message_id = client.send_message( +message_id = client.message.send( template_name="welcome_email", channel="EMAIL", recipient_type="direct", @@ -35,41 +35,43 @@ message_id = client.send_message( ) print(f"Message sent! ID: {message_id}") -# to specify env +# To specify env client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") ``` ## SDK Methods -The Siren-AI Python SDK provides an interface to interact with the Siren API. - -**Templates** -- **`get_templates()`** - Retrieves a list of notification templates with optional filtering, sorting, and pagination -- **`create_template()`** - Creates a new notification template -- **`update_template()`** - Updates an existing notification template -- **`delete_template()`** - Deletes an existing notification template -- **`publish_template()`** - Publishes a template, making its latest draft version live -- **`create_channel_templates()`** - Creates or updates channel-specific templates (EMAIL, SMS, etc.) -- **`get_channel_templates()`** - Retrieves channel templates for a specific template version - -**Messaging** -- **`send_message()`** - Sends a message using a template to a recipient via a chosen channel -- **`get_replies()`** - Retrieves replies for a specific message ID -- **`get_message_status()`** - Retrieves the status of a specific message (SENT, DELIVERED, FAILED, etc.) - -**Workflows** -- **`trigger_workflow()`** - Triggers a workflow with given data and notification payloads -- **`trigger_bulk_workflow()`** - Triggers a workflow in bulk for multiple recipients -- **`schedule_workflow()`** - Schedules a workflow to run at a future time (once or recurring) - -**Webhooks** -- **`configure_notifications_webhook()`** - Configures webhook URL for receiving status updates -- **`configure_inbound_message_webhook()`** - Configures webhook URL for receiving inbound messages - -**Users** -- **`add_user()`** - Creates a new user or updates existing user with given unique_id -- **`update_user()`** - Updates an existing user's information -- **`delete_user()`** - Deletes an existing user +The Siren-AI Python SDK provides a clean, namespaced interface to interact with the Siren API. + +**Templates** (`client.template.*`) +- **`client.template.get()`** - Retrieves a list of notification templates with optional filtering, sorting, and pagination +- **`client.template.create()`** - Creates a new notification template +- **`client.template.update()`** - Updates an existing notification template +- **`client.template.delete()`** - Deletes an existing notification template +- **`client.template.publish()`** - Publishes a template, making its latest draft version live + +**Channel Templates** (`client.channel_template.*`) +- **`client.channel_template.create()`** - Creates or updates channel-specific templates (EMAIL, SMS, etc.) +- **`client.channel_template.get()`** - Retrieves channel templates for a specific template version + +**Messaging** (`client.message.*`) +- **`client.message.send()`** - Sends a message using a template to a recipient via a chosen channel +- **`client.message.get_replies()`** - Retrieves replies for a specific message ID +- **`client.message.get_status()`** - Retrieves the status of a specific message (SENT, DELIVERED, FAILED, etc.) + +**Workflows** (`client.workflow.*`) +- **`client.workflow.trigger()`** - Triggers a workflow with given data and notification payloads +- **`client.workflow.trigger_bulk()`** - Triggers a workflow in bulk for multiple recipients +- **`client.workflow.schedule()`** - Schedules a workflow to run at a future time (once or recurring) + +**Webhooks** (`client.webhook.*`) +- **`client.webhook.configure_notifications()`** - Configures webhook URL for receiving status updates +- **`client.webhook.configure_inbound()`** - Configures webhook URL for receiving inbound messages + +**Users** (`client.user.*`) +- **`client.user.add()`** - Creates a new user or updates existing user with given unique_id +- **`client.user.update()`** - Updates an existing user's information +- **`client.user.delete()`** - Deletes an existing user ## Examples diff --git a/coverage.xml b/coverage.xml index a3f85ba..d96d4d7 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,52 +16,51 @@ - + + - - - - - - - - - + + + + + + + + + - - - + - - - - - - + + - - + + - - + + - - + + - + + + + + @@ -92,7 +91,7 @@ - + @@ -103,7 +102,8 @@ - + + @@ -162,6 +162,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/templates.py b/examples/templates.py index a0fc195..353c44a 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -1,4 +1,4 @@ -"""Example script demonstrating template methods using SirenClient.""" +"""Example script demonstrating template and channel template operations.""" import os import sys @@ -10,124 +10,99 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -def get_templates_example(client: SirenClient) -> None: - """Example of getting templates.""" +def create_template_example(client: SirenClient) -> str: + """Example of creating a template.""" try: - templates = client.template.get(page=0, size=2) - print(f"Retrieved {len(templates)} templates") + created = client.template.create( + name="Welcome_Email_Example", + description="A welcome email template", + tag_names=["welcome"], + variables=[{"name": "user_name", "defaultValue": "Guest"}], + ) + print(f"Created template: {created.template_id}") + return created.template_id except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: print(f"SDK Error: {e.message}") -def create_template_example(client: SirenClient) -> str: - """Example of creating a template.""" - import time - - timestamp = int(time.time()) - +def create_channel_templates_example(client: SirenClient, template_id: str): + """Example of creating channel templates.""" try: - created = client.template.create( - name=f"SDK_Example_Template_{timestamp}", - description="Test template from SDK", - tag_names=["sdk-test", "example"], - variables=[{"name": "user_name", "defaultValue": "Guest"}], - configurations={ - "EMAIL": { - "subject": "Hello {{user_name}}!", - "channel": "EMAIL", - "body": "

Welcome {{user_name}}!

", - "isRawHTML": True, - "isPlainText": False, - } + channel_templates = client.channel_template.create( + template_id=template_id, + EMAIL={ + "subject": "Welcome {{user_name}}!", + "body": "

Hello {{user_name}}!

", + "channel": "EMAIL", + "isRawHTML": True, }, + SMS={"body": "Hi {{user_name}}! Welcome aboard!", "channel": "SMS"}, ) - print(f"Created template: {created.template_id}") - return created.template_id + print(f"Created {len(channel_templates)} channel templates") + return channel_templates[0].template_version_id if channel_templates else None except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") - return None except SirenSDKError as e: print(f"SDK Error: {e.message}") - return None -def update_template_example(client: SirenClient, template_id: str) -> None: +def update_template_example(client: SirenClient, template_id: str): """Example of updating a template.""" try: updated = client.template.update( template_id, - name="Updated_SDK_Example", - description="Updated description from SDK", - tag_names=["updated", "sdk-test"], + name="Updated_Welcome_Template", + description="Updated welcome email template", + tag_names=["welcome", "updated"], ) - print(f"Updated template: {updated.id}") + print(f"Updated template: {updated.name}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: print(f"SDK Error: {e.message}") -def publish_template_example(client: SirenClient, template_id: str): - """Example of publishing a template.""" +def get_templates_example(client: SirenClient): + """Example of getting templates.""" try: - published = client.template.publish(template_id) - print(f"Published template: {published.id}") - return published + templates = client.template.get(page=0, size=2) + print(f"Retrieved {len(templates)} templates") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") - return None except SirenSDKError as e: print(f"SDK Error: {e.message}") - return None -def create_channel_templates_example(client: SirenClient, template_id: str) -> None: - """Example of creating channel templates for a template.""" +def get_channel_templates_example(client: SirenClient, version_id: str): + """Example of getting channel templates.""" try: - result = client.template.create_channel_templates( - template_id, - SMS={ - "body": "Hello {{user_name}}! This is from SDK.", - "channel": "SMS", - "isFlash": False, - "isUnicode": False, - }, - EMAIL={ - "subject": "Welcome {{user_name}}!", - "channel": "EMAIL", - "body": "

Hello {{user_name}}, welcome from SDK!

", - "isRawHTML": True, - "isPlainText": False, - }, - ) - print(f"Created {len(result)} channel templates") + channel_templates = client.channel_template.get(version_id=version_id) + print(f"Retrieved {len(channel_templates)} channel templates") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: print(f"SDK Error: {e.message}") -def get_channel_templates_example(client: SirenClient, version_id: str) -> None: - """Example of getting channel templates for a template version.""" +def publish_template_example(client: SirenClient, template_id: str): + """Example of publishing a template.""" try: - result = client.template.get_channel_templates(version_id, page=0, size=5) - print(f"Retrieved {len(result)} channel templates") + published = client.template.publish(template_id) + print(f"Published template: {published.name}") + return published except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: print(f"SDK Error: {e.message}") -def delete_template_example(client: SirenClient, template_id: str) -> None: +def delete_template_example(client: SirenClient, template_id: str): """Example of deleting a template.""" try: - result = client.template.delete(template_id) - if result: - print(f"Successfully deleted template: {template_id}") - else: - print(f"Failed to delete template: {template_id}") + success = client.template.delete(template_id) + print(f"Deleted template: {success}") except SirenAPIError as e: print(f"API Error: {e.error_code} - {e.api_message}") except SirenSDKError as e: @@ -140,13 +115,13 @@ def delete_template_example(client: SirenClient, template_id: str) -> None: print("Error: SIREN_API_KEY environment variable not set.") sys.exit(1) - client = SirenClient(api_key) + client = SirenClient(api_key=api_key) get_templates_example(client) template_id = create_template_example(client) if template_id: update_template_example(client, template_id) - create_channel_templates_example(client, template_id) + version_id = create_channel_templates_example(client, template_id) published = publish_template_example(client, template_id) if published and published.published_version: get_channel_templates_example(client, published.published_version.id) diff --git a/siren/client.py b/siren/client.py index c3a6155..7481c4e 100644 --- a/siren/client.py +++ b/siren/client.py @@ -3,6 +3,7 @@ import os from typing import Literal, Optional +from .clients.channel_templates import ChannelTemplateClient from .clients.messaging import MessageClient from .clients.templates import TemplateClient from .clients.users import UserClient @@ -43,6 +44,9 @@ def __init__(self, api_key: str, env: Optional[Literal["dev", "prod"]] = None): self._template_client = TemplateClient( api_key=self.api_key, base_url=self.base_url ) + self._channel_template_client = ChannelTemplateClient( + api_key=self.api_key, base_url=self.base_url + ) self._workflow_client = WorkflowClient( api_key=self.api_key, base_url=self.base_url ) @@ -59,6 +63,11 @@ def template(self) -> TemplateClient: """Access to template operations.""" return self._template_client + @property + def channel_template(self) -> ChannelTemplateClient: + """Access to channel template operations.""" + return self._channel_template_client + @property def workflow(self) -> WorkflowClient: """Access to workflow operations.""" diff --git a/siren/clients/__init__.py b/siren/clients/__init__.py index 739e8e2..683dfe1 100644 --- a/siren/clients/__init__.py +++ b/siren/clients/__init__.py @@ -1,6 +1,7 @@ """Client classes for the Siren SDK.""" from .base import BaseClient +from .channel_templates import ChannelTemplateClient from .messaging import MessageClient from .templates import TemplateClient from .users import UserClient @@ -9,6 +10,7 @@ __all__ = [ "BaseClient", + "ChannelTemplateClient", "TemplateClient", "UserClient", "MessageClient", diff --git a/siren/clients/channel_templates.py b/siren/clients/channel_templates.py new file mode 100644 index 0000000..687ccbb --- /dev/null +++ b/siren/clients/channel_templates.py @@ -0,0 +1,88 @@ +"""Channel template client for the Siren API.""" + +from typing import List, Optional + +from ..models.templates import ( + ChannelTemplate, + CreateChannelTemplatesRequest, + CreateChannelTemplatesResponse, + GetChannelTemplatesResponse, +) +from .base import BaseClient + + +class ChannelTemplateClient(BaseClient): + """Client for channel template operations.""" + + def create( + self, template_id: str, **channel_templates_data + ) -> List[ChannelTemplate]: + """Create or update channel templates for a specific template. + + Args: + template_id: The ID of the template for which to create channel templates. + **channel_templates_data: Channel templates configuration where keys are + channel names (e.g., "EMAIL", "SMS") and values + are the channel-specific template objects. + + Returns: + List[ChannelTemplate]: List of created channel template objects. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + response = self._make_request( + method="POST", + endpoint=f"/api/v1/public/template/{template_id}/channel-templates", + request_model=CreateChannelTemplatesRequest, + response_model=CreateChannelTemplatesResponse, + data=channel_templates_data, + ) + return response + + def get( + self, + version_id: str, + channel: Optional[str] = None, + search: Optional[str] = None, + sort: Optional[str] = None, + page: Optional[int] = None, + size: Optional[int] = None, + ) -> List[ChannelTemplate]: + """Fetch channel templates for a specific template version. + + Args: + version_id: The ID of the template version for which to fetch channel templates. + channel: Filter by channel type (e.g., "EMAIL", "SMS"). + search: Search term to filter channel templates. + sort: Sort by field. + page: Page number. + size: Page size. + + Returns: + List[ChannelTemplate]: List of channel template objects. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + """ + params = {} + if channel is not None: + params["channel"] = channel + if search is not None: + params["search"] = search + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if size is not None: + params["size"] = size + + response = self._make_request( + method="GET", + endpoint=f"/api/v1/public/template/versions/{version_id}/channel-templates", + response_model=GetChannelTemplatesResponse, + params=params, + ) + return response From 0224432f42cc41f51937ed5a5421a7042442a136 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 15:25:17 +0530 Subject: [PATCH 32/42] init transport classes, async client, asyncwebhookclient --- coverage.xml | 128 +++++++++++++++++++++++++++++--- examples/webhooks_async.py | 30 ++++++++ pyproject.toml | 2 + siren/async_client.py | 66 ++++++++++++++++ siren/clients/async_base.py | 127 +++++++++++++++++++++++++++++++ siren/clients/webhooks_async.py | 40 ++++++++++ siren/http/transport.py | 89 ++++++++++++++++++++++ 7 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 examples/webhooks_async.py create mode 100644 siren/async_client.py create mode 100644 siren/clients/async_base.py create mode 100644 siren/clients/webhooks_async.py create mode 100644 siren/http/transport.py diff --git a/coverage.xml b/coverage.xml index d96d4d7..2dd8a60 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -16,6 +16,35 @@
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -91,7 +120,7 @@
- + @@ -106,6 +135,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -170,11 +263,9 @@ - - - - - + + + @@ -184,8 +275,10 @@ + - + + @@ -293,6 +386,23 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/webhooks_async.py b/examples/webhooks_async.py new file mode 100644 index 0000000..e4db810 --- /dev/null +++ b/examples/webhooks_async.py @@ -0,0 +1,30 @@ +"""Asynchronous usage example: configuring webhooks with Siren SDK.""" + +import asyncio + +from siren.async_client import AsyncSirenClient + +API_KEY = "sk_example_key" + + +async def main() -> None: + """Run a simple webhook configuration flow.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + + # Configure notifications webhook + config = await client.webhook.configure_notifications( + url="https://example.com/notify" + ) + print("Notifications webhook set:", config) + + # Configure inbound-message webhook + inbound_config = await client.webhook.configure_inbound( + url="https://example.com/inbound" + ) + print("Inbound webhook set:", inbound_config) + + await client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 73ad629..9175a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ keywords = ["siren", "notifications", "api", "sdk", "ai", "messaging", "communic dependencies = [ "requests>=2.25.0", # HTTP client library + "httpx>=0.26.0", # Async/sync HTTP client supporting both transports "pydantic[email]>=2.0,<3.0", # Data validation and settings management ] @@ -47,6 +48,7 @@ dev = [ "pytest-cov", # For test coverage reports "pytest-mock", # For mocking objects in tests "requests-mock", # For mocking HTTP requests in tests + "respx>=0.21.3", # For mocking httpx in async tests "ruff", # Linter, formatter, import sorter "pyright", # Static type checker "pre-commit", # For managing pre-commit hooks diff --git a/siren/async_client.py b/siren/async_client.py new file mode 100644 index 0000000..6ee8ad9 --- /dev/null +++ b/siren/async_client.py @@ -0,0 +1,66 @@ +"""Asynchronous entry point for Siren SDK. + +Provides :class:`AsyncSirenClient` which mirrors the synchronous +:class:`siren.client.SirenClient` API but exposes awaitable domain clients. +Currently only the *webhook* domain is implemented; other domains will follow +incrementally. +""" + +from __future__ import annotations + +import os +from typing import Literal + +from .clients.webhooks_async import AsyncWebhookClient + + +class AsyncSirenClient: # noqa: D101 + API_URLS = { + "dev": "https://api.dev.trysiren.io", + "prod": "https://api.trysiren.io", + } + + def __init__(self, api_key: str, env: Literal["dev", "prod"] | None = None): + """Create a new *asynchronous* Siren client. + + Args: + api_key: Bearer token obtained from the Siren dashboard. + env: Target environment – ``"dev"`` (default when ``SIREN_ENV=dev``) + or ``"prod"`` (default). + """ + self.api_key = api_key + + if env is None: + env = os.getenv("SIREN_ENV", "prod") + + if env not in self.API_URLS: + raise ValueError( + f"Invalid environment '{env}'. Must be one of: {list(self.API_URLS.keys())}" + ) + + self.env: Literal["dev", "prod"] = env # concrete + self.base_url = self.API_URLS[env] + + # Domain clients + self._webhook_client = AsyncWebhookClient( + api_key=self.api_key, base_url=self.base_url + ) + + # ---- Domain accessors ---- + @property + def webhook(self) -> AsyncWebhookClient: + """Non-blocking webhook operations.""" + return self._webhook_client + + # ---- Context management ---- + async def aclose(self) -> None: + """Release underlying HTTP resources.""" + await self._webhook_client.aclose() + + async def __aenter__(self) -> AsyncSirenClient: + """Enter async context manager returning *self*.""" + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override] + """Exit async context manager, closing transports.""" + await self.aclose() diff --git a/siren/clients/async_base.py b/siren/clients/async_base.py new file mode 100644 index 0000000..c1c041f --- /dev/null +++ b/siren/clients/async_base.py @@ -0,0 +1,127 @@ +"""Asynchronous BaseClient for Siren SDK. + +Mirrors the synchronous ``BaseClient`` behaviour but utilises +:class:`siren.http.transport.AsyncTransport` for non-blocking HTTP calls. +""" + +from __future__ import annotations + +from typing import Any + +import httpx # type: ignore +from pydantic import BaseModel, ValidationError + +from ..exceptions import SirenAPIError, SirenSDKError +from ..http.transport import AsyncTransport + + +class AsyncBaseClient: # noqa: D101 – docstring provided at module level + def __init__(self, api_key: str, base_url: str, timeout: int = 10): + """Construct the asynchronous base client. + + Args: + api_key: Bearer token for Siren API. + base_url: Fully-qualified API root (e.g. ``https://api.trysiren.io``). + timeout: Request timeout in seconds. + """ + self.api_key = api_key + self.base_url = base_url + self._transport = AsyncTransport(timeout=timeout) + + async def _parse_json_response(self, response: httpx.Response) -> dict: # noqa: D401 + try: + return response.json() + except ValueError as e: + # httpx raises ValueError for invalid JSON + raise SirenSDKError( + f"API response was not valid JSON. Status: {response.status_code}. Content: {response.text}", + original_exception=e, + status_code=response.status_code, + ) + + async def _make_request( # noqa: C901 + self, + method: str, + endpoint: str, + request_model: type[BaseModel] | None = None, + response_model: type[BaseModel] | None = None, + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + expected_status: int = 200, + ) -> BaseModel | bool: + url = f"{self.base_url}{endpoint}" + headers: dict[str, str] = {"Authorization": f"Bearer {self.api_key}"} + + json_data = None + if data and request_model: + try: + validated_request = request_model.model_validate(data) + json_data = validated_request.model_dump( + by_alias=True, exclude_none=True + ) + except ValidationError as e: + raise SirenSDKError(f"Invalid parameters: {e}", original_exception=e) + + if json_data is not None: + headers["Content-Type"] = "application/json" + + try: + response = await self._transport.request( + method=method, + url=url, + headers=headers, + json=json_data, + params=params, + ) + + # Success + if response.status_code == expected_status: + if expected_status == 204: + return True + + if response_model is not None: + response_json = await self._parse_json_response(response) + parsed = response_model.model_validate(response_json) + if hasattr(parsed, "data") and parsed.data is not None: + return parsed.data + + # Error handling + response_json = await self._parse_json_response(response) + if response_model is not None: + try: + parsed = response_model.model_validate(response_json) + if getattr(parsed, "error_detail", None): + raise SirenAPIError( + error_detail=parsed.error_detail, + status_code=response.status_code, + raw_response=response_json, + ) + except ValidationError: + pass + + raise SirenSDKError( + message=f"Unexpected API response. Status: {response.status_code}", + status_code=response.status_code, + raw_response=response_json, + ) + + except httpx.RequestError as e: + raise SirenSDKError( + f"Network or connection error: {e}", original_exception=e + ) + except (SirenAPIError, SirenSDKError): + raise + except Exception as e: # noqa: BLE001 + raise SirenSDKError(f"Unexpected error: {e}", original_exception=e) + + async def aclose(self) -> None: + """Close underlying transport.""" + await self._transport.aclose() + + async def __aenter__(self) -> AsyncBaseClient: + """Enter async context manager and return *self*.""" + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override] + """Exit context manager, closing the underlying transport.""" + await self.aclose() diff --git a/siren/clients/webhooks_async.py b/siren/clients/webhooks_async.py new file mode 100644 index 0000000..392afec --- /dev/null +++ b/siren/clients/webhooks_async.py @@ -0,0 +1,40 @@ +"""Asynchronous webhook client for the Siren API.""" + +from __future__ import annotations + +from ..models.webhooks import ( + InboundWebhookRequest, + NotificationsWebhookRequest, + WebhookConfig, + WebhookResponse, +) +from .async_base import AsyncBaseClient + + +class AsyncWebhookClient(AsyncBaseClient): # noqa: D101 – simple wrapper + async def configure_notifications(self, url: str) -> WebhookConfig: + """Configure the notifications webhook (async).""" + payload = {"webhook_config": {"url": url}} + + response = await self._make_request( + method="PUT", + endpoint="/api/v1/public/webhooks", + request_model=NotificationsWebhookRequest, + response_model=WebhookResponse, + data=payload, + ) + # ``response`` is WebhookData; return nested config. + return response.webhook_config # type: ignore[return-value] + + async def configure_inbound(self, url: str) -> WebhookConfig: + """Configure the inbound message webhook (async).""" + payload = {"inbound_webhook_config": {"url": url}} + + response = await self._make_request( + method="PUT", + endpoint="/api/v1/public/webhooks", + request_model=InboundWebhookRequest, + response_model=WebhookResponse, + data=payload, + ) + return response.inbound_webhook_config # type: ignore[return-value] diff --git a/siren/http/transport.py b/siren/http/transport.py new file mode 100644 index 0000000..49d6893 --- /dev/null +++ b/siren/http/transport.py @@ -0,0 +1,89 @@ +"""HTTP transport abstraction for Siren SDK. + +The SDK exposes two transports: +1. ``SyncTransport`` – wraps the blocking ``requests`` API for backwards-compatibility. +2. ``AsyncTransport`` – wraps an ``httpx.AsyncClient`` for non-blocking access. + +Both classes expose the same ``request`` signature so domain clients can be +written once and injected with the appropriate transport implementation. +""" + +from __future__ import annotations + +from typing import Any # noqa: D401 + +import httpx # type: ignore +import requests + +__all__ = ["SyncTransport", "AsyncTransport"] + + +class SyncTransport: # noqa: D101 – Simple wrapper, docstring at class level + def __init__(self, timeout: int = 10) -> None: + """Create a synchronous transport wrapper. + + Args: + timeout: The request timeout in seconds. + """ + self._timeout = timeout + # For now keep compatibility: still use the global ``requests`` API rather + # than an ``httpx.Client`` so existing unit-tests remain untouched. This + # branch can later be switched to ``httpx.Client`` once the test mocks + # are migrated. + + def request( + self, + *, + method: str, + url: str, + headers: dict[str, str] | None = None, + json: Any | None = None, + params: dict[str, Any] | None = None, + ) -> requests.Response: + """Perform a blocking HTTP request using ``requests``.""" + return requests.request( + method=method, + url=url, + headers=headers, + json=json, + params=params, + timeout=self._timeout, + ) + + +class AsyncTransport: # noqa: D101 – Simple wrapper, docstring at class level + def __init__(self, timeout: int = 10) -> None: + """Instantiate the transport with an internal ``httpx.AsyncClient``.""" + self._client = httpx.AsyncClient(timeout=timeout) + + async def request( + self, + *, + method: str, + url: str, + headers: dict[str, str] | None = None, + json: Any | None = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + """Make an asynchronous HTTP request using ``httpx.AsyncClient``.""" + response = await self._client.request( + method=method, + url=url, + headers=headers, + json=json, + params=params, + ) + return response + + async def aclose(self) -> None: # noqa: D401 – simple verb + """Close the underlying ``httpx`` client.""" + await self._client.aclose() + + # Enable async context-manager usage + async def __aenter__(self) -> AsyncTransport: + """Enter async context manager.""" + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override] + """Exit context manager, ensure transport is closed.""" + await self.aclose() From aa59eeea8ff4e4b5e08682ca6583ffaaa4f642fa Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 18:47:22 +0530 Subject: [PATCH 33/42] feat: support async --- .cursor/rules/project_context.mdc | 26 ++ README.md | 50 ++- coverage.xml | 435 ++++++++++++++++------- examples/{messages.py => messaging.py} | 0 examples/messaging_async.py | 46 +++ examples/templates_async.py | 81 +++++ examples/users_async.py | 52 +++ examples/webhooks_async.py | 41 ++- examples/workflows_async.py | 56 +++ pyproject.toml | 5 +- siren/__init__.py | 17 +- siren/async_client.py | 58 ++- siren/clients/channel_templates_async.py | 39 ++ siren/clients/messaging_async.py | 62 ++++ siren/clients/templates.py | 104 ++---- siren/clients/templates_async.py | 92 +++++ siren/clients/users_async.py | 44 +++ siren/clients/workflows_async.py | 92 +++++ siren/http/__init__.py | 1 + siren/models/messaging.py | 2 +- tests/test_messaging_async.py | 88 +++++ tests/test_templates_async.py | 40 +++ tests/test_users_async.py | 37 ++ tests/test_webhooks_async.py | 70 ++++ tests/test_workflows_async.py | 64 ++++ 25 files changed, 1363 insertions(+), 239 deletions(-) rename examples/{messages.py => messaging.py} (100%) create mode 100644 examples/messaging_async.py create mode 100644 examples/templates_async.py create mode 100644 examples/users_async.py create mode 100644 examples/workflows_async.py create mode 100644 siren/clients/channel_templates_async.py create mode 100644 siren/clients/messaging_async.py create mode 100644 siren/clients/templates_async.py create mode 100644 siren/clients/users_async.py create mode 100644 siren/clients/workflows_async.py create mode 100644 siren/http/__init__.py create mode 100644 tests/test_messaging_async.py create mode 100644 tests/test_templates_async.py create mode 100644 tests/test_users_async.py create mode 100644 tests/test_webhooks_async.py create mode 100644 tests/test_workflows_async.py diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index 10a9c58..6153560 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -101,3 +101,29 @@ siren-ai/ **Testing Gaps**: - Integration tests against live API (currently only unit tests with mocks) + +## Documentation / Examples + +**Example Script Guidelines**: +- Call `dotenv.load_dotenv()` first, then read `SIREN_API_KEY` (and optional `SIREN_ENV`). +- Instantiate the appropriate sync (`SirenClient`) or async (`AsyncSirenClient`) client with those env values. +- Wrap core SDK calls in minimal error handling: + ```python + try: + ... # SDK call(s) + except SirenAPIError as e: + print(f"API error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK error: {e.message}") + ``` +- Print only the key fields from responses (e.g., `id`, `url`, `status`) to keep output concise. +- Scripts should demonstrate one or two primary operations per domain—avoid extra verbosity. + +## HTTP Transport & Sync/Async Support +- Under the hood the SDK now uses a pluggable transport layer (`siren/http/transport.py`). +- **Sync** clients delegate to `SyncTransport` (currently wraps `requests`, easy to flip to `httpx.Client`). +- **Async** clients delegate to `AsyncTransport` which wraps `httpx.AsyncClient`. +- Every domain client has a 1-to-1 async counterpart; `AsyncSirenClient` exposes them. +- Sync and async share identical method names and signatures—just `await` the async version. +- Testing: existing sync tests use `requests-mock`; async tests use **respx** for `httpx`. +- Examples: each domain has both `*_async.py` and sync counterpart in `examples/` demonstrating identical flows. diff --git a/README.md b/README.md index 46590c6..1db4ab9 100644 --- a/README.md +++ b/README.md @@ -19,26 +19,42 @@ pip install siren-ai ## Basic Usage +### Synchronous ```python from siren import SirenClient -# Initialize the client -client = SirenClient(api_key="YOUR_SIREN_API_KEY") +client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") -# Send a message message_id = client.message.send( template_name="welcome_email", channel="EMAIL", recipient_type="direct", recipient_value="user@example.com", - template_variables={"user_name": "John Doe"} + template_variables={"user_name": "John Doe"}, ) -print(f"Message sent! ID: {message_id}") +print("Sent:", message_id) +``` -# To specify env -client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") +### Asynchronous +```python +from siren import AsyncSirenClient + +client = AsyncSirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") + +message_id = await client.message.send( + template_name="welcome_email", + channel="EMAIL", + recipient_type="direct", + recipient_value="user@example.com", + template_variables={"user_name": "John Doe"}, +) +print("Sent:", message_id) + +await client.aclose() ``` +All synchronous methods have a 1-to-1 asynchronous equivalent—just `await` them on the async client. + ## SDK Methods The Siren-AI Python SDK provides a clean, namespaced interface to interact with the Siren API. @@ -114,19 +130,19 @@ For testing the SDK, set these environment variables: *(On Windows, use: `.venv\Scripts\activate`)* 4. **Install dependencies with `uv`:** - This installs `siren-ai` in editable mode (`-e`) and all development dependencies (`.[dev]`). - ```bash - uv pip install -e ".[dev]" - ``` + This installs `siren-ai` in editable mode (`-e`) and all development dependencies (`.[dev]`). + ```bash + uv pip install -e ".[dev]" + ``` 5. **Set up pre-commit hooks:** - (Ensures code quality before commits) - ```bash - uv run pre-commit install - ``` + (Ensures code quality before commits) + ```bash + uv run pre-commit install + ``` - You are now ready to contribute to the `siren-ai` SDK! - Try `$ python examples/webhooks.py` + You are now ready to contribute to the `siren-ai` SDK! + Try `$ python examples/webhooks.py` ### Code Style & Linting diff --git a/coverage.xml b/coverage.xml index 2dd8a60..39930e1 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,48 +1,79 @@ - + /home/jithu/projects/siren-ai/siren - + - - - + + + + - + - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -120,7 +151,7 @@ - + @@ -135,47 +166,47 @@ - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - + + + + + + - - - - - + + + + + @@ -191,11 +222,11 @@ - - - + + + - + @@ -255,7 +286,7 @@ - + @@ -263,22 +294,38 @@ - - + + - - - - + + + + - + - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -302,56 +349,103 @@ - + - + + + - - - - - + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -370,6 +464,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -386,21 +500,21 @@ - + - - - - - - - - - - - - + + + + + + + + + + + + @@ -431,6 +545,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/messages.py b/examples/messaging.py similarity index 100% rename from examples/messages.py rename to examples/messaging.py diff --git a/examples/messaging_async.py b/examples/messaging_async.py new file mode 100644 index 0000000..6337464 --- /dev/null +++ b/examples/messaging_async.py @@ -0,0 +1,46 @@ +"""Asynchronous example: sending a message and fetching status & replies.""" + +import asyncio +import os + +from dotenv import load_dotenv + +from siren.async_client import AsyncSirenClient +from siren.exceptions import SirenAPIError, SirenSDKError + + +async def main() -> None: + """Send message then query its status and replies.""" + load_dotenv() + api_key = os.getenv("SIREN_API_KEY") + + client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + + try: + # Send message + message_id = await client.message.send( + template_name="sampleTemplate", + channel="SLACK", + recipient_type="direct", + recipient_value="U01UBCD06BB", + template_variables={"user_name": "John"}, + ) + print("Sent message id:", message_id) + + # Fetch status + status = await client.message.get_status(message_id) + print("Status:", status) + + # Fetch replies + replies = await client.message.get_replies(message_id) + print("Replies count:", len(replies)) + except SirenAPIError as e: + print(f"API error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK error: {e.message}") + + await client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/templates_async.py b/examples/templates_async.py new file mode 100644 index 0000000..4a723ab --- /dev/null +++ b/examples/templates_async.py @@ -0,0 +1,81 @@ +"""Asynchronous example for template & channel template operations.""" + +import asyncio +import os + +from dotenv import load_dotenv + +from siren.async_client import AsyncSirenClient +from siren.exceptions import SirenAPIError, SirenSDKError + + +async def main() -> None: + """Create and publish a template asynchronously.""" + load_dotenv() + api_key = os.getenv("SIREN_API_KEY") + + client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + + try: + # Get templates list + templates = await client.template.get(page=0, size=2) + print("Retrieved", len(templates), "templates") + + # Create template + created = await client.template.create( + name="Welcome_Email_Example", + description="A welcome email template", + tag_names=["welcome"], + variables=[{"name": "user_name", "defaultValue": "Guest"}], + ) + print("Created template:", created.template_id) + + # Update template metadata + updated = await client.template.update( + created.template_id, + name="Updated_Welcome_Template", + description="Updated welcome email template", + tag_names=["welcome", "updated"], + ) + print("Updated template:", updated.name) + + # Create channel templates (EMAIL & SMS) + channel_templates = await client.channel_template.create( + template_id=created.template_id, + EMAIL={ + "subject": "Welcome {{user_name}}!", + "body": "

Hello {{user_name}}!

", + "channel": "EMAIL", + "isRawHTML": True, + }, + SMS={ + "body": "Hi {{user_name}}! Welcome aboard!", + "channel": "SMS", + }, + ) + print("Created", len(channel_templates), "channel templates") + + # Publish template now that channels exist + published = await client.template.publish(created.template_id) + print("Published:", published.name) + + if published.published_version: + # Fetch channel templates list + channel_templates = await client.channel_template.get( + version_id=published.published_version.id + ) + print("Retrieved channel templates count:", len(channel_templates)) + + # Delete template (cleanup) + success = await client.template.delete(created.template_id) + print("Deleted template:", success) + except SirenAPIError as e: + print(f"API error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK error: {e.message}") + + await client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/users_async.py b/examples/users_async.py new file mode 100644 index 0000000..41077e9 --- /dev/null +++ b/examples/users_async.py @@ -0,0 +1,52 @@ +"""Asynchronous example: add, update, delete user.""" + +import asyncio +import os + +from dotenv import load_dotenv + +from siren.async_client import AsyncSirenClient +from siren.exceptions import SirenAPIError, SirenSDKError + + +async def main() -> None: + """Add, update, delete a user asynchronously.""" + load_dotenv() + api_key = os.getenv("SIREN_API_KEY") + + client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + + try: + # Add user + user = await client.user.add( + unique_id="john_doe_008", + last_name="Doe", + email="john.doe@company.com", + active_channels=["EMAIL", "SMS"], + active=True, + ) + print("Created user:", user.id) + + # Update user + updated = await client.user.update( + "john_doe_008", + first_name="Jane", + last_name="Smith", + email="jane.smith@company.com", + active_channels=["EMAIL", "SMS", "WHATSAPP"], + ) + print("Updated user:", updated.id) + + # Delete user + deleted = await client.user.delete("john_doe_008") + print("Deleted user:", deleted) + except SirenAPIError as e: + print(f"API error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK error: {e.message}") + + await client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/webhooks_async.py b/examples/webhooks_async.py index e4db810..2c6af4c 100644 --- a/examples/webhooks_async.py +++ b/examples/webhooks_async.py @@ -1,27 +1,38 @@ """Asynchronous usage example: configuring webhooks with Siren SDK.""" import asyncio +import os -from siren.async_client import AsyncSirenClient +from dotenv import load_dotenv -API_KEY = "sk_example_key" +from siren.async_client import AsyncSirenClient +from siren.exceptions import SirenAPIError, SirenSDKError async def main() -> None: """Run a simple webhook configuration flow.""" - client = AsyncSirenClient(api_key=API_KEY, env="dev") - - # Configure notifications webhook - config = await client.webhook.configure_notifications( - url="https://example.com/notify" - ) - print("Notifications webhook set:", config) - - # Configure inbound-message webhook - inbound_config = await client.webhook.configure_inbound( - url="https://example.com/inbound" - ) - print("Inbound webhook set:", inbound_config) + load_dotenv() + + api_key = os.getenv("SIREN_API_KEY") + + client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + + try: + # Configure notifications webhook + config = await client.webhook.configure_notifications( + url="https://example.com/async_notify" + ) + print("Notifications webhook set:", config.url) + + # Configure inbound-message webhook + inbound_config = await client.webhook.configure_inbound( + url="https://example.com/async_inbound" + ) + print("Inbound webhook set:", inbound_config.url) + except SirenAPIError as e: + print(f"API error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK error: {e.message}") await client.aclose() diff --git a/examples/workflows_async.py b/examples/workflows_async.py new file mode 100644 index 0000000..94c6c74 --- /dev/null +++ b/examples/workflows_async.py @@ -0,0 +1,56 @@ +"""Asynchronous example demonstrating workflow trigger APIs.""" + +import asyncio +import os + +from dotenv import load_dotenv + +from siren.async_client import AsyncSirenClient +from siren.exceptions import SirenAPIError, SirenSDKError + + +async def main() -> None: + """Trigger and bulk-trigger workflows asynchronously.""" + load_dotenv() + api_key = os.getenv("SIREN_API_KEY") + + client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + + try: + # Trigger single workflow execution + execution = await client.workflow.trigger( + workflow_name="sampleWorkflow", + data={"subject": "Welcome"}, + notify={"email": "user@example.com"}, + ) + print("Workflow triggered:", execution.request_id) + + # Trigger bulk workflow + bulk = await client.workflow.trigger_bulk( + workflow_name="sampleWorkflow", + notify=[{"email": "user1@example.com"}, {"email": "user2@example.com"}], + data={"template": "welcome"}, + ) + print("Bulk workflow triggered:", bulk.request_id) + + # Schedule workflow (commented; requires valid IDs) + # schedule = await client.workflow.schedule( + # name="sampleWorkflow123", + # schedule_time="21:31:00", + # timezone_id="Asia/Kolkata", + # start_date="2024-06-11", + # workflow_type="DAILY", + # workflow_id="acd59a55-1072-41a7-90d9-5554b21aef1b", + # input_data={"type": "daily_summary"}, + # ) + # print("Workflow scheduled:", schedule.id) + except SirenAPIError as e: + print(f"API error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK error: {e.message}") + + await client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 9175a85..1b00bf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "siren-ai" -version = "0.1.0" +version = "0.2.0" authors = [ { name = "Jithu", email = "jithu@keyvalue.systems" }, ] @@ -48,7 +48,8 @@ dev = [ "pytest-cov", # For test coverage reports "pytest-mock", # For mocking objects in tests "requests-mock", # For mocking HTTP requests in tests - "respx>=0.21.3", # For mocking httpx in async tests + "pytest-asyncio>=0.20", # Async test support + "respx>=0.21.0,<0.23", # For mocking httpx in async tests "ruff", # Linter, formatter, import sorter "pyright", # Static type checker "pre-commit", # For managing pre-commit hooks diff --git a/siren/__init__.py b/siren/__init__.py index 1dd3719..9d7f5a4 100644 --- a/siren/__init__.py +++ b/siren/__init__.py @@ -1,7 +1,18 @@ -"""Siren SDK for Python.""" +"""Siren SDK for Python. +Public API: + SirenClient – synchronous client (blocking HTTP based on *requests*). + AsyncSirenClient – asynchronous client (non-blocking HTTP based on *httpx*). + +Both clients expose the same domain‐specific namespaces (``.message``, +``.template``, etc.). Choose the async variant whenever your application is +already running inside an asyncio event-loop or needs to issue many concurrent +requests. +""" + +from .async_client import AsyncSirenClient from .client import SirenClient -__all__ = ["SirenClient"] +__all__ = ["AsyncSirenClient", "SirenClient"] -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/siren/async_client.py b/siren/async_client.py index 6ee8ad9..2ba54f4 100644 --- a/siren/async_client.py +++ b/siren/async_client.py @@ -1,9 +1,11 @@ """Asynchronous entry point for Siren SDK. Provides :class:`AsyncSirenClient` which mirrors the synchronous -:class:`siren.client.SirenClient` API but exposes awaitable domain clients. -Currently only the *webhook* domain is implemented; other domains will follow -incrementally. +:class:`siren.client.SirenClient` API but exposes awaitable domain clients for +**all** supported Siren domains (webhooks, messaging, templates, channel +templates, users, workflows). Each synchronous method has an equivalent +asynchronous counterpart with the same name – simply prepend ``await`` when +using the async client. """ from __future__ import annotations @@ -11,7 +13,12 @@ import os from typing import Literal +from .clients.channel_templates_async import AsyncChannelTemplateClient +from .clients.messaging_async import AsyncMessageClient +from .clients.templates_async import AsyncTemplateClient +from .clients.users_async import AsyncUserClient from .clients.webhooks_async import AsyncWebhookClient +from .clients.workflows_async import AsyncWorkflowClient class AsyncSirenClient: # noqa: D101 @@ -45,6 +52,21 @@ def __init__(self, api_key: str, env: Literal["dev", "prod"] | None = None): self._webhook_client = AsyncWebhookClient( api_key=self.api_key, base_url=self.base_url ) + self._message_client = AsyncMessageClient( + api_key=self.api_key, base_url=self.base_url + ) + self._template_client = AsyncTemplateClient( + api_key=self.api_key, base_url=self.base_url + ) + self._channel_template_client = AsyncChannelTemplateClient( + api_key=self.api_key, base_url=self.base_url + ) + self._user_client = AsyncUserClient( + api_key=self.api_key, base_url=self.base_url + ) + self._workflow_client = AsyncWorkflowClient( + api_key=self.api_key, base_url=self.base_url + ) # ---- Domain accessors ---- @property @@ -52,10 +74,40 @@ def webhook(self) -> AsyncWebhookClient: """Non-blocking webhook operations.""" return self._webhook_client + @property + def message(self) -> AsyncMessageClient: + """Non-blocking message operations.""" + return self._message_client + + @property + def template(self) -> AsyncTemplateClient: + """Asynchronous template operations.""" + return self._template_client + + @property + def channel_template(self) -> AsyncChannelTemplateClient: + """Asynchronous channel-template operations.""" + return self._channel_template_client + + @property + def user(self) -> AsyncUserClient: + """Asynchronous user operations.""" + return self._user_client + + @property + def workflow(self) -> AsyncWorkflowClient: + """Asynchronous workflow operations.""" + return self._workflow_client + # ---- Context management ---- async def aclose(self) -> None: """Release underlying HTTP resources.""" await self._webhook_client.aclose() + await self._message_client.aclose() + await self._template_client.aclose() + await self._channel_template_client.aclose() + await self._user_client.aclose() + await self._workflow_client.aclose() async def __aenter__(self) -> AsyncSirenClient: """Enter async context manager returning *self*.""" diff --git a/siren/clients/channel_templates_async.py b/siren/clients/channel_templates_async.py new file mode 100644 index 0000000..c965151 --- /dev/null +++ b/siren/clients/channel_templates_async.py @@ -0,0 +1,39 @@ +"""Asynchronous channel-template operations for Siren SDK.""" + +from typing import Any + +from ..models.templates import ( + ChannelTemplate, + CreateChannelTemplatesRequest, + CreateChannelTemplatesResponse, + GetChannelTemplatesResponse, +) +from .async_base import AsyncBaseClient + + +class AsyncChannelTemplateClient(AsyncBaseClient): + """Non-blocking channel-template actions.""" + + async def create( + self, template_id: str, **channel_payloads: Any + ) -> list[ChannelTemplate]: + """Create channel templates for the given template ID.""" + payload: dict[str, Any] = {k: v for k, v in channel_payloads.items() if v} + response = await self._make_request( + method="POST", + endpoint=f"/api/v1/public/template/{template_id}/channel-templates", + request_model=CreateChannelTemplatesRequest, + response_model=CreateChannelTemplatesResponse, + data=payload, + ) + return response # type: ignore[return-value] + + async def get(self, version_id: str, **params: Any) -> list[ChannelTemplate]: + """Get channel templates for a specific template version.""" + response = await self._make_request( + method="GET", + endpoint=f"/api/v1/public/template/versions/{version_id}/channel-templates", + response_model=GetChannelTemplatesResponse, + params=params or None, + ) + return response # type: ignore[return-value] diff --git a/siren/clients/messaging_async.py b/siren/clients/messaging_async.py new file mode 100644 index 0000000..0fe820f --- /dev/null +++ b/siren/clients/messaging_async.py @@ -0,0 +1,62 @@ +"""Asynchronous MessagingClient implementation.""" + +from __future__ import annotations + +from typing import Any + +from ..models.messaging import ( + MessageRepliesResponse, + MessageStatusResponse, + ReplyData, + SendMessageRequest, + SendMessageResponse, +) +from .async_base import AsyncBaseClient + + +class AsyncMessageClient(AsyncBaseClient): + """Non-blocking client for message operations.""" + + async def send( + self, + template_name: str, + channel: str, + recipient_type: str, + recipient_value: str, + template_variables: dict[str, Any] | None = None, + ) -> str: + """Send a message and return the notification ID.""" + payload: dict[str, Any] = { + "template": {"name": template_name}, + "recipient": {"type": recipient_type, "value": recipient_value}, + "channel": channel, + } + if template_variables is not None: + payload["template_variables"] = template_variables + + response = await self._make_request( + method="POST", + endpoint="/api/v1/public/send-messages", + request_model=SendMessageRequest, + response_model=SendMessageResponse, + data=payload, + ) + return response.message_id # type: ignore[return-value] + + async def get_status(self, message_id: str) -> str: + """Return delivery status for a given message ID.""" + response = await self._make_request( + method="GET", + endpoint=f"/api/v1/public/message-status/{message_id}", + response_model=MessageStatusResponse, + ) + return response.status # type: ignore[return-value] + + async def get_replies(self, message_id: str) -> list[ReplyData]: + """Return list of replies for a given message ID.""" + response = await self._make_request( + method="GET", + endpoint=f"/api/v1/public/get-reply/{message_id}", + response_model=MessageRepliesResponse, + ) + return response # type: ignore[return-value] diff --git a/siren/clients/templates.py b/siren/clients/templates.py index 9958399..ca421a4 100644 --- a/siren/clients/templates.py +++ b/siren/clients/templates.py @@ -5,12 +5,9 @@ from ..models.base import DeleteResponse from ..models.templates import ( ChannelTemplate, - CreateChannelTemplatesRequest, - CreateChannelTemplatesResponse, CreatedTemplate, CreateTemplateRequest, CreateTemplateResponse, - GetChannelTemplatesResponse, PublishTemplateResponse, Template, TemplateListResponse, @@ -18,11 +15,26 @@ UpdateTemplateResponse, ) from .base import BaseClient +from .channel_templates import ChannelTemplateClient class TemplateClient(BaseClient): """Client for template operations.""" + def __init__(self, api_key: str, base_url: str, timeout: int = 10): + """Initialize TemplateClient with an internal ChannelTemplateClient. + + Args: + api_key: Bearer token for Siren API. + base_url: API root. + timeout: Request timeout in seconds. + """ + super().__init__(api_key=api_key, base_url=base_url, timeout=timeout) + # Re-use specialised client instead of duplicating logic + self._channel_template_client = ChannelTemplateClient( + api_key=api_key, base_url=base_url, timeout=timeout + ) + def get( self, tag_names: Optional[str] = None, @@ -152,75 +164,37 @@ def publish(self, template_id: str) -> Template: ) return response + # --------------------------------------------------------------------- + # Channel-template helpers (deprecated wrappers) + # --------------------------------------------------------------------- + def create_channel_templates( self, template_id: str, **channel_templates_data - ) -> List[ChannelTemplate]: - """Create or update channel templates for a specific template. + ) -> list[ChannelTemplate]: + """DEPRECATED – use :pyattr:`siren.SirenClient.channel_template.create`. - Args: - template_id: The ID of the template for which to create channel templates. - **channel_templates_data: Channel templates configuration where keys are - channel names (e.g., "EMAIL", "SMS") and values - are the channel-specific template objects. - - Returns: - List[ChannelTemplate]: List of created channel template objects. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + This thin wrapper delegates to an internal ``ChannelTemplateClient`` to + preserve backwards-compatibility while avoiding code duplication. """ - response = self._make_request( - method="POST", - endpoint=f"/api/v1/public/template/{template_id}/channel-templates", - request_model=CreateChannelTemplatesRequest, - response_model=CreateChannelTemplatesResponse, - data=channel_templates_data, + return self._channel_template_client.create( + template_id, **channel_templates_data ) - return response def get_channel_templates( self, version_id: str, - channel: Optional[str] = None, - search: Optional[str] = None, - sort: Optional[str] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> List[ChannelTemplate]: - """Fetch channel templates for a specific template version. - - Args: - version_id: The ID of the template version for which to fetch channel templates. - channel: Filter by channel type (e.g., "EMAIL", "SMS"). - search: Search term to filter channel templates. - sort: Sort by field. - page: Page number. - size: Page size. - - Returns: - List[ChannelTemplate]: List of channel template objects. - - Raises: - SirenAPIError: If the API returns an error response. - SirenSDKError: If there's an SDK-level issue (network, parsing, etc). - """ - params = {} - if channel is not None: - params["channel"] = channel - if search is not None: - params["search"] = search - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if size is not None: - params["size"] = size - - response = self._make_request( - method="GET", - endpoint=f"/api/v1/public/template/versions/{version_id}/channel-templates", - response_model=GetChannelTemplatesResponse, - params=params, + channel: str | None = None, + search: str | None = None, + sort: str | None = None, + page: int | None = None, + size: int | None = None, + ) -> list[ChannelTemplate]: + """DEPRECATED – use ``ChannelTemplateClient.get`` instead.""" + return self._channel_template_client.get( + version_id, + channel=channel, + search=search, + sort=sort, + page=page, + size=size, ) - return response diff --git a/siren/clients/templates_async.py b/siren/clients/templates_async.py new file mode 100644 index 0000000..f86438c --- /dev/null +++ b/siren/clients/templates_async.py @@ -0,0 +1,92 @@ +"""Asynchronous template operations for Siren SDK.""" + +from typing import Any + +from ..models.templates import ( + CreatedTemplate, + CreateTemplateRequest, + CreateTemplateResponse, + PublishTemplateResponse, + Template, + TemplateListResponse, + UpdateTemplateRequest, + UpdateTemplateResponse, +) +from .async_base import AsyncBaseClient + + +class AsyncTemplateClient(AsyncBaseClient): + """Non-blocking template operations.""" + + async def get( + self, page: int | None = None, size: int | None = None + ) -> list[Template]: + """Return paginated list of templates.""" + params: dict[str, Any] | None = None + if page is not None or size is not None: + params = {"page": page or 0, "size": size or 10} + response = await self._make_request( + method="GET", + endpoint="/api/v1/public/template", + response_model=TemplateListResponse, + params=params, + ) + return response # type: ignore[return-value] + + async def create( + self, + name: str, + description: str | None = None, + tag_names: list[str] | None = None, + variables: list[dict[str, Any]] | None = None, + ) -> CreatedTemplate: + """Create a new template and return summary data.""" + payload: dict[str, Any] = {"name": name} + if description is not None: + payload["description"] = description + if tag_names is not None: + payload["tag_names"] = tag_names + if variables is not None: + payload["variables"] = variables + + response = await self._make_request( + method="POST", + endpoint="/api/v1/public/template", + request_model=CreateTemplateRequest, + response_model=CreateTemplateResponse, + data=payload, + ) + return response # type: ignore[return-value] + + async def update( + self, + template_id: str, + **updates: Any, + ) -> Template: + """Update a template's metadata fields.""" + response = await self._make_request( + method="PUT", + endpoint=f"/api/v1/public/template/{template_id}", + request_model=UpdateTemplateRequest, + response_model=UpdateTemplateResponse, + data=updates, + ) + return response # type: ignore[return-value] + + async def delete(self, template_id: str) -> bool: + """Delete template by ID.""" + await self._make_request( + method="DELETE", + endpoint=f"/api/v1/public/template/{template_id}", + expected_status=204, + ) + return True + + async def publish(self, template_id: str) -> Template: + """Publish draft template returning published entity.""" + response = await self._make_request( + method="PATCH", + endpoint=f"/api/v1/public/template/{template_id}/publish", + response_model=PublishTemplateResponse, + ) + return response # type: ignore[return-value] diff --git a/siren/clients/users_async.py b/siren/clients/users_async.py new file mode 100644 index 0000000..eef042c --- /dev/null +++ b/siren/clients/users_async.py @@ -0,0 +1,44 @@ +"""Asynchronous User client for Siren SDK.""" + +from typing import Any + +from ..models.base import DeleteResponse +from ..models.user import User, UserAPIResponse, UserRequest +from .async_base import AsyncBaseClient + + +class AsyncUserClient(AsyncBaseClient): + """Non-blocking user operations (add, update, delete).""" + + async def add(self, **user_data: Any) -> User: + """Create a user and return the resulting object.""" + response = await self._make_request( + method="POST", + endpoint="/api/v1/public/users", + request_model=UserRequest, + response_model=UserAPIResponse, + data=user_data, + ) + return response # type: ignore[return-value] + + async def update(self, unique_id: str, **user_data: Any) -> User: + """Update user identified by unique_id.""" + user_data["unique_id"] = unique_id + response = await self._make_request( + method="PUT", + endpoint=f"/api/v1/public/users/{unique_id}", + request_model=UserRequest, + response_model=UserAPIResponse, + data=user_data, + ) + return response # type: ignore[return-value] + + async def delete(self, unique_id: str) -> bool: + """Delete user and return True on success.""" + await self._make_request( + method="DELETE", + endpoint=f"/api/v1/public/users/{unique_id}", + response_model=DeleteResponse, + expected_status=204, + ) + return True diff --git a/siren/clients/workflows_async.py b/siren/clients/workflows_async.py new file mode 100644 index 0000000..972e59a --- /dev/null +++ b/siren/clients/workflows_async.py @@ -0,0 +1,92 @@ +"""Asynchronous Workflow client for Siren SDK.""" + +from typing import Any, Dict, List, Optional + +from ..models.workflows import ( + BulkWorkflowExecutionData, + ScheduleData, + ScheduleWorkflowRequest, + ScheduleWorkflowResponse, + TriggerBulkWorkflowRequest, + TriggerBulkWorkflowResponse, + TriggerWorkflowRequest, + TriggerWorkflowResponse, + WorkflowExecutionData, +) +from .async_base import AsyncBaseClient + + +class AsyncWorkflowClient(AsyncBaseClient): + """Non-blocking operations for triggering and scheduling workflows.""" + + async def trigger( + self, + workflow_name: str, + data: Optional[Dict[str, Any]] = None, + notify: Optional[Dict[str, Any]] = None, + ) -> WorkflowExecutionData: + """Trigger a workflow execution and return execution data.""" + response = await self._make_request( + method="POST", + endpoint="/api/v2/workflows/trigger", + request_model=TriggerWorkflowRequest, + response_model=TriggerWorkflowResponse, + data={ + "workflow_name": workflow_name, + "data": data, + "notify": notify, + }, + ) + return response # type: ignore[return-value] + + async def trigger_bulk( + self, + workflow_name: str, + notify: List[Dict[str, Any]], + data: Optional[Dict[str, Any]] = None, + ) -> BulkWorkflowExecutionData: + """Trigger workflow for multiple recipients in bulk.""" + response = await self._make_request( + method="POST", + endpoint="/api/v2/workflows/trigger/bulk", + request_model=TriggerBulkWorkflowRequest, + response_model=TriggerBulkWorkflowResponse, + data={ + "workflow_name": workflow_name, + "notify": notify, + "data": data, + }, + ) + return response # type: ignore[return-value] + + async def schedule( + self, + name: str, + schedule_time: str, + timezone_id: str, + start_date: str, + workflow_type: str, + workflow_id: str, + input_data: Dict[str, Any], + end_date: Optional[str] = None, + ) -> ScheduleData: + """Schedule a workflow as per provided recurrence params.""" + if workflow_type == "ONCE" and end_date is None: + end_date = "" + response = await self._make_request( + method="POST", + endpoint="/api/v1/public/schedules", + request_model=ScheduleWorkflowRequest, + response_model=ScheduleWorkflowResponse, + data={ + "name": name, + "schedule_time": schedule_time, + "timezone_id": timezone_id, + "start_date": start_date, + "workflow_type": workflow_type, + "workflow_id": workflow_id, + "input_data": input_data, + "end_date": end_date, + }, + ) + return response # type: ignore[return-value] diff --git a/siren/http/__init__.py b/siren/http/__init__.py new file mode 100644 index 0000000..90dd798 --- /dev/null +++ b/siren/http/__init__.py @@ -0,0 +1 @@ +"""HTTP transport sub-package for Siren SDK (sync + async).""" diff --git a/siren/models/messaging.py b/siren/models/messaging.py index 712cc29..0f67b33 100644 --- a/siren/models/messaging.py +++ b/siren/models/messaging.py @@ -50,7 +50,7 @@ class ReplyData(BaseModel): """Individual reply data.""" text: str - thread_ts: str = Field(alias="threadTs") + thread_ts: Optional[str] = Field(None, alias="threadTs") user: str ts: str diff --git a/tests/test_messaging_async.py b/tests/test_messaging_async.py new file mode 100644 index 0000000..6a8ff50 --- /dev/null +++ b/tests/test_messaging_async.py @@ -0,0 +1,88 @@ +"""Async tests for messaging client using respx.""" + +import httpx # type: ignore +import pytest +import respx # type: ignore + +from siren.async_client import AsyncSirenClient +from siren.models.messaging import ReplyData + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" +MESSAGE_ID = "msg_123" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_send_message_success(): + """send() returns message_id and correct payload.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + + expected_json = { + "data": {"notificationId": MESSAGE_ID}, + "error": None, + } + route = respx.post(f"{BASE_URL}/api/v1/public/send-messages").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + msg_id = await client.message.send( + template_name="ai_summary", + channel="EMAIL", + recipient_type="direct", + recipient_value="user@example.com", + ) + + assert route.called + assert msg_id == MESSAGE_ID + + await client.aclose() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_get_status_success(): + """get_status() retrieves delivered status.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + + expected_json = {"data": {"status": "DELIVERED"}, "error": None} + route = respx.get(f"{BASE_URL}/api/v1/public/message-status/{MESSAGE_ID}").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + status = await client.message.get_status(MESSAGE_ID) + + assert route.called + assert status == "DELIVERED" + + await client.aclose() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_get_replies_success(): + """get_replies() returns parsed ReplyData list.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + + expected_json = { + "data": [ + { + "text": "Thanks!", + "threadTs": "111", + "user": "U1", + "ts": "111.1", + } + ], + "error": None, + } + route = respx.get(f"{BASE_URL}/api/v1/public/get-reply/{MESSAGE_ID}").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + replies = await client.message.get_replies(MESSAGE_ID) + + assert route.called + assert isinstance(replies, list) + assert isinstance(replies[0], ReplyData) + + await client.aclose() diff --git a/tests/test_templates_async.py b/tests/test_templates_async.py new file mode 100644 index 0000000..9accc04 --- /dev/null +++ b/tests/test_templates_async.py @@ -0,0 +1,40 @@ +"""Async tests for template client.""" + +import httpx # type: ignore +import pytest +import respx # type: ignore + +from siren.async_client import AsyncSirenClient +from siren.models.templates import CreatedTemplate + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" +TEMPLATE_ID = "tpl_123" +VERSION_ID = "ver_1" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_create_template_success(): + """create() returns CreatedTemplate object with id.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + expected_json = { + "data": { + "templateId": TEMPLATE_ID, + "templateName": "Welcome_Email_Example", + "draftVersionId": "draft_1", + "channelTemplateList": [], + }, + "error": None, + } + route = respx.post(f"{BASE_URL}/api/v1/public/template").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + created = await client.template.create(name="Welcome_Email_Example") + + assert route.called + assert isinstance(created, CreatedTemplate) + assert created.template_id == TEMPLATE_ID + + await client.aclose() diff --git a/tests/test_users_async.py b/tests/test_users_async.py new file mode 100644 index 0000000..3c6033c --- /dev/null +++ b/tests/test_users_async.py @@ -0,0 +1,37 @@ +"""Async tests for user client.""" + +import httpx # type: ignore +import pytest +import respx # type: ignore + +from siren.async_client import AsyncSirenClient +from siren.models.user import User + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" +UNIQUE_ID = "user_123" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_add_user_success(): + """add() returns created User object.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + expected_json = { + "data": { + "id": "u1", + "uniqueId": UNIQUE_ID, + }, + "error": None, + } + route = respx.post(f"{BASE_URL}/api/v1/public/users").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + user = await client.user.add(unique_id=UNIQUE_ID) + + assert route.called + assert isinstance(user, User) + assert user.unique_id == UNIQUE_ID + + await client.aclose() diff --git a/tests/test_webhooks_async.py b/tests/test_webhooks_async.py new file mode 100644 index 0000000..1344e2b --- /dev/null +++ b/tests/test_webhooks_async.py @@ -0,0 +1,70 @@ +"""Asynchronous tests for Siren webhook client.""" + +import httpx # type: ignore +import pytest +import respx # type: ignore + +from siren.async_client import AsyncSirenClient +from siren.models.webhooks import WebhookConfig + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" +WEBHOOK_URL = "https://example.com/webhook" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_configure_notifications_success(): + """Async configure_notifications returns expected WebhookConfig.""" + async with AsyncSirenClient(api_key=API_KEY, env="dev") as client: + expected_json = { + "data": { + "id": "wh_123", + "webhookConfig": { + "url": WEBHOOK_URL, + "headers": [], + "verificationKey": "test_key_123", + }, + }, + "error": None, + } + route = respx.put(f"{BASE_URL}/api/v1/public/webhooks").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + response: WebhookConfig = await client.webhook.configure_notifications( + url=WEBHOOK_URL + ) + + assert route.called + assert isinstance(response, WebhookConfig) + assert response.url == WEBHOOK_URL + assert response.verification_key == "test_key_123" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_configure_inbound_success(): + """Async configure_inbound returns expected WebhookConfig.""" + async with AsyncSirenClient(api_key=API_KEY, env="dev") as client: + expected_json = { + "data": { + "id": "wh_456", + "inboundWebhookConfig": { + "url": WEBHOOK_URL, + "headers": [], + "verificationKey": "test_key_456", + }, + }, + "error": None, + } + route = respx.put(f"{BASE_URL}/api/v1/public/webhooks").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + response = await client.webhook.configure_inbound(url=WEBHOOK_URL) + + assert route.called + assert isinstance(response, WebhookConfig) + assert response.url == WEBHOOK_URL + assert response.verification_key == "test_key_456" diff --git a/tests/test_workflows_async.py b/tests/test_workflows_async.py new file mode 100644 index 0000000..1995bc6 --- /dev/null +++ b/tests/test_workflows_async.py @@ -0,0 +1,64 @@ +"""Async tests for workflow client.""" + +import httpx # type: ignore +import pytest +import respx # type: ignore + +from siren.async_client import AsyncSirenClient +from siren.models.workflows import BulkWorkflowExecutionData, WorkflowExecutionData + +API_KEY = "test_api_key" +BASE_URL = "https://api.dev.trysiren.io" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_trigger_workflow_success(): + """trigger() returns execution data.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + expected_json = { + "data": { + "requestId": "req1", + "workflowExecutionId": "exec1", + }, + "error": None, + } + route = respx.post(f"{BASE_URL}/api/v2/workflows/trigger").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + exec_data = await client.workflow.trigger(workflow_name="sampleWorkflow") + + assert route.called + assert isinstance(exec_data, WorkflowExecutionData) + assert exec_data.request_id == "req1" + + await client.aclose() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_trigger_bulk_workflow_success(): + """trigger_bulk() returns bulk execution data.""" + client = AsyncSirenClient(api_key=API_KEY, env="dev") + expected_json = { + "data": { + "requestId": "req_bulk", + "workflowExecutionIds": ["e1", "e2"], + }, + "error": None, + } + route = respx.post(f"{BASE_URL}/api/v2/workflows/trigger/bulk").mock( + return_value=httpx.Response(200, json=expected_json) + ) + + bulk_data = await client.workflow.trigger_bulk( + workflow_name="sampleWorkflow", + notify=[{"email": "u1"}], + ) + + assert route.called + assert isinstance(bulk_data, BulkWorkflowExecutionData) + assert bulk_data.request_id == "req_bulk" + + await client.aclose() From d0b3d9a94b264d527ed412acb80044b1ae7a6ff8 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Thu, 12 Jun 2025 18:55:11 +0530 Subject: [PATCH 34/42] doc: update Readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 1db4ab9..694bd47 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ This is the official Python SDK for the [Siren notification platform](https://do - [Basic Usage](#basic-usage) - [SDK Methods](#sdk-methods) - [Examples](#examples) -- [For Package Developers](#getting-started-for-package-developers) -- [Future Enhancements](#future-enhancements) +- [For Package Developers](#for-package-developers) ## Installation From 5bda33d23c927d649d6f5d704d6493b1496271e8 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Fri, 13 Jun 2025 10:38:51 +0530 Subject: [PATCH 35/42] refactor: read env, api_key from environment --- .cursor/rules/project_context.mdc | 8 ++++---- README.md | 13 +++++++++++-- examples/basic_usage.py | 26 -------------------------- examples/messaging.py | 8 ++------ examples/messaging_async.py | 5 ++--- examples/templates.py | 8 ++------ examples/templates_async.py | 5 ++--- examples/users.py | 8 ++------ examples/users_async.py | 5 ++--- examples/webhooks.py | 11 ++--------- examples/webhooks_async.py | 6 ++---- examples/workflows.py | 8 ++------ examples/workflows_async.py | 5 ++--- siren/async_client.py | 18 ++++++++++++++---- siren/client.py | 21 +++++++++++++++++---- 15 files changed, 66 insertions(+), 89 deletions(-) delete mode 100644 examples/basic_usage.py diff --git a/.cursor/rules/project_context.mdc b/.cursor/rules/project_context.mdc index 6153560..82e348a 100644 --- a/.cursor/rules/project_context.mdc +++ b/.cursor/rules/project_context.mdc @@ -7,7 +7,7 @@ alwaysApply: false ## Project Summary -Official Python SDK for the [Siren notification platform](https://docs.trysiren.io). Provides type-safe interface for managing templates, workflows, users, messaging, and webhooks. Built with Pydantic validation, structured error handling, and modular client-based architecture. +Official Python SDK for the [Siren notification platform](mdc:https:/docs.trysiren.io). Provides type-safe interface for managing templates, workflows, users, messaging, and webhooks. Built with Pydantic validation, structured error handling, and modular client-based architecture. ## Key Features / Functional Modules @@ -67,7 +67,7 @@ siren-ai/ - **Authentication**: Bearer token in `Authorization` header - **Status Handling**: Explicit `if status_code == 200` checks instead of `response.ok` - **API Versioning**: Templates/Users/Messaging/Webhooks use `/api/v1/public/`, Workflows use `/api/v2/` -- **Environment Support**: Production (`https://api.trysiren.io`) default, dev (`https://api.dev.trysiren.io`) via `env="dev"` or `SIREN_ENV=dev` +- **Environment Support**: Both `SirenClient` *and* `AsyncSirenClient` automatically read `SIREN_API_KEY` and optional `SIREN_ENV` on instantiation. Production (`https://api.trysiren.io`) is the default; switch to dev (`https://api.dev.trysiren.io`) by setting `SIREN_ENV=dev` or passing `env="dev"` explicitly. ## Tech Stack @@ -105,8 +105,8 @@ siren-ai/ ## Documentation / Examples **Example Script Guidelines**: -- Call `dotenv.load_dotenv()` first, then read `SIREN_API_KEY` (and optional `SIREN_ENV`). -- Instantiate the appropriate sync (`SirenClient`) or async (`AsyncSirenClient`) client with those env values. +- Call `dotenv.load_dotenv()` first so environment variables from a `.env` file are available. +- Instantiate the sync (`SirenClient()`) or async (`AsyncSirenClient()`) client **without arguments** – the constructor will pick up `SIREN_API_KEY` & `SIREN_ENV` automatically. - Wrap core SDK calls in minimal error handling: ```python try: diff --git a/README.md b/README.md index 694bd47..a975d4d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ pip install siren-ai ```python from siren import SirenClient -client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") + +# Uses SIREN_API_KEY and SIREN_ENV environment variables +client = SirenClient() message_id = client.message.send( template_name="welcome_email", @@ -34,11 +36,18 @@ message_id = client.message.send( print("Sent:", message_id) ``` +```python +# You can also do: +client = SirenClient(api_key="YOUR_SIREN_API_KEY") # default env is "prod" + +# Or: +client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") +``` ### Asynchronous ```python from siren import AsyncSirenClient -client = AsyncSirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") +client = AsyncSirenClient() message_id = await client.message.send( template_name="welcome_email", diff --git a/examples/basic_usage.py b/examples/basic_usage.py deleted file mode 100644 index ab70c11..0000000 --- a/examples/basic_usage.py +++ /dev/null @@ -1,26 +0,0 @@ -# examples/basic_usage.py -"""Basic usage examples for the Siren SDK.""" - -import os -import sys - -from dotenv import load_dotenv - -# Ensure the 'siren' package in the parent directory can be imported: -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from siren import SirenClient - -if __name__ == "__main__": - load_dotenv() - - api_key = os.getenv("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY not set.") - sys.exit(1) - - try: - client = SirenClient(api_key=api_key) - print("SirenClient initialized.") - except Exception as e: - print(f"Initialization Error: {e}") diff --git a/examples/messaging.py b/examples/messaging.py index 18aa85f..494f243 100644 --- a/examples/messaging.py +++ b/examples/messaging.py @@ -53,12 +53,8 @@ def get_replies_example(client: SirenClient, message_id: str) -> None: if __name__ == "__main__": - api_key = os.environ.get("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY environment variable not set.") - sys.exit(1) - - client = SirenClient(api_key=api_key) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = SirenClient() message_id = send_message_example(client) if message_id: diff --git a/examples/messaging_async.py b/examples/messaging_async.py index 6337464..a53a10e 100644 --- a/examples/messaging_async.py +++ b/examples/messaging_async.py @@ -1,7 +1,6 @@ """Asynchronous example: sending a message and fetching status & replies.""" import asyncio -import os from dotenv import load_dotenv @@ -12,9 +11,9 @@ async def main() -> None: """Send message then query its status and replies.""" load_dotenv() - api_key = os.getenv("SIREN_API_KEY") - client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = AsyncSirenClient() try: # Send message diff --git a/examples/templates.py b/examples/templates.py index 353c44a..9e237c8 100644 --- a/examples/templates.py +++ b/examples/templates.py @@ -110,12 +110,8 @@ def delete_template_example(client: SirenClient, template_id: str): if __name__ == "__main__": - api_key = os.environ.get("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY environment variable not set.") - sys.exit(1) - - client = SirenClient(api_key=api_key) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = SirenClient() get_templates_example(client) template_id = create_template_example(client) diff --git a/examples/templates_async.py b/examples/templates_async.py index 4a723ab..06a4f8f 100644 --- a/examples/templates_async.py +++ b/examples/templates_async.py @@ -1,7 +1,6 @@ """Asynchronous example for template & channel template operations.""" import asyncio -import os from dotenv import load_dotenv @@ -12,9 +11,9 @@ async def main() -> None: """Create and publish a template asynchronously.""" load_dotenv() - api_key = os.getenv("SIREN_API_KEY") - client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = AsyncSirenClient() try: # Get templates list diff --git a/examples/users.py b/examples/users.py index 1b92426..13d35b0 100644 --- a/examples/users.py +++ b/examples/users.py @@ -60,12 +60,8 @@ def delete_user_example(client: SirenClient, unique_id: str) -> None: if __name__ == "__main__": - api_key = os.environ.get("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY environment variable not set.") - sys.exit(1) - - client = SirenClient(api_key=api_key) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = SirenClient() unique_id = add_user_example(client) if unique_id: diff --git a/examples/users_async.py b/examples/users_async.py index 41077e9..0cb3257 100644 --- a/examples/users_async.py +++ b/examples/users_async.py @@ -1,7 +1,6 @@ """Asynchronous example: add, update, delete user.""" import asyncio -import os from dotenv import load_dotenv @@ -12,9 +11,9 @@ async def main() -> None: """Add, update, delete a user asynchronously.""" load_dotenv() - api_key = os.getenv("SIREN_API_KEY") - client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = AsyncSirenClient() try: # Add user diff --git a/examples/webhooks.py b/examples/webhooks.py index f1b6de4..0fffdb6 100644 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -1,8 +1,5 @@ """Examples for configuring webhooks using the Siren SDK.""" -import os -import sys - from dotenv import load_dotenv from siren import SirenClient @@ -39,12 +36,8 @@ def configure_inbound_message_webhook_example(client: SirenClient, webhook_url: if __name__ == "__main__": load_dotenv() - api_key = os.getenv("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY not found in environment variables or .env file.") - sys.exit(1) - - siren_client = SirenClient(api_key=api_key) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + siren_client = SirenClient() example_webhook_url = "https://siren-ai-test.example.com/siren123" diff --git a/examples/webhooks_async.py b/examples/webhooks_async.py index 2c6af4c..f8c04f5 100644 --- a/examples/webhooks_async.py +++ b/examples/webhooks_async.py @@ -1,7 +1,6 @@ """Asynchronous usage example: configuring webhooks with Siren SDK.""" import asyncio -import os from dotenv import load_dotenv @@ -13,9 +12,8 @@ async def main() -> None: """Run a simple webhook configuration flow.""" load_dotenv() - api_key = os.getenv("SIREN_API_KEY") - - client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = AsyncSirenClient() try: # Configure notifications webhook diff --git a/examples/workflows.py b/examples/workflows.py index a3efe6e..e0975ca 100644 --- a/examples/workflows.py +++ b/examples/workflows.py @@ -60,12 +60,8 @@ def schedule_workflow_example(client: SirenClient) -> None: if __name__ == "__main__": - api_key = os.environ.get("SIREN_API_KEY") - if not api_key: - print("Error: SIREN_API_KEY environment variable not set.") - sys.exit(1) - - client = SirenClient(api_key=api_key) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = SirenClient() # trigger_workflow_example(client) # trigger_bulk_workflow_example(client) diff --git a/examples/workflows_async.py b/examples/workflows_async.py index 94c6c74..87a179c 100644 --- a/examples/workflows_async.py +++ b/examples/workflows_async.py @@ -1,7 +1,6 @@ """Asynchronous example demonstrating workflow trigger APIs.""" import asyncio -import os from dotenv import load_dotenv @@ -12,9 +11,9 @@ async def main() -> None: """Trigger and bulk-trigger workflows asynchronously.""" load_dotenv() - api_key = os.getenv("SIREN_API_KEY") - client = AsyncSirenClient(api_key=api_key, env=os.getenv("SIREN_ENV", "dev")) + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = AsyncSirenClient() try: # Trigger single workflow execution diff --git a/siren/async_client.py b/siren/async_client.py index 2ba54f4..e455278 100644 --- a/siren/async_client.py +++ b/siren/async_client.py @@ -27,14 +27,24 @@ class AsyncSirenClient: # noqa: D101 "prod": "https://api.trysiren.io", } - def __init__(self, api_key: str, env: Literal["dev", "prod"] | None = None): + def __init__( + self, + *, + api_key: str | None = None, + env: Literal["dev", "prod"] | None = None, + ): """Create a new *asynchronous* Siren client. Args: - api_key: Bearer token obtained from the Siren dashboard. - env: Target environment – ``"dev"`` (default when ``SIREN_ENV=dev``) - or ``"prod"`` (default). + api_key: Siren API key. If ``None``, falls back to the ``SIREN_API_KEY`` env-var. + env: Deployment environment – ``"dev"`` or ``"prod"``. If ``None``, uses ``SIREN_ENV`` or defaults to ``"prod"``. """ + if api_key is None: + api_key = os.getenv("SIREN_API_KEY") + if api_key is None: + raise ValueError( + "The api_key must be set either by passing api_key to the client or by setting the SIREN_API_KEY environment variable" + ) self.api_key = api_key if env is None: diff --git a/siren/client.py b/siren/client.py index 7481c4e..e9a044a 100644 --- a/siren/client.py +++ b/siren/client.py @@ -20,14 +20,25 @@ class SirenClient: "prod": "https://api.trysiren.io", } - def __init__(self, api_key: str, env: Optional[Literal["dev", "prod"]] = None): + def __init__( + self, + *, + api_key: Optional[str] = None, + env: Optional[Literal["dev", "prod"]] = None, + ): """Initialize the SirenClient. Args: - api_key: The API key for authentication. - env: Environment to use ('dev' or 'prod'). - If not provided, defaults to 'prod' or uses SIREN_ENV environment variable. + api_key: The API key for authentication. If not provided, will be read from SIREN_API_KEY environment variable. + env: Environment to use ('dev' or 'prod'). If not provided, defaults to 'prod' or uses SIREN_ENV environment variable. """ + # Get API key from environment if not provided + if api_key is None: + api_key = os.getenv("SIREN_API_KEY") + if api_key is None: + raise ValueError( + "The api_key must be set either by passing api_key to the client or by setting the SIREN_API_KEY environment variable" + ) self.api_key = api_key # Determine environment and base URL @@ -41,6 +52,8 @@ def __init__(self, api_key: str, env: Optional[Literal["dev", "prod"]] = None): self.env = env self.base_url = self.API_URLS[env] + + # Initialize API clients self._template_client = TemplateClient( api_key=self.api_key, base_url=self.base_url ) From a78d740630c46645e05bbf8b1d0e2d20727a2d3a Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Fri, 13 Jun 2025 18:36:27 +0530 Subject: [PATCH 36/42] refactor: update to Pydantic V2 field_validator --- .cursor/rules/general.mdc | 2 +- README.md | 31 ++++-- coverage.xml | 204 ++++++++++++++++++++----------------- examples/messaging.py | 45 +++++--- siren/clients/messaging.py | 38 ++++--- siren/models/messaging.py | 17 +++- 6 files changed, 203 insertions(+), 134 deletions(-) diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 9c5a52f..1715217 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -1,7 +1,7 @@ --- description: globs: -alwaysApply: true +alwaysApply: false --- Development Approach: - Make small, focused changes incrementally diff --git a/README.md b/README.md index a975d4d..1701358 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,22 @@ from siren import SirenClient # Uses SIREN_API_KEY and SIREN_ENV environment variables client = SirenClient() +# Send a direct message without template message_id = client.message.send( - template_name="welcome_email", + recipient_type="direct", + recipient_value="alice@company.com", channel="EMAIL", + body="Your account has been successfully verified. You can now access all features." +) + +# Send a message using a template +message_id = client.message.send( recipient_type="direct", - recipient_value="user@example.com", - template_variables={"user_name": "John Doe"}, + recipient_value="U01UBCD06BB", + channel="SLACK", + template_name="welcome_template", + template_variables={"user_name": "John"}, ) -print("Sent:", message_id) ``` ```python @@ -50,13 +58,11 @@ from siren import AsyncSirenClient client = AsyncSirenClient() message_id = await client.message.send( - template_name="welcome_email", - channel="EMAIL", recipient_type="direct", - recipient_value="user@example.com", - template_variables={"user_name": "John Doe"}, + recipient_value="alice@company.com", + channel="EMAIL", + body="Your account has been successfully verified. You can now access all features." ) -print("Sent:", message_id) await client.aclose() ``` @@ -150,7 +156,8 @@ For testing the SDK, set these environment variables: ``` You are now ready to contribute to the `siren-ai` SDK! - Try `$ python examples/webhooks.py` + + Try `$ python examples/messaging_async.py` ### Code Style & Linting @@ -172,3 +179,7 @@ This will execute all tests defined in the `tests/` directory. * Create a feature branch for your changes. * Commit your changes (pre-commit hooks will run). * Push your branch and open a Pull Request against the `develop` repository branch. + + +## Changes planned +- Check how critical is .close() for async client, explore ways to avoid that. diff --git a/coverage.xml b/coverage.xml index 39930e1..1623aac 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /home/jithu/projects/siren-ai/siren - + @@ -17,7 +17,7 @@ - + @@ -32,51 +32,55 @@ - - - - - + + + + - - - + + + + - - - + - - - - + + - - - - - + + - + + - - - + - - + + + + + + + + + + + + + + - + @@ -90,37 +94,41 @@ - - - - + + + - - - - + + + + - + - - - + + - - - - + + - + - - + + - + + + + + + + + + @@ -151,7 +159,7 @@ - + @@ -320,33 +328,38 @@ - - - - - - + + + + + + - + - - - - + + + - + + + + - - - - - - + + + + + + + + + @@ -422,30 +435,30 @@ - - - - - - - + + + + + - - + + + - - - - - - - - + + + + + + + + + @@ -602,7 +615,7 @@ - + @@ -634,7 +647,7 @@ - + @@ -651,21 +664,28 @@ + - - + + + + + - - + + + + + diff --git a/examples/messaging.py b/examples/messaging.py index 494f243..42bb49e 100644 --- a/examples/messaging.py +++ b/examples/messaging.py @@ -1,24 +1,19 @@ -"""Example script demonstrating messaging methods in the Siren SDK.""" +"""Examples for using the messaging client.""" -import os -import sys +from dotenv import load_dotenv -from siren.client import SirenClient +from siren import SirenClient from siren.exceptions import SirenAPIError, SirenSDKError -# Allow running from examples directory -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -def send_message_example(client: SirenClient) -> str: - """Example of sending a message.""" +def send_direct_message_example(client: SirenClient) -> str: + """Example of sending a direct message to a Slack user.""" try: message_id = client.message.send( - template_name="sampleTemplate", - channel="SLACK", recipient_type="direct", recipient_value="U01UBCD06BB", - template_variables={"user_name": "John"}, + channel="SLACK", + body="Hello! This is a direct message without template.", ) print(f"Message sent: {message_id}") return message_id @@ -39,6 +34,24 @@ def get_message_status_example(client: SirenClient, message_id: str) -> None: print(f"SDK Error: {e.message}") +def send_template_message_example(client: SirenClient) -> str: + """Example of sending a message using a template.""" + try: + message_id = client.message.send( + recipient_type="direct", + recipient_value="U01UBCD06BB", + channel="SLACK", + template_name="sampleTemplate", + template_variables={"user_name": "Alan"}, + ) + print(f"Message sent: {message_id}") + return message_id + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") + + def get_replies_example(client: SirenClient, message_id: str) -> None: """Example of getting message replies.""" try: @@ -53,10 +66,16 @@ def get_replies_example(client: SirenClient, message_id: str) -> None: if __name__ == "__main__": + load_dotenv() # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) client = SirenClient() - message_id = send_message_example(client) + # Send direct message and check status + message_id = send_direct_message_example(client) if message_id: get_message_status_example(client, message_id) + + # Send template message and get replies + message_id = send_template_message_example(client) + if message_id: get_replies_example(client, message_id) diff --git a/siren/clients/messaging.py b/siren/clients/messaging.py index 47b960d..fdc698e 100644 --- a/siren/clients/messaging.py +++ b/siren/clients/messaging.py @@ -1,10 +1,11 @@ """Messaging client for the Siren SDK.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from ..models.messaging import ( MessageRepliesResponse, MessageStatusResponse, + Recipient, ReplyData, SendMessageRequest, SendMessageResponse, @@ -17,20 +18,23 @@ class MessageClient(BaseClient): def send( self, - template_name: str, - channel: str, - recipient_type: str, + recipient_type: Literal["user_id", "direct"], recipient_value: str, + channel: str, + *, + body: Optional[str] = None, + template_name: Optional[str] = None, template_variables: Optional[Dict[str, Any]] = None, ) -> str: - """Send a message using a specific template. + """Send a message either using a template or directly. Args: - template_name: The name of the template to use. - channel: The channel to send the message through (e.g., "SLACK", "EMAIL"). - recipient_type: The type of recipient (e.g., "direct"). - recipient_value: The identifier for the recipient (e.g., Slack user ID, email address). - template_variables: A dictionary of variables to populate the template. + recipient_type: The type of recipient ("user_id" or "direct") + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address) + channel: The channel to send the message through (e.g., "SLACK", "EMAIL") + body: Optional message body text (required if no template) + template_name: Optional template name (required if no body) + template_variables: Optional template variables for template-based messages Returns: The message ID of the sent message. @@ -38,14 +42,20 @@ def send( Raises: SirenAPIError: If the API returns an error response. SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + ValueError: If neither body nor template_name is provided """ + recipient = Recipient(type=recipient_type, value=recipient_value) payload = { - "template": {"name": template_name}, - "recipient": {"type": recipient_type, "value": recipient_value}, + "recipient": recipient.model_dump(), "channel": channel, } - if template_variables is not None: - payload["template_variables"] = template_variables + + if body is not None: + payload["body"] = body + elif template_name is not None: + payload["template"] = {"name": template_name} + if template_variables is not None: + payload["template_variables"] = template_variables response = self._make_request( method="POST", diff --git a/siren/models/messaging.py b/siren/models/messaging.py index 0f67b33..fea1ee8 100644 --- a/siren/models/messaging.py +++ b/siren/models/messaging.py @@ -1,8 +1,8 @@ """Messaging-related models for the Siren SDK.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from .base import BaseAPIResponse @@ -16,7 +16,7 @@ class TemplateInfo(BaseModel): class Recipient(BaseModel): """Recipient information for messaging.""" - type: str + type: Literal["user_id", "direct"] value: str @@ -26,13 +26,22 @@ class SendMessageRequest(BaseModel): # Fix: template_variables was silently becoming None during model creation model_config = ConfigDict(populate_by_name=True) - template: TemplateInfo recipient: Recipient channel: str + body: Optional[str] = None + template: Optional[TemplateInfo] = None template_variables: Optional[Dict[str, Any]] = Field( alias="templateVariables", default=None ) + @field_validator("body") + @classmethod + def validate_body(cls, v: Optional[str], values: Dict[str, Any]) -> Optional[str]: + """Validate that either body or template is provided.""" + if not v and not values.get("template"): + raise ValueError("Either body or template must be provided") + return v + class MessageData(BaseModel): """Message response data.""" From 321e8fe5f3d11ad85466f1be29d03918a57e4b72 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Mon, 16 Jun 2025 14:51:33 +0530 Subject: [PATCH 37/42] Update Readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1701358..c71196b 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ The Siren-AI Python SDK provides a clean, namespaced interface to interact with - **`client.channel_template.get()`** - Retrieves channel templates for a specific template version **Messaging** (`client.message.*`) -- **`client.message.send()`** - Sends a message using a template to a recipient via a chosen channel +- **`client.message.send()`** - Sends a message (with or without a template) to a recipient via a chosen channel - **`client.message.get_replies()`** - Retrieves replies for a specific message ID - **`client.message.get_status()`** - Retrieves the status of a specific message (SENT, DELIVERED, FAILED, etc.) From 609c66be5d3780c6ee00fe0b60b36b48b10b793f Mon Sep 17 00:00:00 2001 From: Ranjith <87976642+ranjith-keyvalue@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:42:09 +0530 Subject: [PATCH 38/42] feat: add support for sending messages using awesome templates * send awesome template and change in send message * provider code and name added * fix: change types and add example for awesome template * fix: test for send awesome templates * fix: update readme --------- Co-authored-by: aavani-kv <208427043+aavani-kv@users.noreply.github.com> --- README.md | 56 +++++++-- coverage.xml | 170 +++++++++++++++++-------- examples/awesome_template.py | 66 ++++++++++ examples/messaging.py | 11 +- examples/messaging_async.py | 6 +- siren/clients/messaging.py | 117 ++++++++++++++++- siren/clients/messaging_async.py | 57 ++++++++- siren/models/messaging.py | 82 +++++++++--- tests/test_messaging.py | 210 ++++++++++++++++++++++++++++++- tests/test_messaging_async.py | 1 - uv.lock | 135 +++++++++++++++++++- 11 files changed, 815 insertions(+), 96 deletions(-) create mode 100644 examples/awesome_template.py diff --git a/README.md b/README.md index c71196b..e222a2a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ client = SirenClient() # Send a direct message without template message_id = client.message.send( - recipient_type="direct", recipient_value="alice@company.com", channel="EMAIL", body="Your account has been successfully verified. You can now access all features." @@ -36,12 +35,37 @@ message_id = client.message.send( # Send a message using a template message_id = client.message.send( - recipient_type="direct", recipient_value="U01UBCD06BB", channel="SLACK", template_name="welcome_template", template_variables={"user_name": "John"}, ) + +# Send a message with specific provider +from siren.models.messaging import ProviderCode +message_id = client.message.send( + recipient_value="alice@company.com", + channel="EMAIL", + body="Your account has been successfully verified.", + provider_name="email-provider", + provider_code=ProviderCode.EMAIL_SENDGRID, +) + +# Send a message using awesome template +message_id = client.message.send_awesome_template( + recipient_value="U01UBCD06BB", + channel="SLACK", + template_identifier="awesome-templates/customer-support/escalation_required/official/casual.yaml", + template_variables={ + "ticket_id": "123456", + "customer_name": "John", + "issue_summary": "Payment processing issue", + "ticket_url": "https://support.company.com/ticket/123456", + "sender_name": "Support Team" + }, + provider_name="slack-provider", + provider_code=ProviderCode.SLACK, +) ``` ```python @@ -51,20 +75,29 @@ client = SirenClient(api_key="YOUR_SIREN_API_KEY") # default env is "prod" # Or: client = SirenClient(api_key="YOUR_SIREN_API_KEY", env="dev") ``` + ### Asynchronous ```python from siren import AsyncSirenClient -client = AsyncSirenClient() - -message_id = await client.message.send( - recipient_type="direct", - recipient_value="alice@company.com", - channel="EMAIL", - body="Your account has been successfully verified. You can now access all features." -) +# Using async context manager (recommended) +async with AsyncSirenClient() as client: + message_id = await client.message.send( + recipient_value="alice@company.com", + channel="EMAIL", + body="Your account has been successfully verified. You can now access all features." + ) -await client.aclose() +# Or manually managing the client +client = AsyncSirenClient() +try: + message_id = await client.message.send( + recipient_value="alice@company.com", + channel="EMAIL", + body="Your account has been successfully verified. You can now access all features." + ) +finally: + await client.aclose() ``` All synchronous methods have a 1-to-1 asynchronous equivalent—just `await` them on the async client. @@ -86,6 +119,7 @@ The Siren-AI Python SDK provides a clean, namespaced interface to interact with **Messaging** (`client.message.*`) - **`client.message.send()`** - Sends a message (with or without a template) to a recipient via a chosen channel +- **`client.message.send_awesome_template()`** - Sends a message using a template path/identifier - **`client.message.get_replies()`** - Retrieves replies for a specific message ID - **`client.message.get_status()`** - Retrieves the status of a specific message (SENT, DELIVERED, FAILED, etc.) diff --git a/coverage.xml b/coverage.xml index 1623aac..20f8d42 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,9 +1,9 @@ - - + + - /home/jithu/projects/siren-ai/siren + /home/aavanisudayan/kv/siren/siren-py-sdk/siren @@ -159,7 +159,7 @@ - + @@ -336,52 +336,83 @@ - + - - - - - - - - + + + + + - - + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - + + + + + + + + + - + + + + + + + + + + + + @@ -615,7 +646,7 @@ - + @@ -647,45 +678,84 @@ - + - - - - + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + - - - + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/awesome_template.py b/examples/awesome_template.py new file mode 100644 index 0000000..a8f1bd2 --- /dev/null +++ b/examples/awesome_template.py @@ -0,0 +1,66 @@ +"""Awesome template example: sending a message using awesome templayes and fetching status & replies.""" + +import asyncio + +from dotenv import load_dotenv + +from siren import SirenClient +from siren.exceptions import SirenAPIError, SirenSDKError +from siren.models.messaging import ProviderCode + +def main() -> None: + """Send message then query its status and replies.""" + load_dotenv() + + # Set environment variables: SIREN_API_KEY & SIREN_ENV (or pass as arguments) + client = SirenClient() + + try: + # Send message using awesome template -- slack + message_id = client.message.send_awesome_template( + recipient_value="U08FK1G6DGE", + channel="SLACK", + template_identifier="awesome-templates/customer-support/escalation_required/official/casual.yaml", + template_variables={ + "ticket_id": "123456", + "customer_name": "John", + "issue_summary": "Issue summary", + "ticket_url": "https://siren.ai", + "sender_name": "Siren" + }, + provider_name="slack-test-py-sdk", + provider_code=ProviderCode.SLACK, + ) + + # Send message using awesome template -- email + # message_id = client.message.send_awesome_template( + # recipient_value="kv@keyvalue.systems", + # channel="EMAIL", + # template_identifier="awesome-templates/customer-support/escalation_required/official/casual.yaml", + # template_variables={ + # "ticket_id": "123456", + # "customer_name": "John", + # "issue_summary": "Issue summary", + # "ticket_url": "https://siren.ai", + # "sender_name": "Siren" + # }, + # provider_name="py-sdk-test-mailgun", + # provider_code=ProviderCode.EMAIL_MAILGUN, + # ) + + print("Sent message id:", message_id) + + # Get message status + status = client.message.get_status(message_id) + print("Message status:", status) + + # Get message replies + replies = client.message.get_replies(message_id) + print("Message replies:", replies) + except SirenAPIError as e: + print(f"API Error: {e.error_code} - {e.api_message}") + except SirenSDKError as e: + print(f"SDK Error: {e.message}") + +if __name__ == "__main__": + main() diff --git a/examples/messaging.py b/examples/messaging.py index 42bb49e..565edbe 100644 --- a/examples/messaging.py +++ b/examples/messaging.py @@ -4,16 +4,18 @@ from siren import SirenClient from siren.exceptions import SirenAPIError, SirenSDKError +from siren.models.messaging import ProviderCode def send_direct_message_example(client: SirenClient) -> str: """Example of sending a direct message to a Slack user.""" try: message_id = client.message.send( - recipient_type="direct", - recipient_value="U01UBCD06BB", + recipient_value="U08FK1G6DGE", channel="SLACK", body="Hello! This is a direct message without template.", + provider_name="slack-test-py-sdk", # Optional, if not provided, the default provider will be used + provider_code=ProviderCode.SLACK, # Optional, if not provided, the default provider will be used ) print(f"Message sent: {message_id}") return message_id @@ -38,11 +40,12 @@ def send_template_message_example(client: SirenClient) -> str: """Example of sending a message using a template.""" try: message_id = client.message.send( - recipient_type="direct", - recipient_value="U01UBCD06BB", + recipient_value="U08FK1G6DGE", channel="SLACK", template_name="sampleTemplate", template_variables={"user_name": "Alan"}, + provider_name="slack-test-py-sdk", # Optional, if not provided, the default provider will be used + provider_code=ProviderCode.SLACK, # Optional, if not provided, the default provider will be used ) print(f"Message sent: {message_id}") return message_id diff --git a/examples/messaging_async.py b/examples/messaging_async.py index a53a10e..b62bf88 100644 --- a/examples/messaging_async.py +++ b/examples/messaging_async.py @@ -6,6 +6,7 @@ from siren.async_client import AsyncSirenClient from siren.exceptions import SirenAPIError, SirenSDKError +from siren.models.messaging import ProviderCode async def main() -> None: @@ -20,9 +21,10 @@ async def main() -> None: message_id = await client.message.send( template_name="sampleTemplate", channel="SLACK", - recipient_type="direct", - recipient_value="U01UBCD06BB", + recipient_value="U08FK1G6DGE", template_variables={"user_name": "John"}, + provider_name="slack-test-py-sdk", + provider_code=ProviderCode.SLACK, ) print("Sent message id:", message_id) diff --git a/siren/clients/messaging.py b/siren/clients/messaging.py index fdc698e..d9d6044 100644 --- a/siren/clients/messaging.py +++ b/siren/clients/messaging.py @@ -1,10 +1,11 @@ """Messaging client for the Siren SDK.""" -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, List, Optional from ..models.messaging import ( MessageRepliesResponse, MessageStatusResponse, + ProviderCode, Recipient, ReplyData, SendMessageRequest, @@ -18,23 +19,25 @@ class MessageClient(BaseClient): def send( self, - recipient_type: Literal["user_id", "direct"], recipient_value: str, channel: str, *, body: Optional[str] = None, template_name: Optional[str] = None, template_variables: Optional[Dict[str, Any]] = None, + provider_name: Optional[str] = None, + provider_code: Optional[ProviderCode] = None, ) -> str: """Send a message either using a template or directly. Args: - recipient_type: The type of recipient ("user_id" or "direct") recipient_value: The identifier for the recipient (e.g., Slack user ID, email address) channel: The channel to send the message through (e.g., "SLACK", "EMAIL") body: Optional message body text (required if no template) template_name: Optional template name (required if no body) template_variables: Optional template variables for template-based messages + provider_name: Optional provider name (must be provided with provider_code) + provider_code: Optional provider code from ProviderCode enum (must be provided with provider_name) Returns: The message ID of the sent message. @@ -44,7 +47,13 @@ def send( SirenSDKError: If there's an SDK-level issue (network, parsing, etc). ValueError: If neither body nor template_name is provided """ - recipient = Recipient(type=recipient_type, value=recipient_value) + # Validate that both provider arguments are provided together + if (provider_name is not None) != (provider_code is not None): + raise ValueError( + "Both provider_name and provider_code must be provided together" + ) + + recipient = self._create_recipient(channel, recipient_value) payload = { "recipient": recipient.model_dump(), "channel": channel, @@ -55,7 +64,13 @@ def send( elif template_name is not None: payload["template"] = {"name": template_name} if template_variables is not None: - payload["template_variables"] = template_variables + payload["templateVariables"] = template_variables + + if provider_name is not None and provider_code is not None: + payload["providerIntegration"] = { + "code": provider_code.value, + "name": provider_name, + } response = self._make_request( method="POST", @@ -66,6 +81,65 @@ def send( ) return response.message_id + def send_awesome_template( + self, + recipient_value: str, + channel: str, + template_identifier: str, + *, + template_variables: Optional[Dict[str, Any]] = None, + provider_name: Optional[str] = None, + provider_code: Optional[ProviderCode] = None, + ) -> str: + """Send a message using a template path. + + Args: + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address) + channel: The channel to send the message through (e.g., "SLACK", "EMAIL") + template_identifier: The template identifier path (e.g., "awesome-templates/support/escalation/official/friendly") + template_variables: Optional template variables for template-based messages + provider_name: Optional provider name (must be provided with provider_code) + provider_code: Optional provider code from ProviderCode enum (must be provided with provider_name) + + Returns: + The message ID of the sent message. + + Raises: + SirenAPIError: If the API returns an error response. + SirenSDKError: If there's an SDK-level issue (network, parsing, etc). + ValueError: If provider_name and provider_code are not both provided together + """ + # Validate that both provider arguments are provided together + if (provider_name is not None) != (provider_code is not None): + raise ValueError( + "Both provider_name and provider_code must be provided together" + ) + + recipient = self._create_recipient(channel, recipient_value) + payload = { + "channel": channel, + "templateIdentifier": template_identifier, + "recipient": recipient.model_dump(exclude_none=True), + } + + if template_variables is not None: + payload["templateVariables"] = template_variables + + if provider_name is not None and provider_code is not None: + payload["providerIntegration"] = { + "code": provider_code.value, + "name": provider_name, + } + + response = self._make_request( + method="POST", + endpoint="/api/v1/public/send-awesome-messages", + request_model=SendMessageRequest, + response_model=SendMessageResponse, + data=payload, + ) + return response.message_id + def get_status(self, message_id: str) -> str: """Retrieve the status of a specific message. @@ -105,3 +179,36 @@ def get_replies(self, message_id: str) -> List[ReplyData]: response_model=MessageRepliesResponse, ) return response + + def _create_recipient(self, channel: str, recipient_value: str) -> Recipient: + """Create a Recipient object based on the channel and recipient value. + + Args: + channel: The channel to send the message through (e.g., "SLACK", "EMAIL") + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address) + + Returns: + A Recipient object configured for the specified channel + + Raises: + ValueError: If the channel is not supported + """ + channel_to_recipient_key = { + "EMAIL": "email", + "SMS": "sms", + "WHATSAPP": "whatsapp", + "SLACK": "slack", + "TEAMS": "teams", + "DISCORD": "discord", + "LINE": "line", + "IN_APP": "inApp", + "PUSH": "pushToken", + } + + recipient_key = channel_to_recipient_key.get(channel.upper()) + if recipient_key is None: + raise ValueError(f"Unsupported channel: {channel}") + + # Create recipient with only the relevant field + recipient_data = {recipient_key: recipient_value} + return Recipient(**recipient_data) diff --git a/siren/clients/messaging_async.py b/siren/clients/messaging_async.py index 0fe820f..d647975 100644 --- a/siren/clients/messaging_async.py +++ b/siren/clients/messaging_async.py @@ -7,6 +7,7 @@ from ..models.messaging import ( MessageRepliesResponse, MessageStatusResponse, + Recipient, ReplyData, SendMessageRequest, SendMessageResponse, @@ -21,18 +22,35 @@ async def send( self, template_name: str, channel: str, - recipient_type: str, recipient_value: str, template_variables: dict[str, Any] | None = None, + provider_name: str | None = None, + provider_code: str | None = None, ) -> str: - """Send a message and return the notification ID.""" + """Send a message and return the notification ID. + + Args: + template_name: The name of the template to use. + channel: The channel to send the message to. + recipient_value: The value of the recipient. + template_variables: The variables to use in the template. + provider_name: The name of the provider to use. + provider_code: The code of the provider to use. + """ + recipient = self._create_recipient(channel, recipient_value) + payload: dict[str, Any] = { "template": {"name": template_name}, - "recipient": {"type": recipient_type, "value": recipient_value}, + "recipient": recipient.model_dump(), "channel": channel, } if template_variables is not None: - payload["template_variables"] = template_variables + payload["templateVariables"] = template_variables + if provider_name is not None and provider_code is not None: + payload["providerIntegration"] = { + "name": provider_name, + "code": provider_code.value, + } response = await self._make_request( method="POST", @@ -60,3 +78,34 @@ async def get_replies(self, message_id: str) -> list[ReplyData]: response_model=MessageRepliesResponse, ) return response # type: ignore[return-value] + + def _create_recipient(self, channel: str, recipient_value: str) -> Recipient: + """Create a Recipient object based on the channel and recipient value. + + Args: + channel: The channel to send the message through (e.g., "SLACK", "EMAIL") + recipient_value: The identifier for the recipient (e.g., Slack user ID, email address) + + Returns: + A Recipient object configured for the specified channel + + Raises: + ValueError: If the channel is not supported + """ + channel_to_recipient_key = { + "EMAIL": "email", + "SMS": "sms", + "WHATSAPP": "whatsapp", + "SLACK": "slack", + "TEAMS": "teams", + "DISCORD": "discord", + "LINE": "line", + "IN_APP": "inApp", + "PUSH": "pushToken", + } + + recipient_key = channel_to_recipient_key.get(channel.upper()) + if recipient_key is None: + raise ValueError(f"Unsupported channel: {channel}") + + return Recipient(**{recipient_key: recipient_value}) \ No newline at end of file diff --git a/siren/models/messaging.py b/siren/models/messaging.py index fea1ee8..1917f4d 100644 --- a/siren/models/messaging.py +++ b/siren/models/messaging.py @@ -1,12 +1,43 @@ """Messaging-related models for the Siren SDK.""" -from typing import Any, Dict, List, Literal, Optional +from enum import Enum +from typing import Any, Dict, List, Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from .base import BaseAPIResponse +class ProviderCode(str, Enum): + """Available provider codes for messaging.""" + + EMAIL_SENDGRID = "EMAIL_SENDGRID" + SMS_MSG91 = "SMS_MSG91" + PUSH_FCM = "PUSH_FCM" + WHATSAPP_META = "WHATSAPP_META" + WHATSAPP_WATI = "WHATSAPP_WATI" + IN_APP_SIREN = "IN_APP_SIREN" + SMS_TWILIO = "SMS_TWILIO" + SMS_KALEYRA_IO = "SMS_KALEYRA_IO" + SMS_PLIVO = "SMS_PLIVO" + EMAIL_MAILCHIMP = "EMAIL_MAILCHIMP" + EMAIL_GMAIL = "EMAIL_GMAIL" + EMAIL_POSTMARK = "EMAIL_POSTMARK" + EMAIL_OUTLOOK = "EMAIL_OUTLOOK" + EMAIL_SIREN_SAMPLE = "EMAIL_SIREN_SAMPLE" + SMS_MESSAGEBIRD = "SMS_MESSAGEBIRD" + PUSH_ONESIGNAL = "PUSH_ONESIGNAL" + EMAIL_MAILGUN = "EMAIL_MAILGUN" + EMAIL_SES = "EMAIL_SES" + SLACK = "SLACK" + WHATSAPP_TWILIO = "WHATSAPP_TWILIO" + TEAMS = "TEAMS" + WHATSAPP_GUPSHUP = "WHATSAPP_GUPSHUP" + DISCORD = "DISCORD" + WHATSAPP_MSG91 = "WHATSAPP_MSG91" + LINE = "LINE" + + class TemplateInfo(BaseModel): """Template information for messaging.""" @@ -15,9 +46,16 @@ class TemplateInfo(BaseModel): class Recipient(BaseModel): """Recipient information for messaging.""" + # type: str = "direct" # Default to "direct", can be "user_id" or other values + slack: str | None = None + email: str | None = None - type: Literal["user_id", "direct"] - value: str + +class ProviderIntegration(BaseModel): + """Provider integration information.""" + + name: str + code: str class SendMessageRequest(BaseModel): @@ -26,22 +64,38 @@ class SendMessageRequest(BaseModel): # Fix: template_variables was silently becoming None during model creation model_config = ConfigDict(populate_by_name=True) - recipient: Recipient channel: str body: Optional[str] = None template: Optional[TemplateInfo] = None template_variables: Optional[Dict[str, Any]] = Field( alias="templateVariables", default=None ) - - @field_validator("body") - @classmethod - def validate_body(cls, v: Optional[str], values: Dict[str, Any]) -> Optional[str]: - """Validate that either body or template is provided.""" - if not v and not values.get("template"): - raise ValueError("Either body or template must be provided") - return v - + recipient: Recipient + template_identifier: Optional[str] = Field(alias="templateIdentifier", default=None) + template_path: Optional[str] = Field(alias="templatePath", default=None) + provider_integration: Optional[ProviderIntegration] = Field(alias="providerIntegration", default=None) + + @model_validator(mode="after") + def validate_message_content(self) -> "SendMessageRequest": + """Validate that either body, template, or template_identifier is provided.""" + if not self.body and not self.template and not self.template_identifier and not self.template_path: + raise ValueError( + "Either body, template,template_path or template_identifier must be provided" + ) + return self + + @model_validator(mode="after") + def validate_provider_fields(self) -> "SendMessageRequest": + """Validate that both provider_name and provider_code are provided together.""" + has_provider_name = self.provider_integration is not None and self.provider_integration.name is not None + has_provider_code = self.provider_integration is not None and self.provider_integration.code is not None + + if has_provider_name != has_provider_code: + raise ValueError( + "Both provider_name and provider_code must be provided together" + ) + + return self class MessageData(BaseModel): """Message response data.""" diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 23f1ece..8be5731 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -6,6 +6,7 @@ from siren.clients.messaging import MessageClient from siren.exceptions import SirenAPIError, SirenSDKError +from siren.models.messaging import ProviderCode API_KEY = "test_api_key" BASE_URL = "https://api.dev.trysiren.io" @@ -34,7 +35,6 @@ def test_send_message_success(self, mock_request): result = self.client.send( template_name="test_template", channel="SLACK", - recipient_type="direct", recipient_value="U123ABC", template_variables={"name": "John"}, ) @@ -126,7 +126,6 @@ def test_api_error_handling(self, mock_request): self.client.send( template_name="nonexistent", channel="SLACK", - recipient_type="direct", recipient_value="U123", ) @@ -149,7 +148,6 @@ def test_send_message_without_template_variables(self, mock_request): result = self.client.send( template_name="simple_template", channel="EMAIL", - recipient_type="direct", recipient_value="test@example.com", ) @@ -187,8 +185,212 @@ def test_network_error_handling(self, mock_request): self.client.send( template_name="test", channel="SLACK", - recipient_type="direct", recipient_value="U123", ) assert "Connection timeout" in str(exc_info.value) + + @patch("siren.clients.base.requests.request") + def test_send_awesome_template_success(self, mock_request): + """Test successful awesome template sending.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"notificationId": "awesome_msg_123"}, + "error": None, + } + mock_request.return_value = mock_response + + # Call the method + result = self.client.send_awesome_template( + recipient_value="U123ABC", + channel="SLACK", + template_identifier="awesome-templates/customer-support/escalation_required/official/casual.yaml", + template_variables={ + "ticket_id": "TICKET-123", + "customer_name": "John Doe", + "issue_summary": "Payment failed", + "ticket_url": "https://support.example.com/tickets/TICKET-123", + "sender_name": "Support Team" + }, + provider_name="slack-test-provider", + provider_code=ProviderCode.SLACK, + ) + + # Verify result + assert result == "awesome_msg_123" + + # Verify request was made correctly + mock_request.assert_called_once() + call_args = mock_request.call_args + + # Check URL and method + assert call_args[1]["method"] == "POST" + assert call_args[1]["url"] == f"{BASE_URL}/api/v1/public/send-awesome-messages" + + # Check payload structure + payload = call_args[1]["json"] + assert payload["channel"] == "SLACK" + assert payload["templateIdentifier"] == "awesome-templates/customer-support/escalation_required/official/casual.yaml" + assert payload["recipient"]["slack"] == "U123ABC" + assert payload["templateVariables"]["ticket_id"] == "TICKET-123" + assert payload["templateVariables"]["customer_name"] == "John Doe" + assert payload["providerIntegration"]["name"] == "slack-test-provider" + assert payload["providerIntegration"]["code"] == "SLACK" + + @patch("siren.clients.base.requests.request") + def test_send_awesome_template_without_provider(self, mock_request): + """Test awesome template sending without provider information.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"notificationId": "awesome_msg_456"}, + "error": None, + } + mock_request.return_value = mock_response + + # Call the method without provider info + result = self.client.send_awesome_template( + recipient_value="test@example.com", + channel="EMAIL", + template_identifier="awesome-templates/welcome/email/official.yaml", + template_variables={"user_name": "Alice"}, + ) + + # Verify result + assert result == "awesome_msg_456" + + # Verify payload doesn't include providerIntegration + payload = mock_request.call_args[1]["json"] + assert "providerIntegration" not in payload + + @patch("siren.clients.base.requests.request") + def test_send_awesome_template_without_template_variables(self, mock_request): + """Test awesome template sending without template variables.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"notificationId": "awesome_msg_789"}, + "error": None, + } + mock_request.return_value = mock_response + + # Call the method without template variables + result = self.client.send_awesome_template( + recipient_value="U456DEF", + channel="SLACK", + template_identifier="awesome-templates/simple/notification.yaml", + provider_name="slack-provider", + provider_code=ProviderCode.SLACK, + ) + + # Verify result + assert result == "awesome_msg_789" + + # Verify payload doesn't include templateVariables + payload = mock_request.call_args[1]["json"] + assert "templateVariables" not in payload + + @patch("siren.clients.base.requests.request") + def test_send_awesome_template_api_error(self, mock_request): + """Test awesome template API error handling.""" + # Mock API error response + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "data": None, + "error": { + "errorCode": "VALIDATION_EXCEPTION", + "message": "Validation failed: Should provide either provider Id or provider name and code." + }, + } + mock_request.return_value = mock_response + + # Should raise SirenAPIError + with pytest.raises(SirenAPIError) as exc_info: + self.client.send_awesome_template( + recipient_value="U123", + channel="SLACK", + template_identifier="awesome-templates/test.yaml", + template_variables={"test": "value"}, + ) + + assert exc_info.value.error_code == "VALIDATION_EXCEPTION" + assert "Validation failed" in exc_info.value.api_message + + def test_send_awesome_template_invalid_channel(self): + """Test awesome template with invalid channel.""" + # Should raise ValueError for unsupported channel + with pytest.raises(ValueError) as exc_info: + self.client.send_awesome_template( + recipient_value="test@example.com", + channel="INVALID_CHANNEL", + template_identifier="awesome-templates/test.yaml", + ) + + assert "Unsupported channel" in str(exc_info.value) + + def test_send_awesome_template_provider_validation(self): + """Test awesome template provider validation.""" + # Should raise ValueError when only one provider field is provided + with pytest.raises(ValueError) as exc_info: + self.client.send_awesome_template( + recipient_value="U123", + channel="SLACK", + template_identifier="awesome-templates/test.yaml", + provider_name="test-provider", + # Missing provider_code + ) + + assert "Both provider_name and provider_code must be provided together" in str(exc_info.value) + + @patch("siren.clients.base.requests.request") + def test_send_awesome_template_different_channels(self, mock_request): + """Test awesome template with different channels.""" + # Mock successful API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"notificationId": "awesome_msg_channel_test"}, + "error": None, + } + mock_request.return_value = mock_response + + # Test EMAIL channel + result = self.client.send_awesome_template( + recipient_value="test@example.com", + channel="EMAIL", + template_identifier="awesome-templates/email/welcome.yaml", + template_variables={"user_name": "Bob"}, + provider_name="email-provider", + provider_code=ProviderCode.EMAIL_SENDGRID, + ) + + assert result == "awesome_msg_channel_test" + + # Verify recipient structure for EMAIL + payload = mock_request.call_args[1]["json"] + assert payload["recipient"]["email"] == "test@example.com" + assert "slack" not in payload["recipient"] + + @patch("siren.clients.base.requests.request") + def test_send_awesome_template_network_error(self, mock_request): + """Test awesome template network error handling.""" + # Mock network error + mock_request.side_effect = Exception("Network connection failed") + + # Should raise SirenSDKError + with pytest.raises(SirenSDKError) as exc_info: + self.client.send_awesome_template( + recipient_value="U123", + channel="SLACK", + template_identifier="awesome-templates/test.yaml", + template_variables={"test": "value"}, + provider_name="test-provider", + provider_code=ProviderCode.SLACK, + ) + + assert "Network connection failed" in str(exc_info.value) diff --git a/tests/test_messaging_async.py b/tests/test_messaging_async.py index 6a8ff50..f20e994 100644 --- a/tests/test_messaging_async.py +++ b/tests/test_messaging_async.py @@ -29,7 +29,6 @@ async def test_async_send_message_success(): msg_id = await client.message.send( template_name="ai_summary", channel="EMAIL", - recipient_type="direct", recipient_value="user@example.com", ) diff --git a/uv.lock b/uv.lock index 0ebb4fb..4942912 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "idna", marker = "python_full_version < '3.9'" }, + { name = "sniffio", marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "idna", marker = "python_full_version >= '3.9'" }, + { name = "sniffio", marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -526,6 +562,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "anyio", version = "4.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "id" version = "1.5.0" @@ -1244,6 +1318,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "pytest", version = "8.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, +] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -1456,6 +1561,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + [[package]] name = "rfc3986" version = "2.0.0" @@ -1520,9 +1637,10 @@ wheels = [ [[package]] name = "siren-ai" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, extra = ["email"], marker = "python_full_version < '3.9'" }, { name = "pydantic", version = "2.11.5", source = { registry = "https://pypi.org/simple" }, extra = ["email"], marker = "python_full_version >= '3.9'" }, { name = "requests" }, @@ -1536,12 +1654,15 @@ dev = [ { name = "pyright" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-asyncio", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-cov", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-mock" }, { name = "python-dotenv", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "python-dotenv", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "requests-mock" }, + { name = "respx" }, { name = "ruff" }, { name = "twine" }, { name = "uv" }, @@ -1550,21 +1671,33 @@ dev = [ [package.metadata] requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=0.10.0" }, + { name = "httpx", specifier = ">=0.26.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pydantic", extras = ["email"], specifier = ">=2.0,<3.0" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.20" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "python-dotenv", marker = "extra == 'dev'" }, { name = "requests", specifier = ">=2.25.0" }, { name = "requests-mock", marker = "extra == 'dev'" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0,<0.23" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=3.8.0" }, { name = "uv", marker = "extra == 'dev'" }, ] provides-extras = ["dev"] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.2.1" From 5ede557ac6275bc5eb1ac64ee0026456b02d2276 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue <85345436+jithu-keyvalue@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:47:09 +0530 Subject: [PATCH 39/42] Release 0.1.0 (#5) --- .github/workflows/publish.yml | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 5 +---- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2eb23bf --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*.*.*" # e.g. v1.2.3 + +jobs: + build-and-upload: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" # choose what you like + + - name: Install build tools + run: python -m pip install --upgrade build twine + + - name: Build wheel & sdist + run: python -m build # outputs to ./dist + + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload dist/* --non-interactive diff --git a/pyproject.toml b/pyproject.toml index 1b00bf9..c286dd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "siren-ai" -version = "0.2.0" -authors = [ - { name = "Jithu", email = "jithu@keyvalue.systems" }, -] +version = "0.1.0" description = "Python SDK for the Siren Notification Platform." readme = "README.md" requires-python = ">=3.8" From 68ee9ffc003cd9481c2a2cb30a841b04341e5e37 Mon Sep 17 00:00:00 2001 From: jithu-keyvalue <85345436+jithu-keyvalue@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:19:26 +0530 Subject: [PATCH 40/42] chore: support manual package publish also (#6) --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2eb23bf..36f8e39 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,7 @@ name: Publish to PyPI on: + workflow_dispatch: push: tags: - "v*.*.*" # e.g. v1.2.3 From 4075f790b076e8df59ba916148f82f50b3bd594b Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 25 Jun 2025 16:55:18 +0530 Subject: [PATCH 41/42] chore: update repo home page --- pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c286dd3..12f6c98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "siren-ai" -version = "0.1.0" +version = "0.1.1" description = "Python SDK for the Siren Notification Platform." readme = "README.md" requires-python = ">=3.8" @@ -34,10 +34,10 @@ dependencies = [ ] [project.urls] -"Homepage" = "https://github.com/jithu-keyvalue/siren-ai" -"Documentation" = "https://github.com/jithu-keyvalue/siren-ai#readme" -"Repository" = "https://github.com/jithu-keyvalue/siren-ai" -"Bug Tracker" = "https://github.com/jithu-keyvalue/siren-ai/issues" +"Homepage" = "https://github.com/KeyValueSoftwareSystems/siren-py-sdk" +"Documentation" = "https://github.com/KeyValueSoftwareSystems/siren-py-sdk#readme" +"Repository" = "https://github.com/KeyValueSoftwareSystems/siren-py-sdk" +"Bug Tracker" = "https://github.com/KeyValueSoftwareSystems/siren-py-sdk/issues" [project.optional-dependencies] dev = [ From 69e85b7fa242ad2af582752cba26e829ec92a9db Mon Sep 17 00:00:00 2001 From: jithu-keyvalue Date: Wed, 25 Jun 2025 18:03:28 +0530 Subject: [PATCH 42/42] doc: update Readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e222a2a..a30dd2a 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,8 @@ For testing the SDK, set these environment variables: 1. **Clone the repository:** ```bash - git clone https://github.com/jithu-keyvalue/siren-ai.git - cd siren-ai + git clone https://github.com/KeyValueSoftwareSystems/siren-py-sdk.git + cd siren-py-sdk ``` 2. **Create a virtual environment using `uv`:**