gosms-python 1.0.3__py3-none-any.whl

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.
gosms/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """GoSMS.GE Python SDK -- Official SMS gateway client."""
2
+ from gosms.client import SMS
3
+ from gosms.exceptions import GoSmsApiError, GoSmsErrorCode
4
+
5
+ __version__ = "1.0.3"
6
+ __all__ = ["SMS", "GoSmsApiError", "GoSmsErrorCode"]
7
+
8
+ try:
9
+ from gosms.async_client import AsyncSMS # noqa: F401
10
+
11
+ __all__.append("AsyncSMS")
12
+ except ImportError:
13
+ pass
gosms/_base.py ADDED
@@ -0,0 +1,69 @@
1
+ """Shared base logic for sync and async clients."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ from gosms.exceptions import GoSmsApiError
8
+
9
+ logger = logging.getLogger("gosms")
10
+
11
+
12
+ class _BaseClient:
13
+ """Base class providing validation, URL building, and error parsing."""
14
+
15
+ BASE_URL: str = "https://api.gosms.ge/api"
16
+
17
+ def __init__(
18
+ self,
19
+ api_key: str,
20
+ *,
21
+ timeout: int = 30,
22
+ retries: int = 1,
23
+ debug: bool = False,
24
+ ) -> None:
25
+ if not api_key:
26
+ raise TypeError("api_key is required")
27
+ if not isinstance(api_key, str):
28
+ raise TypeError("api_key must be a string")
29
+
30
+ self._api_key = api_key
31
+ self._timeout = timeout
32
+ self._retries = max(1, retries)
33
+ self._debug = debug
34
+
35
+ if debug:
36
+ logging.basicConfig(level=logging.DEBUG)
37
+
38
+ def _build_url(self, endpoint: str) -> str:
39
+ return f"{self.BASE_URL}/{endpoint}"
40
+
41
+ def _build_payload(self, **kwargs: Any) -> dict[str, Any]:
42
+ payload: dict[str, Any] = {"api_key": self._api_key}
43
+ payload.update(kwargs)
44
+ return payload
45
+
46
+ def _parse_response(self, data: dict[str, Any], status_code: int) -> dict[str, Any]:
47
+ error_code = data.get("errorCode", 0)
48
+ if status_code >= 400 or error_code:
49
+ raise GoSmsApiError(
50
+ error_code=error_code,
51
+ message=data.get("message", "Unknown error"),
52
+ )
53
+ return data
54
+
55
+ def _validate_phone_number(self, value: Any, param_name: str) -> None:
56
+ if not value or not isinstance(value, str):
57
+ raise TypeError(f"{param_name} is required and must be a string")
58
+
59
+ def _validate_string(self, value: Any, param_name: str) -> None:
60
+ if not value or not isinstance(value, str):
61
+ raise TypeError(f"{param_name} is required and must be a string")
62
+
63
+ def _log(self, *args: Any) -> None:
64
+ if self._debug:
65
+ logger.debug("[GOSMS Debug] %s", " ".join(str(a) for a in args))
66
+
67
+ def _calculate_delay(self, attempt: int) -> float:
68
+ """Exponential backoff: 1s, 2s, 4s, ..."""
69
+ return float(2 ** (attempt - 1))
gosms/async_client.py ADDED
@@ -0,0 +1,167 @@
1
+ """Asynchronous SMS client using the httpx library."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from gosms._base import _BaseClient
10
+ from gosms.types import (
11
+ BalanceResponse,
12
+ CheckStatusResponse,
13
+ OtpSendResponse,
14
+ OtpVerifyResponse,
15
+ SendBulkSmsResponse,
16
+ SenderCreateResponse,
17
+ SmsSendResponse,
18
+ )
19
+
20
+
21
+ class AsyncSMS(_BaseClient):
22
+ """Asynchronous GoSMS.GE API client.
23
+
24
+ Can be used as an async context manager::
25
+
26
+ async with AsyncSMS('api_key') as sms:
27
+ result = await sms.send('995555123456', 'Hello!', 'GOSMS.GE')
28
+
29
+ Or standalone::
30
+
31
+ sms = AsyncSMS('api_key')
32
+ result = await sms.send('995555123456', 'Hello!', 'GOSMS.GE')
33
+ await sms.close()
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str,
39
+ *,
40
+ timeout: int = 30,
41
+ retries: int = 1,
42
+ debug: bool = False,
43
+ ) -> None:
44
+ super().__init__(api_key, timeout=timeout, retries=retries, debug=debug)
45
+ self._client: httpx.AsyncClient | None = None
46
+
47
+ async def __aenter__(self) -> AsyncSMS:
48
+ self._client = httpx.AsyncClient(
49
+ timeout=self._timeout,
50
+ headers={"Content-Type": "application/json"},
51
+ )
52
+ return self
53
+
54
+ async def __aexit__(self, *args: Any) -> None:
55
+ await self.close()
56
+
57
+ async def close(self) -> None:
58
+ """Close the underlying HTTP client."""
59
+ if self._client is not None:
60
+ await self._client.aclose()
61
+ self._client = None
62
+
63
+ def _get_client(self) -> httpx.AsyncClient:
64
+ if self._client is None:
65
+ self._client = httpx.AsyncClient(
66
+ timeout=self._timeout,
67
+ headers={"Content-Type": "application/json"},
68
+ )
69
+ return self._client
70
+
71
+ async def _make_request(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
72
+ url = self._build_url(endpoint)
73
+ client = self._get_client()
74
+ last_error: Exception | None = None
75
+
76
+ for attempt in range(1, self._retries + 1):
77
+ try:
78
+ self._log(f"Request attempt {attempt}/{self._retries} to {endpoint}")
79
+ response = await client.post(url, json=payload)
80
+ data: dict[str, Any] = response.json()
81
+ self._log("Response:", data)
82
+ return self._parse_response(data, response.status_code)
83
+ except Exception as exc:
84
+ last_error = exc
85
+ self._log(f"Attempt {attempt} failed:", exc)
86
+ if attempt < self._retries:
87
+ delay = self._calculate_delay(attempt)
88
+ self._log(f"Retrying in {delay}s...")
89
+ await asyncio.sleep(delay)
90
+
91
+ raise last_error # type: ignore[misc]
92
+
93
+ async def send(
94
+ self,
95
+ phone_number: str,
96
+ text: str,
97
+ sender_name: str,
98
+ urgent: bool = False,
99
+ ) -> SmsSendResponse:
100
+ """Send an SMS message to a single recipient."""
101
+ self._validate_phone_number(phone_number, "phone_number")
102
+ self._validate_string(text, "text")
103
+ self._validate_string(sender_name, "sender_name")
104
+
105
+ payload = self._build_payload(to=phone_number, text=text, urgent=urgent)
106
+ payload["from"] = sender_name
107
+
108
+ data = await self._make_request("sendsms", payload)
109
+ return SmsSendResponse.from_dict(data)
110
+
111
+ async def send_bulk(
112
+ self,
113
+ sender_name: str,
114
+ phone_numbers: list[str],
115
+ text: str,
116
+ urgent: bool = False,
117
+ no_sms_number: str | None = None,
118
+ ) -> SendBulkSmsResponse:
119
+ """Send an SMS message to multiple recipients (max 1000)."""
120
+ self._validate_string(sender_name, "sender_name")
121
+ if not isinstance(phone_numbers, list) or len(phone_numbers) == 0:
122
+ raise TypeError("phone_numbers is required and must be a non-empty list")
123
+ self._validate_string(text, "text")
124
+
125
+ payload = self._build_payload(to=phone_numbers, text=text, urgent=urgent)
126
+ payload["from"] = sender_name
127
+ if no_sms_number is not None:
128
+ payload["noSmsNumber"] = no_sms_number
129
+
130
+ data = await self._make_request("sendbulk", payload)
131
+ return SendBulkSmsResponse.from_dict(data)
132
+
133
+ async def send_otp(self, phone_number: str) -> OtpSendResponse:
134
+ """Send an OTP (One-Time Password) SMS."""
135
+ self._validate_phone_number(phone_number, "phone_number")
136
+
137
+ data = await self._make_request("otp/send", self._build_payload(phone=phone_number))
138
+ return OtpSendResponse.from_dict(data)
139
+
140
+ async def verify_otp(self, phone_number: str, hash: str, code: str) -> OtpVerifyResponse:
141
+ """Verify an OTP code."""
142
+ self._validate_phone_number(phone_number, "phone_number")
143
+ self._validate_string(hash, "hash")
144
+ self._validate_string(code, "code")
145
+
146
+ data = await self._make_request(
147
+ "otp/verify",
148
+ self._build_payload(phone=phone_number, hash=hash, code=code),
149
+ )
150
+ return OtpVerifyResponse.from_dict(data)
151
+
152
+ async def status(self, message_id: int) -> CheckStatusResponse:
153
+ """Check the delivery status of a sent SMS message."""
154
+ data = await self._make_request("checksms", self._build_payload(messageId=message_id))
155
+ return CheckStatusResponse.from_dict(data)
156
+
157
+ async def balance(self) -> BalanceResponse:
158
+ """Check the current SMS balance of your account."""
159
+ data = await self._make_request("sms-balance", self._build_payload())
160
+ return BalanceResponse.from_dict(data)
161
+
162
+ async def create_sender(self, name: str) -> SenderCreateResponse:
163
+ """Register a new sender name."""
164
+ self._validate_string(name, "name")
165
+
166
+ data = await self._make_request("sender", self._build_payload(name=name))
167
+ return SenderCreateResponse.from_dict(data)
gosms/client.py ADDED
@@ -0,0 +1,144 @@
1
+ """Synchronous SMS client using the requests library."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from gosms._base import _BaseClient
10
+ from gosms.types import (
11
+ BalanceResponse,
12
+ CheckStatusResponse,
13
+ OtpSendResponse,
14
+ OtpVerifyResponse,
15
+ SendBulkSmsResponse,
16
+ SenderCreateResponse,
17
+ SmsSendResponse,
18
+ )
19
+
20
+
21
+ class SMS(_BaseClient):
22
+ """Synchronous GoSMS.GE API client.
23
+
24
+ Args:
25
+ api_key: Your API key from https://gosms.ge
26
+ timeout: Request timeout in seconds (default: 30)
27
+ retries: Number of retry attempts (default: 1)
28
+ debug: Enable debug logging (default: False)
29
+
30
+ Example::
31
+
32
+ sms = SMS('your_api_key')
33
+ result = sms.send('995555123456', 'Hello!', 'GOSMS.GE')
34
+ print(result.message_id)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ api_key: str,
40
+ *,
41
+ timeout: int = 30,
42
+ retries: int = 1,
43
+ debug: bool = False,
44
+ ) -> None:
45
+ super().__init__(api_key, timeout=timeout, retries=retries, debug=debug)
46
+ self._session = requests.Session()
47
+ self._session.headers.update({"Content-Type": "application/json"})
48
+
49
+ def _make_request(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
50
+ url = self._build_url(endpoint)
51
+ last_error: Exception | None = None
52
+
53
+ for attempt in range(1, self._retries + 1):
54
+ try:
55
+ self._log(f"Request attempt {attempt}/{self._retries} to {endpoint}")
56
+ response = self._session.post(url, json=payload, timeout=self._timeout)
57
+ data: dict[str, Any] = response.json()
58
+ self._log("Response:", data)
59
+ return self._parse_response(data, response.status_code)
60
+ except Exception as exc:
61
+ last_error = exc
62
+ self._log(f"Attempt {attempt} failed:", exc)
63
+ if attempt < self._retries:
64
+ delay = self._calculate_delay(attempt)
65
+ self._log(f"Retrying in {delay}s...")
66
+ time.sleep(delay)
67
+
68
+ raise last_error # type: ignore[misc]
69
+
70
+ def send(
71
+ self,
72
+ phone_number: str,
73
+ text: str,
74
+ sender_name: str,
75
+ urgent: bool = False,
76
+ ) -> SmsSendResponse:
77
+ """Send an SMS message to a single recipient."""
78
+ self._validate_phone_number(phone_number, "phone_number")
79
+ self._validate_string(text, "text")
80
+ self._validate_string(sender_name, "sender_name")
81
+
82
+ payload = self._build_payload(to=phone_number, text=text, urgent=urgent)
83
+ payload["from"] = sender_name
84
+
85
+ data = self._make_request("sendsms", payload)
86
+ return SmsSendResponse.from_dict(data)
87
+
88
+ def send_bulk(
89
+ self,
90
+ sender_name: str,
91
+ phone_numbers: list[str],
92
+ text: str,
93
+ urgent: bool = False,
94
+ no_sms_number: str | None = None,
95
+ ) -> SendBulkSmsResponse:
96
+ """Send an SMS message to multiple recipients (max 1000)."""
97
+ self._validate_string(sender_name, "sender_name")
98
+ if not isinstance(phone_numbers, list) or len(phone_numbers) == 0:
99
+ raise TypeError("phone_numbers is required and must be a non-empty list")
100
+ self._validate_string(text, "text")
101
+
102
+ payload = self._build_payload(to=phone_numbers, text=text, urgent=urgent)
103
+ payload["from"] = sender_name
104
+ if no_sms_number is not None:
105
+ payload["noSmsNumber"] = no_sms_number
106
+
107
+ data = self._make_request("sendbulk", payload)
108
+ return SendBulkSmsResponse.from_dict(data)
109
+
110
+ def send_otp(self, phone_number: str) -> OtpSendResponse:
111
+ """Send an OTP (One-Time Password) SMS."""
112
+ self._validate_phone_number(phone_number, "phone_number")
113
+
114
+ data = self._make_request("otp/send", self._build_payload(phone=phone_number))
115
+ return OtpSendResponse.from_dict(data)
116
+
117
+ def verify_otp(self, phone_number: str, hash: str, code: str) -> OtpVerifyResponse:
118
+ """Verify an OTP code."""
119
+ self._validate_phone_number(phone_number, "phone_number")
120
+ self._validate_string(hash, "hash")
121
+ self._validate_string(code, "code")
122
+
123
+ data = self._make_request(
124
+ "otp/verify",
125
+ self._build_payload(phone=phone_number, hash=hash, code=code),
126
+ )
127
+ return OtpVerifyResponse.from_dict(data)
128
+
129
+ def status(self, message_id: int) -> CheckStatusResponse:
130
+ """Check the delivery status of a sent SMS message."""
131
+ data = self._make_request("checksms", self._build_payload(messageId=message_id))
132
+ return CheckStatusResponse.from_dict(data)
133
+
134
+ def balance(self) -> BalanceResponse:
135
+ """Check the current SMS balance of your account."""
136
+ data = self._make_request("sms-balance", self._build_payload())
137
+ return BalanceResponse.from_dict(data)
138
+
139
+ def create_sender(self, name: str) -> SenderCreateResponse:
140
+ """Register a new sender name."""
141
+ self._validate_string(name, "name")
142
+
143
+ data = self._make_request("sender", self._build_payload(name=name))
144
+ return SenderCreateResponse.from_dict(data)
gosms/django.py ADDED
@@ -0,0 +1,80 @@
1
+ """Django integration for GoSMS.GE SDK."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ from gosms.client import SMS
8
+
9
+ _client: SMS | None = None
10
+
11
+
12
+ class _ImproperlyConfigured(Exception):
13
+ """Fallback when Django is not installed."""
14
+
15
+
16
+ def _get_settings() -> Any:
17
+ """Import and return Django settings. Separated for testability."""
18
+ from django.conf import settings
19
+
20
+ return settings
21
+
22
+
23
+ def _get_improperly_configured() -> type:
24
+ """Import and return Django's ImproperlyConfigured exception."""
25
+ try:
26
+ from django.core.exceptions import ImproperlyConfigured
27
+
28
+ return ImproperlyConfigured
29
+ except ImportError:
30
+ return _ImproperlyConfigured
31
+
32
+
33
+ def get_sms_client(**overrides: Any) -> SMS:
34
+ """Get or create a singleton SMS client configured from Django settings.
35
+
36
+ Reads from ``settings.GOSMS_SETTINGS``::
37
+
38
+ # settings.py
39
+ GOSMS_SETTINGS = {
40
+ 'api_key': 'your_key',
41
+ 'timeout': 30, # optional
42
+ 'retries': 1, # optional
43
+ 'debug': False, # optional (defaults to DEBUG)
44
+ }
45
+
46
+ Usage::
47
+
48
+ from gosms.django import get_sms_client
49
+ sms = get_sms_client()
50
+ sms.send('995555123456', 'Hello!', 'GOSMS.GE')
51
+ """
52
+ global _client
53
+ if _client is None:
54
+ from gosms.client import SMS
55
+
56
+ settings = _get_settings()
57
+ ImproperlyConfigured = _get_improperly_configured()
58
+
59
+ config: dict[str, Any] = getattr(settings, "GOSMS_SETTINGS", {})
60
+ api_key = config.get("api_key")
61
+ if not api_key:
62
+ raise ImproperlyConfigured(
63
+ "GOSMS_SETTINGS must contain 'api_key'. "
64
+ "Add GOSMS_SETTINGS = {'api_key': '...'} to your Django settings."
65
+ )
66
+
67
+ opts: dict[str, Any] = {
68
+ "timeout": config.get("timeout", 30),
69
+ "retries": config.get("retries", 1),
70
+ "debug": config.get("debug", getattr(settings, "DEBUG", False)),
71
+ }
72
+ opts.update(overrides)
73
+ _client = SMS(api_key, **opts)
74
+ return _client
75
+
76
+
77
+ def reset_client() -> None:
78
+ """Reset the singleton client. Useful for testing."""
79
+ global _client
80
+ _client = None
gosms/exceptions.py ADDED
@@ -0,0 +1,33 @@
1
+ """Exception types and error codes for the GoSMS.GE SDK."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class GoSmsErrorCode:
6
+ """Error code constants matching the GoSMS.GE API."""
7
+
8
+ INVALID_API_KEY = 100
9
+ INVALID_SENDER = 101
10
+ INSUFFICIENT_BALANCE = 102
11
+ INVALID_PARAMETERS = 103
12
+ MESSAGE_NOT_FOUND = 104
13
+ INVALID_PHONE = 105
14
+ OTP_FAILED = 106
15
+ SENDER_EXISTS = 107
16
+ NOT_CONFIGURED = 108
17
+ TOO_MANY_REQUESTS = 109
18
+ ACCOUNT_LOCKED = 110
19
+ OTP_EXPIRED = 111
20
+ OTP_ALREADY_USED = 112
21
+ INVALID_NO_SMS_NUMBER = 113
22
+
23
+
24
+ class GoSmsApiError(Exception):
25
+ """Raised when the GoSMS.GE API returns an error response."""
26
+
27
+ def __init__(self, error_code: int, message: str) -> None:
28
+ self.error_code = error_code
29
+ self.message = message
30
+ super().__init__(f"[{error_code}] {message}")
31
+
32
+ def __repr__(self) -> str:
33
+ return f"GoSmsApiError(error_code={self.error_code}, message={self.message!r})"
gosms/py.typed ADDED
File without changes
gosms/types.py ADDED
@@ -0,0 +1,173 @@
1
+ """Response type definitions matching the GoSMS.GE API."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SmsSendResponse:
10
+ success: bool
11
+ message_id: int
12
+ sender: str
13
+ to: str
14
+ text: str
15
+ send_at: str
16
+ balance: int
17
+ encode: str
18
+ segment: int
19
+ sms_characters: int
20
+
21
+ @classmethod
22
+ def from_dict(cls, data: dict[str, Any]) -> SmsSendResponse:
23
+ return cls(
24
+ success=data.get("success", False),
25
+ message_id=data.get("messageId", 0),
26
+ sender=data.get("from", ""),
27
+ to=data.get("to", ""),
28
+ text=data.get("text", ""),
29
+ send_at=data.get("sendAt", ""),
30
+ balance=data.get("balance", 0),
31
+ encode=data.get("encode", ""),
32
+ segment=data.get("segment", 0),
33
+ sms_characters=data.get("smsCharacters", 0),
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BulkSmsResult:
39
+ message_id: int
40
+ to: str
41
+ success: bool
42
+ error: str | None = None
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: dict[str, Any]) -> BulkSmsResult:
46
+ return cls(
47
+ message_id=data.get("messageId", 0),
48
+ to=data.get("to", ""),
49
+ success=data.get("success", False),
50
+ error=data.get("error"),
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SendBulkSmsResponse:
56
+ success: bool
57
+ total_count: int
58
+ success_count: int
59
+ failed_count: int
60
+ balance: int
61
+ sender: str
62
+ text: str
63
+ encode: str
64
+ segment: int
65
+ sms_characters: int
66
+ messages: list[BulkSmsResult] = field(default_factory=list)
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: dict[str, Any]) -> SendBulkSmsResponse:
70
+ return cls(
71
+ success=data.get("success", False),
72
+ total_count=data.get("totalCount", 0),
73
+ success_count=data.get("successCount", 0),
74
+ failed_count=data.get("failedCount", 0),
75
+ balance=data.get("balance", 0),
76
+ sender=data.get("from", ""),
77
+ text=data.get("text", ""),
78
+ encode=data.get("encode", ""),
79
+ segment=data.get("segment", 0),
80
+ sms_characters=data.get("smsCharacters", 0),
81
+ messages=[BulkSmsResult.from_dict(m) for m in data.get("messages", [])],
82
+ )
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class CheckStatusResponse:
87
+ success: bool
88
+ message_id: int
89
+ sender: str
90
+ to: str
91
+ text: str
92
+ send_at: str
93
+ encode: str
94
+ segment: int
95
+ sms_characters: int
96
+ status: str
97
+
98
+ @classmethod
99
+ def from_dict(cls, data: dict[str, Any]) -> CheckStatusResponse:
100
+ return cls(
101
+ success=data.get("success", False),
102
+ message_id=data.get("messageId", 0),
103
+ sender=data.get("from", ""),
104
+ to=data.get("to", ""),
105
+ text=data.get("text", ""),
106
+ send_at=data.get("sendAt", ""),
107
+ encode=data.get("encode", ""),
108
+ segment=data.get("segment", 0),
109
+ sms_characters=data.get("smsCharacters", 0),
110
+ status=data.get("status", ""),
111
+ )
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class BalanceResponse:
116
+ success: bool
117
+ balance: int
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: dict[str, Any]) -> BalanceResponse:
121
+ return cls(
122
+ success=data.get("success", False),
123
+ balance=data.get("balance", 0),
124
+ )
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class OtpSendResponse:
129
+ success: bool
130
+ hash: str
131
+ balance: int
132
+ to: str
133
+ send_at: str
134
+ encode: str
135
+ segment: int
136
+ sms_characters: int
137
+
138
+ @classmethod
139
+ def from_dict(cls, data: dict[str, Any]) -> OtpSendResponse:
140
+ return cls(
141
+ success=data.get("success", False),
142
+ hash=data.get("hash", ""),
143
+ balance=data.get("balance", 0),
144
+ to=data.get("to", ""),
145
+ send_at=data.get("sendAt", ""),
146
+ encode=data.get("encode", ""),
147
+ segment=data.get("segment", 0),
148
+ sms_characters=data.get("smsCharacters", 0),
149
+ )
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class OtpVerifyResponse:
154
+ success: bool
155
+ verify: bool
156
+
157
+ @classmethod
158
+ def from_dict(cls, data: dict[str, Any]) -> OtpVerifyResponse:
159
+ return cls(
160
+ success=data.get("success", False),
161
+ verify=data.get("verify", False),
162
+ )
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class SenderCreateResponse:
167
+ success: bool
168
+
169
+ @classmethod
170
+ def from_dict(cls, data: dict[str, Any]) -> SenderCreateResponse:
171
+ return cls(
172
+ success=data.get("success", False),
173
+ )
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.4
2
+ Name: gosms-python
3
+ Version: 1.0.3
4
+ Summary: Official Python SDK for GoSMS.GE SMS Gateway
5
+ Project-URL: Homepage, https://gosms.ge
6
+ Project-URL: Repository, https://github.com/gosms-ge/gosmsge-python
7
+ Project-URL: Issues, https://github.com/gosms-ge/gosmsge-python/issues
8
+ Author-email: "GoSMS.GE" <info@gosms.ge>
9
+ License-Expression: MIT
10
+ Keywords: gosms,otp,sms,sms-api,sms-gateway,sms-sdk
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: requests>=2.28.0
24
+ Provides-Extra: async
25
+ Requires-Dist: httpx>=0.24.0; extra == 'async'
26
+ Provides-Extra: dev
27
+ Requires-Dist: build; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
29
+ Requires-Dist: pytest-httpx>=0.21; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0; extra == 'dev'
31
+ Requires-Dist: responses>=0.23; extra == 'dev'
32
+ Requires-Dist: ruff>=0.1; extra == 'dev'
33
+ Provides-Extra: django
34
+ Requires-Dist: django>=3.2; extra == 'django'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # GoSMS.GE Python SDK
38
+
39
+ [![PyPI version](https://img.shields.io/pypi/v/gosms-python.svg)](https://pypi.org/project/gosms-python/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/gosms-python.svg)](https://pypi.org/project/gosms-python/)
41
+ [![Tests](https://github.com/gosms-python/gosms-python-python/actions/workflows/release.yml/badge.svg)](https://github.com/gosms-python/gosms-python-python/actions)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
43
+
44
+ Official Python SDK for the [GoSMS.GE](https://gosms.ge) SMS gateway. Send SMS messages, manage OTP verification, and check balances with both sync and async clients.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install gosms-python
50
+ ```
51
+
52
+ For async support:
53
+
54
+ ```bash
55
+ pip install gosms-python[async]
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from gosms import SMS
62
+
63
+ sms = SMS("your_api_key")
64
+
65
+ # Send a message
66
+ result = sms.send("995555123456", "Hello!", "GOSMS.GE")
67
+ print(result.message_id) # 12345
68
+ print(result.balance) # 99
69
+
70
+ # Check balance
71
+ balance = sms.balance()
72
+ print(balance.balance) # 500
73
+ ```
74
+
75
+ ## All Endpoints
76
+
77
+ ### Send SMS
78
+
79
+ ```python
80
+ result = sms.send("995555123456", "Hello!", "GOSMS.GE")
81
+ result = sms.send("995555123456", "Urgent!", "GOSMS.GE", urgent=True)
82
+ ```
83
+
84
+ ### Send Bulk SMS
85
+
86
+ ```python
87
+ result = sms.send_bulk(
88
+ "GOSMS.GE",
89
+ ["995555111111", "995555222222"],
90
+ "Hello everyone!",
91
+ )
92
+ print(result.total_count) # 2
93
+ print(result.success_count) # 2
94
+
95
+ for msg in result.messages:
96
+ print(f"{msg.to}: {msg.message_id}")
97
+ ```
98
+
99
+ ### Send OTP
100
+
101
+ ```python
102
+ result = sms.send_otp("995555123456")
103
+ print(result.hash) # "abc123hash" — save this for verification
104
+ ```
105
+
106
+ ### Verify OTP
107
+
108
+ ```python
109
+ result = sms.verify_otp("995555123456", "abc123hash", "1234")
110
+ print(result.verify) # True
111
+ ```
112
+
113
+ ### Check Message Status
114
+
115
+ ```python
116
+ result = sms.status(12345)
117
+ print(result.status) # "delivered"
118
+ ```
119
+
120
+ ### Check Balance
121
+
122
+ ```python
123
+ result = sms.balance()
124
+ print(result.balance) # 500
125
+ ```
126
+
127
+ ### Create Sender Name
128
+
129
+ ```python
130
+ result = sms.create_sender("MyBrand")
131
+ print(result.success) # True
132
+ ```
133
+
134
+ ## Async Usage
135
+
136
+ ```python
137
+ import asyncio
138
+ from gosms import AsyncSMS
139
+
140
+ async def main():
141
+ async with AsyncSMS("your_api_key") as sms:
142
+ result = await sms.send("995555123456", "Hello!", "GOSMS.GE")
143
+ print(result.message_id)
144
+
145
+ balance = await sms.balance()
146
+ print(balance.balance)
147
+
148
+ asyncio.run(main())
149
+ ```
150
+
151
+ All methods from the sync client are available as async equivalents with the same signatures.
152
+
153
+ ## Django Integration
154
+
155
+ Add to your `settings.py`:
156
+
157
+ ```python
158
+ GOSMS_SETTINGS = {
159
+ "api_key": "your_api_key",
160
+ "timeout": 30, # optional
161
+ "retries": 1, # optional
162
+ }
163
+ ```
164
+
165
+ Use anywhere in your project:
166
+
167
+ ```python
168
+ from gosms.django import get_sms_client
169
+
170
+ sms = get_sms_client()
171
+ sms.send("995555123456", "Hello!", "GOSMS.GE")
172
+ ```
173
+
174
+ The client is created lazily on first call and reused as a singleton.
175
+
176
+ ## Configuration
177
+
178
+ ```python
179
+ sms = SMS(
180
+ "your_api_key",
181
+ timeout=30, # request timeout in seconds (default: 30)
182
+ retries=3, # retry attempts on failure (default: 1)
183
+ debug=True, # enable debug logging (default: False)
184
+ )
185
+ ```
186
+
187
+ ## Error Handling
188
+
189
+ ```python
190
+ from gosms import SMS, GoSmsApiError, GoSmsErrorCode
191
+
192
+ sms = SMS("your_api_key")
193
+
194
+ try:
195
+ result = sms.send("995555123456", "Hello!", "GOSMS.GE")
196
+ except GoSmsApiError as e:
197
+ print(e.error_code) # 100
198
+ print(e.message) # "Invalid API key"
199
+
200
+ if e.error_code == GoSmsErrorCode.INVALID_API_KEY:
201
+ print("Check your API key")
202
+ ```
203
+
204
+ ### Error Codes
205
+
206
+ | Code | Constant | Description |
207
+ |------|----------|-------------|
208
+ | 100 | `INVALID_API_KEY` | Invalid API key |
209
+ | 101 | `INVALID_PHONE_NUMBER` | Invalid phone number |
210
+ | 102 | `INSUFFICIENT_BALANCE` | Insufficient balance |
211
+ | 103 | `SENDER_NOT_FOUND` | Sender name not found |
212
+ | 104 | `INVALID_TEXT` | Invalid message text |
213
+ | 105 | `TOO_MANY_RECIPIENTS` | Too many recipients (max 1000) |
214
+ | 106 | `INVALID_MESSAGE_ID` | Invalid message ID |
215
+ | 107 | `SENDER_EXISTS` | Sender name already exists |
216
+ | 108 | `INVALID_SENDER_NAME` | Invalid sender name |
217
+ | 109 | `INVALID_OTP_HASH` | Invalid OTP hash |
218
+ | 110 | `INVALID_OTP_CODE` | Invalid OTP code |
219
+ | 111 | `OTP_EXPIRED` | OTP expired |
220
+ | 112 | `OTP_ALREADY_VERIFIED` | OTP already verified |
221
+ | 113 | `RATE_LIMIT_EXCEEDED` | Rate limit exceeded |
222
+
223
+ ## Response Types
224
+
225
+ All methods return typed frozen dataclasses:
226
+
227
+ | Method | Return Type | Key Fields |
228
+ |--------|-------------|------------|
229
+ | `send()` | `SmsSendResponse` | `success`, `message_id`, `balance` |
230
+ | `send_bulk()` | `SendBulkSmsResponse` | `success`, `total_count`, `messages` |
231
+ | `send_otp()` | `OtpSendResponse` | `success`, `hash`, `balance` |
232
+ | `verify_otp()` | `OtpVerifyResponse` | `success`, `verify` |
233
+ | `status()` | `CheckStatusResponse` | `success`, `status`, `message_id` |
234
+ | `balance()` | `BalanceResponse` | `success`, `balance` |
235
+ | `create_sender()` | `SenderCreateResponse` | `success` |
236
+
237
+ ## Migration from v1.x
238
+
239
+ v2.0 is a complete rewrite. Key changes:
240
+
241
+ ```python
242
+ # v1.x (old)
243
+ from gosms import sms # module-level singleton
244
+ sms.send('995...', 'text', 'SENDER')
245
+
246
+ # v2.0 (new)
247
+ from gosms import SMS # explicit instantiation
248
+ sms = SMS('your_api_key')
249
+ sms.send('995...', 'text', 'SENDER')
250
+
251
+ # v1.x Django (old)
252
+ from gosms import sms # import-time side effect
253
+
254
+ # v2.0 Django (new)
255
+ from gosms.django import get_sms_client # lazy factory
256
+ sms = get_sms_client()
257
+ ```
258
+
259
+ Other changes:
260
+ - `GoSmsApiError` now extends `Exception` (was `BaseException`)
261
+ - Added `GoSmsErrorCode` constants for typed error handling
262
+ - Added `send_bulk()` and `create_sender()` endpoints
263
+ - Added async client (`AsyncSMS`) via `pip install gosms-python[async]`
264
+ - All responses are typed frozen dataclasses
265
+ - Removed `dev_mode` / `RequestMock` in favor of standard test mocking
266
+
267
+ ## License
268
+
269
+ MIT
270
+
271
+ ## Links
272
+
273
+ - Website: https://gosms.ge
274
+ - PyPI: https://pypi.org/project/gosms-python/
275
+ - GitHub: https://github.com/gosms-python/gosms-python-python
276
+ - Support: info@gosms.ge
@@ -0,0 +1,11 @@
1
+ gosms/__init__.py,sha256=MtJSvCMP5rqx_Yi-Fzw9bJ7cXtwHV9In_4OJXcHs5z0,347
2
+ gosms/_base.py,sha256=mRtlZ-iMxGouswbVwNZvJulbzLB5AYF0_34Lh7A7nMQ,2210
3
+ gosms/async_client.py,sha256=OSZEyGFcSAiEvLttQNM4WhzF8GwSqB2GM3iS6d5Hixs,5924
4
+ gosms/client.py,sha256=5u0F-wUpy2eItn8X7IYF-h7WHMoEV8omfTbvSqTpGTI,5175
5
+ gosms/django.py,sha256=R8__y8LbWTcv2zgtOVwRcUnzjqT5B_faj3u3yZpCwLs,2246
6
+ gosms/exceptions.py,sha256=fpUslXvA3oNFb4eixbJ5s5tJgM4ew_-7FymKBz0dYGs,951
7
+ gosms/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ gosms/types.py,sha256=9Lu13z3DRUqODb_gu0nFivRd7cX0JKCq6ZFk4oVP1r4,4580
9
+ gosms_python-1.0.3.dist-info/METADATA,sha256=odYFHrYQgRgmeiRR-u7s3LwMlkUU8Qkhkhd4sOJxyF8,7533
10
+ gosms_python-1.0.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ gosms_python-1.0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any