kalourmade-sailup 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: kalourmade-sailup
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Sailup SMS gateway
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: httpx>=0.24
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0; extra == "dev"
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "kalourmade-sailup"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for the Sailup SMS gateway"
5
+ requires-python = ">=3.9"
6
+ license = { text = "MIT" }
7
+ dependencies = [
8
+ "httpx>=0.24",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ dev = [
13
+ "pytest>=7.0",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["setuptools>=68", "wheel"]
18
+ build-backend = "setuptools.build_meta"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ from .client import SailupClient
2
+ from .exceptions import (
3
+ ApiError,
4
+ AuthenticationError,
5
+ InvalidSignatureError,
6
+ NetworkError,
7
+ RateLimitError,
8
+ SailupError,
9
+ ValidationError,
10
+ )
11
+ from .models import DeliveryStatus, Message, Page
12
+ from .webhooks import Webhooks
13
+
14
+ __all__ = [
15
+ "SailupClient",
16
+ "Webhooks",
17
+ "Message",
18
+ "Page",
19
+ "DeliveryStatus",
20
+ "ApiError",
21
+ "AuthenticationError",
22
+ "InvalidSignatureError",
23
+ "NetworkError",
24
+ "RateLimitError",
25
+ "SailupError",
26
+ "ValidationError",
27
+ ]
@@ -0,0 +1,13 @@
1
+ from .http import HttpxTransport
2
+ from .sms import SmsResource
3
+
4
+
5
+ class SailupClient:
6
+ def __init__(
7
+ self,
8
+ api_key: str,
9
+ base_url: str = "https://api.sailup.io/v1",
10
+ timeout: float = 30.0,
11
+ ):
12
+ transport = HttpxTransport(api_key, base_url, timeout)
13
+ self.sms = SmsResource(transport)
@@ -0,0 +1,16 @@
1
+ from .exceptions import ApiError, AuthenticationError, RateLimitError, ValidationError
2
+
3
+
4
+ def raise_for_status(status: int, raw_body: str) -> None:
5
+ if status < 400:
6
+ return
7
+
8
+ message = f"Sailup API error (status {status})"
9
+
10
+ if status == 401:
11
+ raise AuthenticationError(message)
12
+ if status == 429:
13
+ raise RateLimitError(message)
14
+ if status in (400, 422):
15
+ raise ValidationError(message)
16
+ raise ApiError(status, raw_body, message)
@@ -0,0 +1,31 @@
1
+ class SailupError(Exception):
2
+ """Base exception for all Sailup SDK errors."""
3
+
4
+
5
+ class AuthenticationError(SailupError):
6
+ """Raised on 401 responses."""
7
+
8
+
9
+ class ValidationError(SailupError):
10
+ """Raised on 400/422 responses."""
11
+
12
+
13
+ class RateLimitError(SailupError):
14
+ """Raised on 429 responses."""
15
+
16
+
17
+ class ApiError(SailupError):
18
+ """Raised on other 4xx/5xx responses."""
19
+
20
+ def __init__(self, status_code: int, raw_body: str, message: str):
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+ self.raw_body = raw_body
24
+
25
+
26
+ class NetworkError(SailupError):
27
+ """Raised when the request could not reach Sailup."""
28
+
29
+
30
+ class InvalidSignatureError(SailupError):
31
+ """Raised when webhook signature verification fails."""
@@ -0,0 +1,44 @@
1
+ from typing import Optional, Protocol
2
+
3
+ import httpx
4
+
5
+ from .exceptions import NetworkError
6
+
7
+
8
+ class Transport(Protocol):
9
+ def request(
10
+ self,
11
+ method: str,
12
+ path: str,
13
+ query: Optional[dict] = None,
14
+ json_body: Optional[dict] = None,
15
+ ) -> tuple[int, str]:
16
+ ...
17
+
18
+
19
+ class HttpxTransport:
20
+ def __init__(
21
+ self,
22
+ api_key: str,
23
+ base_url: str = "https://api.sailup.io/v1",
24
+ timeout: float = 30.0,
25
+ ):
26
+ self._client = httpx.Client(
27
+ base_url=base_url,
28
+ headers={"Authorization": f"Bearer {api_key}"},
29
+ timeout=timeout,
30
+ )
31
+
32
+ def request(
33
+ self,
34
+ method: str,
35
+ path: str,
36
+ query: Optional[dict] = None,
37
+ json_body: Optional[dict] = None,
38
+ ) -> tuple[int, str]:
39
+ try:
40
+ response = self._client.request(method, path, params=query, json=json_body)
41
+ except httpx.HTTPError as exc:
42
+ raise NetworkError(str(exc)) from exc
43
+
44
+ return response.status_code, response.text
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import List, Optional
4
+
5
+
6
+ class DeliveryStatus(str, Enum):
7
+ PENDING = "pending"
8
+ DELIVERED = "delivered"
9
+ FAILED = "failed"
10
+ EXPIRED = "expired"
11
+ REJECTED = "rejected"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Message:
16
+ id: str
17
+ to: List[str]
18
+ sender: Optional[str]
19
+ body: str
20
+ quantity: int
21
+ delivery_status: DeliveryStatus
22
+ created_at: str
23
+
24
+ @staticmethod
25
+ def from_dict(data: dict) -> "Message":
26
+ to = data["to"] if isinstance(data["to"], list) else [data["to"]]
27
+ return Message(
28
+ id=data["id"],
29
+ to=to,
30
+ sender=data.get("sender"),
31
+ body=data["body"],
32
+ quantity=data["quantity"],
33
+ delivery_status=DeliveryStatus(data["delivery_status"]),
34
+ created_at=data["created_at"],
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Page:
40
+ count: int
41
+ next: Optional[str]
42
+ previous: Optional[str]
43
+ results: List[Message]
44
+
45
+ @staticmethod
46
+ def from_dict(data: dict) -> "Page":
47
+ return Page(
48
+ count=data["count"],
49
+ next=data.get("next"),
50
+ previous=data.get("previous"),
51
+ results=[Message.from_dict(m) for m in data["results"]],
52
+ )
@@ -0,0 +1,29 @@
1
+ import json
2
+
3
+ from .error_mapping import raise_for_status
4
+ from .http import Transport
5
+ from .models import Message, Page
6
+
7
+
8
+ class SmsResource:
9
+ def __init__(self, transport: Transport):
10
+ self._transport = transport
11
+
12
+ def send(self, from_: str, to: list, body: str) -> Message:
13
+ status, raw = self._transport.request(
14
+ "POST", "/sms/", json_body={"from": from_, "to": to, "body": body}
15
+ )
16
+ raise_for_status(status, raw)
17
+ return Message.from_dict(json.loads(raw))
18
+
19
+ def list(self, page: int = 1, page_size: int = 30) -> Page:
20
+ status, raw = self._transport.request(
21
+ "GET", "/sms/", query={"page": page, "page_size": page_size}
22
+ )
23
+ raise_for_status(status, raw)
24
+ return Page.from_dict(json.loads(raw))
25
+
26
+ def get(self, message_id: str) -> Message:
27
+ status, raw = self._transport.request("GET", f"/sms/{message_id}/")
28
+ raise_for_status(status, raw)
29
+ return Message.from_dict(json.loads(raw))
@@ -0,0 +1,26 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ from typing import Mapping
5
+
6
+ from .exceptions import InvalidSignatureError
7
+
8
+
9
+ class Webhooks:
10
+ @staticmethod
11
+ def verify(raw_body: str, headers: Mapping[str, str], secret: str) -> dict:
12
+ headers_lower = {k.lower(): v for k, v in headers.items()}
13
+ signature = headers_lower.get("x-sailup-signature")
14
+ timestamp = headers_lower.get("x-sailup-timestamp")
15
+
16
+ if not signature or not timestamp:
17
+ raise InvalidSignatureError("Missing signature headers")
18
+
19
+ expected = hmac.new(
20
+ secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
21
+ ).hexdigest()
22
+
23
+ if not hmac.compare_digest(expected, signature):
24
+ raise InvalidSignatureError("Signature mismatch")
25
+
26
+ return json.loads(raw_body)
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: kalourmade-sailup
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Sailup SMS gateway
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: httpx>=0.24
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0; extra == "dev"
@@ -0,0 +1,20 @@
1
+ pyproject.toml
2
+ src/kalourmade_sailup/__init__.py
3
+ src/kalourmade_sailup/client.py
4
+ src/kalourmade_sailup/error_mapping.py
5
+ src/kalourmade_sailup/exceptions.py
6
+ src/kalourmade_sailup/http.py
7
+ src/kalourmade_sailup/models.py
8
+ src/kalourmade_sailup/sms.py
9
+ src/kalourmade_sailup/webhooks.py
10
+ src/kalourmade_sailup.egg-info/PKG-INFO
11
+ src/kalourmade_sailup.egg-info/SOURCES.txt
12
+ src/kalourmade_sailup.egg-info/dependency_links.txt
13
+ src/kalourmade_sailup.egg-info/requires.txt
14
+ src/kalourmade_sailup.egg-info/top_level.txt
15
+ tests/test_client.py
16
+ tests/test_error_mapping.py
17
+ tests/test_exceptions.py
18
+ tests/test_models.py
19
+ tests/test_sms.py
20
+ tests/test_webhooks.py
@@ -0,0 +1,4 @@
1
+ httpx>=0.24
2
+
3
+ [dev]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ kalourmade_sailup
@@ -0,0 +1,8 @@
1
+ from kalourmade_sailup.client import SailupClient
2
+ from kalourmade_sailup.sms import SmsResource
3
+
4
+
5
+ def test_client_exposes_sms_resource():
6
+ client = SailupClient("sailup_test_key")
7
+
8
+ assert isinstance(client.sms, SmsResource)
@@ -0,0 +1,36 @@
1
+ import pytest
2
+
3
+ from kalourmade_sailup.error_mapping import raise_for_status
4
+ from kalourmade_sailup.exceptions import (
5
+ ApiError,
6
+ AuthenticationError,
7
+ RateLimitError,
8
+ ValidationError,
9
+ )
10
+
11
+
12
+ def test_ok_status_does_not_raise():
13
+ raise_for_status(200, "{}")
14
+
15
+
16
+ def test_401_raises_authentication_error():
17
+ with pytest.raises(AuthenticationError):
18
+ raise_for_status(401, "{}")
19
+
20
+
21
+ def test_429_raises_rate_limit_error():
22
+ with pytest.raises(RateLimitError):
23
+ raise_for_status(429, "{}")
24
+
25
+
26
+ def test_400_and_422_raise_validation_error():
27
+ with pytest.raises(ValidationError):
28
+ raise_for_status(422, "{}")
29
+
30
+
31
+ def test_other_4xx_5xx_raises_api_error_with_status_and_body():
32
+ with pytest.raises(ApiError) as exc_info:
33
+ raise_for_status(503, '{"detail":"down"}')
34
+
35
+ assert exc_info.value.status_code == 503
36
+ assert exc_info.value.raw_body == '{"detail":"down"}'
@@ -0,0 +1,26 @@
1
+ from kalourmade_sailup.exceptions import (
2
+ ApiError,
3
+ AuthenticationError,
4
+ InvalidSignatureError,
5
+ NetworkError,
6
+ RateLimitError,
7
+ SailupError,
8
+ ValidationError,
9
+ )
10
+
11
+
12
+ def test_all_exceptions_extend_sailup_error():
13
+ assert issubclass(AuthenticationError, SailupError)
14
+ assert issubclass(ValidationError, SailupError)
15
+ assert issubclass(RateLimitError, SailupError)
16
+ assert issubclass(NetworkError, SailupError)
17
+ assert issubclass(InvalidSignatureError, SailupError)
18
+ assert issubclass(ApiError, SailupError)
19
+
20
+
21
+ def test_api_error_carries_status_and_raw_body():
22
+ error = ApiError(503, '{"detail":"down"}', "Sailup API error (status 503)")
23
+
24
+ assert error.status_code == 503
25
+ assert error.raw_body == '{"detail":"down"}'
26
+ assert str(error) == "Sailup API error (status 503)"
@@ -0,0 +1,44 @@
1
+ from kalourmade_sailup.models import DeliveryStatus, Message, Page
2
+
3
+
4
+ def message_dict(**overrides):
5
+ base = {
6
+ "id": "msg_123",
7
+ "to": ["233241234567"],
8
+ "sender": "MyBrand",
9
+ "body": "Hello",
10
+ "quantity": 1,
11
+ "delivery_status": "delivered",
12
+ "created_at": "2026-09-12T10:00:00Z",
13
+ }
14
+ base.update(overrides)
15
+ return base
16
+
17
+
18
+ def test_message_from_dict_maps_all_fields():
19
+ message = Message.from_dict(message_dict())
20
+
21
+ assert message.id == "msg_123"
22
+ assert message.to == ["233241234567"]
23
+ assert message.delivery_status == DeliveryStatus.DELIVERED
24
+
25
+
26
+ def test_message_from_dict_wraps_scalar_to_in_list():
27
+ message = Message.from_dict(message_dict(to="233241234567", sender=None))
28
+
29
+ assert message.to == ["233241234567"]
30
+ assert message.sender is None
31
+
32
+
33
+ def test_page_from_dict_maps_envelope_and_results():
34
+ page = Page.from_dict({
35
+ "count": 1,
36
+ "next": None,
37
+ "previous": None,
38
+ "results": [message_dict()],
39
+ })
40
+
41
+ assert page.count == 1
42
+ assert page.next is None
43
+ assert len(page.results) == 1
44
+ assert isinstance(page.results[0], Message)
@@ -0,0 +1,70 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from kalourmade_sailup.exceptions import AuthenticationError
6
+ from kalourmade_sailup.sms import SmsResource
7
+ from tests.fakes import FakeTransport
8
+
9
+
10
+ def message_json():
11
+ return json.dumps({
12
+ "id": "msg_123",
13
+ "to": ["233241234567"],
14
+ "sender": "MyBrand",
15
+ "body": "Hello",
16
+ "quantity": 1,
17
+ "delivery_status": "pending",
18
+ "created_at": "2026-09-12T10:00:00Z",
19
+ })
20
+
21
+
22
+ def test_send_posts_to_sms_and_returns_message():
23
+ transport = FakeTransport([(202, message_json())])
24
+ resource = SmsResource(transport)
25
+
26
+ message = resource.send("MyBrand", ["233241234567"], "Hello")
27
+
28
+ assert message.id == "msg_123"
29
+ assert transport.requests[0]["method"] == "POST"
30
+ assert transport.requests[0]["path"] == "/sms/"
31
+ assert transport.requests[0]["json_body"] == {
32
+ "from": "MyBrand",
33
+ "to": ["233241234567"],
34
+ "body": "Hello",
35
+ }
36
+
37
+
38
+ def test_list_gets_sms_with_pagination_query():
39
+ envelope = json.dumps({
40
+ "count": 1,
41
+ "next": None,
42
+ "previous": None,
43
+ "results": [json.loads(message_json())],
44
+ })
45
+ transport = FakeTransport([(200, envelope)])
46
+ resource = SmsResource(transport)
47
+
48
+ page = resource.list(page=2, page_size=50)
49
+
50
+ assert page.count == 1
51
+ assert len(page.results) == 1
52
+ assert transport.requests[0]["query"] == {"page": 2, "page_size": 50}
53
+
54
+
55
+ def test_get_fetches_single_message_by_id():
56
+ transport = FakeTransport([(200, message_json())])
57
+ resource = SmsResource(transport)
58
+
59
+ message = resource.get("msg_123")
60
+
61
+ assert message.id == "msg_123"
62
+ assert transport.requests[0]["path"] == "/sms/msg_123/"
63
+
64
+
65
+ def test_error_status_raises_mapped_exception():
66
+ transport = FakeTransport([(401, "{}")])
67
+ resource = SmsResource(transport)
68
+
69
+ with pytest.raises(AuthenticationError):
70
+ resource.get("msg_123")
@@ -0,0 +1,47 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from kalourmade_sailup.exceptions import InvalidSignatureError
8
+ from kalourmade_sailup.webhooks import Webhooks
9
+
10
+ SECRET = "whsec_test"
11
+
12
+
13
+ def sign(timestamp: str, raw_body: str) -> str:
14
+ return hmac.new(SECRET.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()
15
+
16
+
17
+ def test_verify_returns_decoded_payload_for_valid_signature():
18
+ raw_body = json.dumps({"event": "message.delivered", "data": {"message_id": "msg_123"}})
19
+ timestamp = "1700000000"
20
+ signature = sign(timestamp, raw_body)
21
+
22
+ event = Webhooks.verify(
23
+ raw_body,
24
+ {"X-Sailup-Signature": signature, "X-Sailup-Timestamp": timestamp},
25
+ SECRET,
26
+ )
27
+
28
+ assert event["event"] == "message.delivered"
29
+
30
+
31
+ def test_verify_raises_on_tampered_body():
32
+ raw_body = json.dumps({"event": "message.delivered"})
33
+ timestamp = "1700000000"
34
+ signature = sign(timestamp, raw_body)
35
+ tampered = json.dumps({"event": "message.failed"})
36
+
37
+ with pytest.raises(InvalidSignatureError):
38
+ Webhooks.verify(
39
+ tampered,
40
+ {"X-Sailup-Signature": signature, "X-Sailup-Timestamp": timestamp},
41
+ SECRET,
42
+ )
43
+
44
+
45
+ def test_verify_raises_on_missing_headers():
46
+ with pytest.raises(InvalidSignatureError):
47
+ Webhooks.verify("{}", {}, SECRET)