Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ client = DevoClient(api_key="your-api-key")
```python
client = DevoClient(
api_key="your-api-key",
base_url="https://api.devo.com", # Optional: custom base URL
timeout=30.0, # Optional: request timeout
)
```
Expand Down
22 changes: 18 additions & 4 deletions src/devo_global_comms_python/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class DevoClient:
def __init__(
self,
api_key: str,
base_url: Optional[str] = None,
sandbox_api_key: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT,
max_retries: int = 3,
session: Optional[requests.Session] = None,
Expand All @@ -65,7 +65,7 @@ def __init__(

Args:
api_key: API key for authentication
base_url: Base URL for the API (defaults to production)
sandbox_api_key: Optional sandbox API key for testing environments
timeout: Request timeout in seconds
max_retries: Maximum number of retries for failed requests
session: Custom requests session (optional)
Expand All @@ -76,7 +76,9 @@ def __init__(
if not api_key or not api_key.strip():
raise DevoMissingAPIKeyException()

self.base_url = base_url or self.DEFAULT_BASE_URL
self.api_key = api_key.strip()
self.sandbox_api_key = sandbox_api_key.strip() if sandbox_api_key else None
self.base_url = self.DEFAULT_BASE_URL
self.timeout = timeout

# Set up authentication
Expand Down Expand Up @@ -121,6 +123,7 @@ def request(
data: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
sandbox: bool = False,
) -> requests.Response:
"""
Make an authenticated request to the API.
Expand All @@ -132,6 +135,7 @@ def request(
data: Form data
json: JSON data
headers: Additional headers
sandbox: Use sandbox API key for this request (default: False)

Returns:
requests.Response: The API response
Expand All @@ -142,6 +146,10 @@ def request(
"""
url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"

# Validate sandbox usage
if sandbox and not self.sandbox_api_key:
raise DevoException("Sandbox API key required when sandbox=True")

# Prepare headers
request_headers = {
"User-Agent": f"devo-python-sdk/{__version__}",
Expand All @@ -151,7 +159,13 @@ def request(
request_headers.update(headers)

# Add authentication headers
auth_headers = self.auth.get_headers()
if sandbox and self.sandbox_api_key:
# Use sandbox API key for this request
sandbox_auth = APIKeyAuth(self.sandbox_api_key)
auth_headers = sandbox_auth.get_headers()
else:
# Use regular API key
auth_headers = self.auth.get_headers()
request_headers.update(auth_headers)

try:
Expand Down
2 changes: 2 additions & 0 deletions src/devo_global_comms_python/resources/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def send_email(
body: str,
sender: str,
recipient: str,
sandbox: bool = False,
) -> "EmailSendResponse":
"""
Send an email using the exact API specification.
Expand All @@ -36,6 +37,7 @@ def send_email(
body: Email body content
sender: Sender email address
recipient: Recipient email address
sandbox: Use sandbox environment for testing (default: False)

Returns:
EmailSendResponse: The email send response
Expand Down
2 changes: 1 addition & 1 deletion src/devo_global_comms_python/resources/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class MessagesResource(BaseResource):
across any channel (SMS, Email, WhatsApp, RCS).
"""

def send(self, data: "SendMessageDto") -> "SendMessageSerializer":
def send(self, data: "SendMessageDto", sandbox: bool = False) -> "SendMessageSerializer":
"""
Send a message through any channel (omni-channel endpoint).

Expand Down
20 changes: 16 additions & 4 deletions src/devo_global_comms_python/resources/sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def send_sms(
message: str,
sender: str,
hirvalidation: bool = True,
sandbox: bool = False,
) -> "SMSQuickSendResponse":
"""
Send an SMS message using the quick-send API.
Expand All @@ -67,6 +68,7 @@ def send_sms(
message: The SMS message content
sender: The sender phone number or sender ID
hirvalidation: Enable HIR validation (default: True)
sandbox: Use sandbox environment for testing (default: False)

Returns:
SMSQuickSendResponse: The sent message details including ID and status
Expand Down Expand Up @@ -102,7 +104,7 @@ def send_sms(
)

# Send request to the exact API endpoint
response = self.client.post("user-api/sms/quick-send", json=request_data.dict())
response = self.client.post("user-api/sms/quick-send", json=request_data.dict(), sandbox=sandbox)

# Parse response according to API spec
from ..models.sms import SMSQuickSendResponse
Expand All @@ -112,10 +114,13 @@ def send_sms(

return result

def get_senders(self) -> "SendersListResponse":
def get_senders(self, sandbox: bool = False) -> "SendersListResponse":
"""
Retrieve the list of available senders for the account.

Args:
sandbox: Use sandbox environment for testing (default: False)

Returns:
SendersListResponse: List of available senders with their details

Expand All @@ -131,7 +136,7 @@ def get_senders(self) -> "SendersListResponse":
logger.info("Fetching available senders")

# Send request to the exact API endpoint
response = self.client.get("user-api/me/senders")
response = self.client.get("user-api/me/senders", sandbox=sandbox)

# Parse response according to API spec
from ..models.sms import SendersListResponse
Expand All @@ -151,6 +156,7 @@ def buy_number(
is_longcode: bool = True,
agreement_last_sent_date: Optional[datetime] = None,
is_automated_enabled: bool = True,
sandbox: bool = False,
) -> "NumberPurchaseResponse":
"""
Purchase a phone number.
Expand All @@ -164,6 +170,7 @@ def buy_number(
is_longcode: Whether this is a long code number (default: True)
agreement_last_sent_date: Last date agreement was sent (optional)
is_automated_enabled: Whether automated messages are enabled (default: True)
sandbox: Use sandbox environment for testing (default: False)

Returns:
NumberPurchaseResponse: Details of the purchased number including features
Expand Down Expand Up @@ -227,6 +234,7 @@ def get_available_numbers(
type: Optional[str] = None,
prefix: Optional[str] = None,
region: str = "US",
sandbox: bool = False,
) -> "AvailableNumbersResponse":
"""
Get available phone numbers for purchase.
Expand All @@ -238,6 +246,7 @@ def get_available_numbers(
type: Filter by type (optional)
prefix: Filter by prefix (optional)
region: Filter by region (Country ISO Code), default: "US"
sandbox: Use sandbox environment for testing (default: False)

Returns:
AvailableNumbersResponse: List of available numbers with their features
Expand Down Expand Up @@ -292,14 +301,17 @@ def get_available_numbers(
return result

# Legacy methods for backward compatibility
def send(self, to: str, body: str, from_: Optional[str] = None, **kwargs) -> "SMSQuickSendResponse":
def send(
self, to: str, body: str, from_: Optional[str] = None, sandbox: bool = False, **kwargs
) -> "SMSQuickSendResponse":
"""
Legacy method for sending SMS (backward compatibility).

Args:
to: The recipient's phone number in E.164 format
body: The message body text
from_: The sender's phone number (optional)
sandbox: Use sandbox environment for testing (default: False)
**kwargs: Additional parameters (ignored for compatibility)

Returns:
Expand Down
6 changes: 5 additions & 1 deletion src/devo_global_comms_python/resources/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def get_accounts(
limit: Optional[int] = None,
is_approved: Optional[bool] = None,
search: Optional[str] = None,
sandbox: bool = False,
) -> "GetWhatsAppAccountsResponse":
"""
Get all shared WhatsApp accounts.
Expand Down Expand Up @@ -92,7 +93,7 @@ def get_accounts(

return GetWhatsAppAccountsResponse.model_validate(response.json())

def get_template(self, name: str) -> "WhatsAppTemplate":
def get_template(self, name: str, sandbox: bool = False) -> "WhatsAppTemplate":
"""
Get a WhatsApp template by name.

Expand Down Expand Up @@ -126,6 +127,7 @@ def upload_file(
file_content: bytes,
filename: str,
content_type: str,
sandbox: bool = False,
) -> "WhatsAppUploadFileResponse":
"""
Upload a file for WhatsApp messaging.
Expand Down Expand Up @@ -177,6 +179,7 @@ def send_normal_message(
to: str,
message: str,
account_id: Optional[str] = None,
sandbox: bool = False,
) -> "WhatsAppSendMessageResponse":
"""
Send a normal WhatsApp message.
Expand Down Expand Up @@ -228,6 +231,7 @@ def create_template(
self,
account_id: str,
template: "WhatsAppTemplateRequest",
sandbox: bool = False,
) -> "WhatsAppTemplateResponse":
"""
Create a WhatsApp template.
Expand Down
5 changes: 2 additions & 3 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,12 @@ def test_client_initialization_with_api_key(self, api_key):

def test_client_initialization_with_custom_params(self, api_key):
"""Test client initialization with custom parameters."""
base_url = "https://custom.api.com"
timeout = 60.0
max_retries = 5

client = DevoClient(api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
client = DevoClient(api_key=api_key, timeout=timeout, max_retries=max_retries)

assert client.base_url == base_url
assert client.base_url == DevoClient.DEFAULT_BASE_URL
assert client.timeout == timeout

def test_client_has_all_resources(self, api_key):
Expand Down
109 changes: 109 additions & 0 deletions tests/test_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Tests for sandbox functionality in the Devo Global Communications Python SDK.
"""

from unittest.mock import Mock, patch

import pytest

from devo_global_comms_python.client import DevoClient
from devo_global_comms_python.exceptions import DevoException


class TestSandboxFunctionality:
"""Test sandbox API key switching functionality."""

def test_client_initialization_with_sandbox_api_key(self):
"""Test that client can be initialized with sandbox API key."""
client = DevoClient(api_key="test-api-key", sandbox_api_key="sandbox-api-key")

assert client.api_key == "test-api-key"
assert client.sandbox_api_key == "sandbox-api-key"

def test_client_initialization_without_sandbox_api_key(self):
"""Test that client can be initialized without sandbox API key."""
client = DevoClient(api_key="test-api-key")

assert client.api_key == "test-api-key"
assert client.sandbox_api_key is None

def test_sandbox_request_without_sandbox_api_key_raises_error(self):
"""Test that sandbox request without sandbox API key raises appropriate error."""
client = DevoClient(api_key="test-api-key")

with pytest.raises(DevoException, match="Sandbox API key required when sandbox=True"):
client.get("test-endpoint", sandbox=True)

@patch("devo_global_comms_python.client.requests.Session.request")
def test_sandbox_request_uses_sandbox_api_key(self, mock_request):
"""Test that sandbox request uses sandbox API key for authentication."""
# Mock successful response
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"success": True}
mock_request.return_value = mock_response

client = DevoClient(api_key="production-api-key", sandbox_api_key="sandbox-api-key")

# Make sandbox request
client.get("test-endpoint", sandbox=True)

# Verify the request was made with sandbox API key
mock_request.assert_called_once()
call_args = mock_request.call_args
headers = call_args[1]["headers"]

# Check that X-API-Key header contains sandbox API key
assert "X-API-Key" in headers
assert "sandbox-api-key" in headers["X-API-Key"]

@patch("devo_global_comms_python.client.requests.Session.request")
def test_regular_request_uses_production_api_key(self, mock_request):
"""Test that regular request uses production API key for authentication."""
# Mock successful response
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"success": True}
mock_request.return_value = mock_response

client = DevoClient(api_key="production-api-key", sandbox_api_key="sandbox-api-key")

# Make regular request (sandbox=False by default)
client.get("test-endpoint")

# Verify the request was made with production API key
mock_request.assert_called_once()
call_args = mock_request.call_args
headers = call_args[1]["headers"]

# Check that X-API-Key header contains production API key
assert "X-API-Key" in headers
assert "production-api-key" in headers["X-API-Key"]

@patch("devo_global_comms_python.client.requests.Session.request")
def test_sms_resource_sandbox_parameter(self, mock_request):
"""Test that SMS resource functions correctly pass sandbox parameter."""
# Mock successful response
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {"senders": []}
mock_request.return_value = mock_response

client = DevoClient(api_key="production-api-key", sandbox_api_key="sandbox-api-key")

# Call SMS function with sandbox=True
client.sms.get_senders(sandbox=True)

# Verify the request was made with sandbox API key
mock_request.assert_called_once()
call_args = mock_request.call_args
headers = call_args[1]["headers"]

# Check that X-API-Key header contains sandbox API key
assert "X-API-Key" in headers
assert "sandbox-api-key" in headers["X-API-Key"]


if __name__ == "__main__":
pytest.main([__file__, "-v"])
3 changes: 2 additions & 1 deletion tests/test_sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def test_send_sms_success(self, sms_resource, test_phone_number):
"message": "Hello, World!",
"hirvalidation": True,
},
sandbox=False,
)

def test_send_sms_with_invalid_recipient(self, sms_resource):
Expand Down Expand Up @@ -125,7 +126,7 @@ def test_get_senders_success(self, sms_resource):
assert result.senders[1].istest is True

# Verify the API call
sms_resource.client.get.assert_called_once_with("user-api/me/senders")
sms_resource.client.get.assert_called_once_with("user-api/me/senders", sandbox=False)

def test_buy_number_success(self, sms_resource):
"""Test purchasing a phone number successfully."""
Expand Down