otp-sanly 1.0.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.
- otp_sanly-1.0.0/LICENSE +21 -0
- otp_sanly-1.0.0/PKG-INFO +84 -0
- otp_sanly-1.0.0/README.md +64 -0
- otp_sanly-1.0.0/pyproject.toml +29 -0
- otp_sanly-1.0.0/setup.cfg +4 -0
- otp_sanly-1.0.0/src/otp_sanly/__init__.py +202 -0
- otp_sanly-1.0.0/src/otp_sanly.egg-info/PKG-INFO +84 -0
- otp_sanly-1.0.0/src/otp_sanly.egg-info/SOURCES.txt +9 -0
- otp_sanly-1.0.0/src/otp_sanly.egg-info/dependency_links.txt +1 -0
- otp_sanly-1.0.0/src/otp_sanly.egg-info/top_level.txt +1 -0
- otp_sanly-1.0.0/tests/test_otp_sanly.py +82 -0
otp_sanly-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OTP Sanly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
otp_sanly-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: otp-sanly
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for OTP Sanly (otp.sanly.dev) — SMS & Email OTP for Turkmenistan
|
|
5
|
+
Author: OTP Sanly
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://otp.sanly.dev/developers
|
|
8
|
+
Project-URL: Repository, https://github.com/sanly-dev/otp-sanly-python
|
|
9
|
+
Project-URL: Issues, https://github.com/sanly-dev/otp-sanly-python/issues
|
|
10
|
+
Keywords: otp,sms,email,verification,turkmenistan,2fa,authentication
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# OTP Sanly — Python SDK
|
|
22
|
+
|
|
23
|
+
[Türkmençe](README.tk.md) | [Русский](README.ru.md) | English
|
|
24
|
+
|
|
25
|
+
Official SDK for [OTP Sanly](https://otp.sanly.dev) — SMS & Email OTP authentication for Turkmenistan.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install otp-sanly
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from otp_sanly import OtpSanly
|
|
37
|
+
|
|
38
|
+
sanly = OtpSanly(api_key="YOUR_API_KEY")
|
|
39
|
+
|
|
40
|
+
# Send an OTP
|
|
41
|
+
sent = sanly.send_otp(
|
|
42
|
+
phone="+99361234567", # Turkmenistan numbers only for SMS. Use `email=` instead for worldwide delivery.
|
|
43
|
+
project="My App",
|
|
44
|
+
lang="ru", # "tk" | "ru" | "en" — which language to send the OTP in
|
|
45
|
+
)
|
|
46
|
+
print(sent.otp_id, sent.message)
|
|
47
|
+
|
|
48
|
+
# Verify the code the user entered
|
|
49
|
+
verified = sanly.verify_otp(phone="+99361234567", code="123456")
|
|
50
|
+
if verified.success:
|
|
51
|
+
... # proceed — the code was correct
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Sandbox mode
|
|
55
|
+
|
|
56
|
+
Create a **sandbox** API key in your [dashboard](https://otp.sanly.dev/dashboard/api-keys) to test your integration
|
|
57
|
+
for free, without sending real SMS/email. The OTP code is returned directly in the response:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
sent = sanly.send_otp(phone="+99361234567")
|
|
61
|
+
print(sent.code) # only present for sandbox keys
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Error handling
|
|
65
|
+
|
|
66
|
+
Failed requests raise `OtpSanlyError`, which includes the HTTP status and the raw response body:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from otp_sanly import OtpSanly, OtpSanlyError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
sanly.send_otp(phone="+99361234567")
|
|
73
|
+
except OtpSanlyError as err:
|
|
74
|
+
print(err.status, err, err.body)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Full API reference
|
|
78
|
+
|
|
79
|
+
See [https://otp.sanly.dev/developers](https://otp.sanly.dev/developers) for the complete API reference, webhook docs,
|
|
80
|
+
and framework-specific examples in other languages.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# OTP Sanly — Python SDK
|
|
2
|
+
|
|
3
|
+
[Türkmençe](README.tk.md) | [Русский](README.ru.md) | English
|
|
4
|
+
|
|
5
|
+
Official SDK for [OTP Sanly](https://otp.sanly.dev) — SMS & Email OTP authentication for Turkmenistan.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install otp-sanly
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from otp_sanly import OtpSanly
|
|
17
|
+
|
|
18
|
+
sanly = OtpSanly(api_key="YOUR_API_KEY")
|
|
19
|
+
|
|
20
|
+
# Send an OTP
|
|
21
|
+
sent = sanly.send_otp(
|
|
22
|
+
phone="+99361234567", # Turkmenistan numbers only for SMS. Use `email=` instead for worldwide delivery.
|
|
23
|
+
project="My App",
|
|
24
|
+
lang="ru", # "tk" | "ru" | "en" — which language to send the OTP in
|
|
25
|
+
)
|
|
26
|
+
print(sent.otp_id, sent.message)
|
|
27
|
+
|
|
28
|
+
# Verify the code the user entered
|
|
29
|
+
verified = sanly.verify_otp(phone="+99361234567", code="123456")
|
|
30
|
+
if verified.success:
|
|
31
|
+
... # proceed — the code was correct
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Sandbox mode
|
|
35
|
+
|
|
36
|
+
Create a **sandbox** API key in your [dashboard](https://otp.sanly.dev/dashboard/api-keys) to test your integration
|
|
37
|
+
for free, without sending real SMS/email. The OTP code is returned directly in the response:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
sent = sanly.send_otp(phone="+99361234567")
|
|
41
|
+
print(sent.code) # only present for sandbox keys
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Error handling
|
|
45
|
+
|
|
46
|
+
Failed requests raise `OtpSanlyError`, which includes the HTTP status and the raw response body:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from otp_sanly import OtpSanly, OtpSanlyError
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
sanly.send_otp(phone="+99361234567")
|
|
53
|
+
except OtpSanlyError as err:
|
|
54
|
+
print(err.status, err, err.body)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Full API reference
|
|
58
|
+
|
|
59
|
+
See [https://otp.sanly.dev/developers](https://otp.sanly.dev/developers) for the complete API reference, webhook docs,
|
|
60
|
+
and framework-specific examples in other languages.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "otp-sanly"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for OTP Sanly (otp.sanly.dev) — SMS & Email OTP for Turkmenistan"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "OTP Sanly" }]
|
|
13
|
+
keywords = ["otp", "sms", "email", "verification", "turkmenistan", "2fa", "authentication"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://otp.sanly.dev/developers"
|
|
25
|
+
Repository = "https://github.com/sanly-dev/otp-sanly-python"
|
|
26
|
+
Issues = "https://github.com/sanly-dev/otp-sanly-python/issues"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OTP Sanly — Official Python SDK
|
|
3
|
+
https://otp.sanly.dev/developers
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
from urllib import request as _urllib_request
|
|
11
|
+
from urllib.error import HTTPError, URLError
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://otp.sanly.dev"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OtpSanlyError(Exception):
|
|
20
|
+
"""Raised when the OTP Sanly API returns an error response."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, status: int, body: Any = None):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.body = body
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SendOtpResult:
|
|
30
|
+
success: bool
|
|
31
|
+
otp_id: Optional[int] = None
|
|
32
|
+
target: Optional[str] = None
|
|
33
|
+
channel: Optional[str] = None
|
|
34
|
+
expires_at: Optional[str] = None
|
|
35
|
+
expires_at_tm: Optional[str] = None
|
|
36
|
+
expires_in: Optional[int] = None
|
|
37
|
+
remaining_otp: Optional[int] = None
|
|
38
|
+
attempt: Optional[int] = None
|
|
39
|
+
max_attempts: Optional[int] = None
|
|
40
|
+
auto_read: Optional[bool] = None
|
|
41
|
+
message: Optional[str] = None
|
|
42
|
+
error: Optional[str] = None
|
|
43
|
+
code: Optional[str] = None
|
|
44
|
+
sandbox: Optional[bool] = None
|
|
45
|
+
raw: dict = None # full raw response, for any fields not modeled above
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _from_dict(cls, d: dict) -> "SendOtpResult":
|
|
49
|
+
return cls(
|
|
50
|
+
success=bool(d.get("success")),
|
|
51
|
+
otp_id=d.get("otpId"),
|
|
52
|
+
target=d.get("target"),
|
|
53
|
+
channel=d.get("channel"),
|
|
54
|
+
expires_at=d.get("expiresAt"),
|
|
55
|
+
expires_at_tm=d.get("expiresAtTM"),
|
|
56
|
+
expires_in=d.get("expiresIn"),
|
|
57
|
+
remaining_otp=d.get("remainingOtp"),
|
|
58
|
+
attempt=d.get("attempt"),
|
|
59
|
+
max_attempts=d.get("maxAttempts"),
|
|
60
|
+
auto_read=d.get("autoRead"),
|
|
61
|
+
message=d.get("message"),
|
|
62
|
+
error=d.get("error"),
|
|
63
|
+
code=d.get("code"),
|
|
64
|
+
sandbox=d.get("sandbox"),
|
|
65
|
+
raw=d,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class VerifyOtpResult:
|
|
71
|
+
success: bool
|
|
72
|
+
message: Optional[str] = None
|
|
73
|
+
otp_id: Optional[int] = None
|
|
74
|
+
target: Optional[str] = None
|
|
75
|
+
verified_at: Optional[str] = None
|
|
76
|
+
verified_at_tm: Optional[str] = None
|
|
77
|
+
channel: Optional[str] = None
|
|
78
|
+
project: Optional[str] = None
|
|
79
|
+
error: Optional[str] = None
|
|
80
|
+
raw: dict = None
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def _from_dict(cls, d: dict) -> "VerifyOtpResult":
|
|
84
|
+
return cls(
|
|
85
|
+
success=bool(d.get("success")),
|
|
86
|
+
message=d.get("message"),
|
|
87
|
+
otp_id=d.get("otpId"),
|
|
88
|
+
target=d.get("target"),
|
|
89
|
+
verified_at=d.get("verifiedAt"),
|
|
90
|
+
verified_at_tm=d.get("verifiedAtTM"),
|
|
91
|
+
channel=d.get("channel"),
|
|
92
|
+
project=d.get("project"),
|
|
93
|
+
error=d.get("error"),
|
|
94
|
+
raw=d,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OtpSanly:
|
|
99
|
+
"""
|
|
100
|
+
Client for the OTP Sanly API (SMS & Email OTP for Turkmenistan).
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
>>> from otp_sanly import OtpSanly
|
|
104
|
+
>>> sanly = OtpSanly(api_key="otpsanly_xxx")
|
|
105
|
+
>>> result = sanly.send_otp(phone="+99361234567", lang="ru")
|
|
106
|
+
>>> sanly.verify_otp(phone="+99361234567", code="123456")
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0):
|
|
110
|
+
if not api_key:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"OtpSanly: api_key is required. Get one at https://otp.sanly.dev/dashboard/api-keys"
|
|
113
|
+
)
|
|
114
|
+
self.api_key = api_key
|
|
115
|
+
self.base_url = base_url.rstrip("/")
|
|
116
|
+
self.timeout = timeout
|
|
117
|
+
|
|
118
|
+
def _request(self, path: str, body: dict) -> dict:
|
|
119
|
+
payload = json.dumps({"apiKey": self.api_key, **body}).encode("utf-8")
|
|
120
|
+
req = _urllib_request.Request(
|
|
121
|
+
f"{self.base_url}{path}",
|
|
122
|
+
data=payload,
|
|
123
|
+
headers={"Content-Type": "application/json"},
|
|
124
|
+
method="POST",
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
with _urllib_request.urlopen(req, timeout=self.timeout) as resp:
|
|
128
|
+
status = resp.status
|
|
129
|
+
data = json.loads(resp.read().decode("utf-8") or "{}")
|
|
130
|
+
except HTTPError as e:
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(e.read().decode("utf-8") or "{}")
|
|
133
|
+
except Exception:
|
|
134
|
+
data = {}
|
|
135
|
+
raise OtpSanlyError(
|
|
136
|
+
data.get("error") or f"Request failed with status {e.code}", e.code, data
|
|
137
|
+
) from None
|
|
138
|
+
except URLError as e:
|
|
139
|
+
raise OtpSanlyError(f"Network error: {e.reason}", 0, None) from None
|
|
140
|
+
|
|
141
|
+
if not (200 <= status < 300) or data.get("success") is False:
|
|
142
|
+
raise OtpSanlyError(data.get("error") or f"Request failed with status {status}", status, data)
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
def send_otp(
|
|
146
|
+
self,
|
|
147
|
+
phone: Optional[str] = None,
|
|
148
|
+
email: Optional[str] = None,
|
|
149
|
+
project: Optional[str] = None,
|
|
150
|
+
lang: Optional[str] = None,
|
|
151
|
+
) -> SendOtpResult:
|
|
152
|
+
"""
|
|
153
|
+
Send an OTP via SMS (Turkmenistan numbers only) or email (worldwide).
|
|
154
|
+
Provide `phone` OR `email`, not both.
|
|
155
|
+
|
|
156
|
+
`lang` selects which of the 3 configured languages ("tk" | "ru" | "en")
|
|
157
|
+
the message template uses. Since this SDK calls the API server-to-server,
|
|
158
|
+
the Accept-Language header is unreliable — always pass `lang` explicitly
|
|
159
|
+
if you support multiple end-user languages. Defaults to "tk" if omitted.
|
|
160
|
+
"""
|
|
161
|
+
if not phone and not email:
|
|
162
|
+
raise ValueError("OtpSanly.send_otp: provide either 'phone' or 'email'")
|
|
163
|
+
body: dict = {}
|
|
164
|
+
if phone:
|
|
165
|
+
body["phone"] = phone
|
|
166
|
+
if email:
|
|
167
|
+
body["email"] = email
|
|
168
|
+
if project:
|
|
169
|
+
body["project"] = project
|
|
170
|
+
if lang:
|
|
171
|
+
body["lang"] = lang
|
|
172
|
+
data = self._request("/api/send-otp", body)
|
|
173
|
+
return SendOtpResult._from_dict(data)
|
|
174
|
+
|
|
175
|
+
def verify_otp(
|
|
176
|
+
self,
|
|
177
|
+
code: str,
|
|
178
|
+
phone: Optional[str] = None,
|
|
179
|
+
email: Optional[str] = None,
|
|
180
|
+
otp_id: Optional[int] = None,
|
|
181
|
+
) -> VerifyOtpResult:
|
|
182
|
+
"""
|
|
183
|
+
Verify a code the user entered. Provide `phone` OR `email` (same one used in send_otp).
|
|
184
|
+
`otp_id` is optional — pass the otpId returned by send_otp() for extra precision
|
|
185
|
+
if you have multiple pending OTPs for the same target.
|
|
186
|
+
"""
|
|
187
|
+
if not phone and not email:
|
|
188
|
+
raise ValueError("OtpSanly.verify_otp: provide either 'phone' or 'email'")
|
|
189
|
+
if not code:
|
|
190
|
+
raise ValueError("OtpSanly.verify_otp: 'code' is required")
|
|
191
|
+
body: dict = {"code": code}
|
|
192
|
+
if phone:
|
|
193
|
+
body["phone"] = phone
|
|
194
|
+
if email:
|
|
195
|
+
body["email"] = email
|
|
196
|
+
if otp_id is not None:
|
|
197
|
+
body["otpId"] = otp_id
|
|
198
|
+
data = self._request("/api/verify-otp", body)
|
|
199
|
+
return VerifyOtpResult._from_dict(data)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ["OtpSanly", "OtpSanlyError", "SendOtpResult", "VerifyOtpResult"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: otp-sanly
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for OTP Sanly (otp.sanly.dev) — SMS & Email OTP for Turkmenistan
|
|
5
|
+
Author: OTP Sanly
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://otp.sanly.dev/developers
|
|
8
|
+
Project-URL: Repository, https://github.com/sanly-dev/otp-sanly-python
|
|
9
|
+
Project-URL: Issues, https://github.com/sanly-dev/otp-sanly-python/issues
|
|
10
|
+
Keywords: otp,sms,email,verification,turkmenistan,2fa,authentication
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# OTP Sanly — Python SDK
|
|
22
|
+
|
|
23
|
+
[Türkmençe](README.tk.md) | [Русский](README.ru.md) | English
|
|
24
|
+
|
|
25
|
+
Official SDK for [OTP Sanly](https://otp.sanly.dev) — SMS & Email OTP authentication for Turkmenistan.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install otp-sanly
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from otp_sanly import OtpSanly
|
|
37
|
+
|
|
38
|
+
sanly = OtpSanly(api_key="YOUR_API_KEY")
|
|
39
|
+
|
|
40
|
+
# Send an OTP
|
|
41
|
+
sent = sanly.send_otp(
|
|
42
|
+
phone="+99361234567", # Turkmenistan numbers only for SMS. Use `email=` instead for worldwide delivery.
|
|
43
|
+
project="My App",
|
|
44
|
+
lang="ru", # "tk" | "ru" | "en" — which language to send the OTP in
|
|
45
|
+
)
|
|
46
|
+
print(sent.otp_id, sent.message)
|
|
47
|
+
|
|
48
|
+
# Verify the code the user entered
|
|
49
|
+
verified = sanly.verify_otp(phone="+99361234567", code="123456")
|
|
50
|
+
if verified.success:
|
|
51
|
+
... # proceed — the code was correct
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Sandbox mode
|
|
55
|
+
|
|
56
|
+
Create a **sandbox** API key in your [dashboard](https://otp.sanly.dev/dashboard/api-keys) to test your integration
|
|
57
|
+
for free, without sending real SMS/email. The OTP code is returned directly in the response:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
sent = sanly.send_otp(phone="+99361234567")
|
|
61
|
+
print(sent.code) # only present for sandbox keys
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Error handling
|
|
65
|
+
|
|
66
|
+
Failed requests raise `OtpSanlyError`, which includes the HTTP status and the raw response body:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from otp_sanly import OtpSanly, OtpSanlyError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
sanly.send_otp(phone="+99361234567")
|
|
73
|
+
except OtpSanlyError as err:
|
|
74
|
+
print(err.status, err, err.body)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Full API reference
|
|
78
|
+
|
|
79
|
+
See [https://otp.sanly.dev/developers](https://otp.sanly.dev/developers) for the complete API reference, webhook docs,
|
|
80
|
+
and framework-specific examples in other languages.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
otp_sanly
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import patch, MagicMock
|
|
4
|
+
|
|
5
|
+
from otp_sanly import OtpSanly, OtpSanlyError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _fake_response(status: int, body: dict):
|
|
9
|
+
m = MagicMock()
|
|
10
|
+
m.status = status
|
|
11
|
+
m.read.return_value = json.dumps(body).encode("utf-8")
|
|
12
|
+
m.__enter__.return_value = m
|
|
13
|
+
m.__exit__.return_value = False
|
|
14
|
+
return m
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestOtpSanlyInit(unittest.TestCase):
|
|
18
|
+
def test_requires_api_key(self):
|
|
19
|
+
with self.assertRaises(ValueError):
|
|
20
|
+
OtpSanly(api_key="")
|
|
21
|
+
|
|
22
|
+
def test_strips_trailing_slash(self):
|
|
23
|
+
c = OtpSanly(api_key="k", base_url="https://example.com/")
|
|
24
|
+
self.assertEqual(c.base_url, "https://example.com")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestSendOtp(unittest.TestCase):
|
|
28
|
+
def setUp(self):
|
|
29
|
+
self.client = OtpSanly(api_key="otpsanly_test")
|
|
30
|
+
|
|
31
|
+
def test_requires_phone_or_email(self):
|
|
32
|
+
with self.assertRaises(ValueError):
|
|
33
|
+
self.client.send_otp()
|
|
34
|
+
|
|
35
|
+
@patch("otp_sanly._urllib_request.urlopen")
|
|
36
|
+
def test_send_otp_success(self, mock_urlopen):
|
|
37
|
+
mock_urlopen.return_value = _fake_response(
|
|
38
|
+
200,
|
|
39
|
+
{
|
|
40
|
+
"success": True,
|
|
41
|
+
"otpId": 42,
|
|
42
|
+
"target": "+99361234567",
|
|
43
|
+
"channel": "sms",
|
|
44
|
+
"expiresIn": 300,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
result = self.client.send_otp(phone="+99361234567", lang="ru")
|
|
48
|
+
self.assertTrue(result.success)
|
|
49
|
+
self.assertEqual(result.otp_id, 42)
|
|
50
|
+
self.assertEqual(result.channel, "sms")
|
|
51
|
+
|
|
52
|
+
@patch("otp_sanly._urllib_request.urlopen")
|
|
53
|
+
def test_send_otp_raises_on_error(self, mock_urlopen):
|
|
54
|
+
mock_urlopen.return_value = _fake_response(200, {"success": False, "error": "Limit exceeded"})
|
|
55
|
+
with self.assertRaises(OtpSanlyError):
|
|
56
|
+
self.client.send_otp(email="a@b.com")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestVerifyOtp(unittest.TestCase):
|
|
60
|
+
def setUp(self):
|
|
61
|
+
self.client = OtpSanly(api_key="otpsanly_test")
|
|
62
|
+
|
|
63
|
+
def test_requires_target(self):
|
|
64
|
+
with self.assertRaises(ValueError):
|
|
65
|
+
self.client.verify_otp(code="123456")
|
|
66
|
+
|
|
67
|
+
def test_requires_code(self):
|
|
68
|
+
with self.assertRaises(ValueError):
|
|
69
|
+
self.client.verify_otp(code="", phone="+99361234567")
|
|
70
|
+
|
|
71
|
+
@patch("otp_sanly._urllib_request.urlopen")
|
|
72
|
+
def test_verify_otp_success(self, mock_urlopen):
|
|
73
|
+
mock_urlopen.return_value = _fake_response(
|
|
74
|
+
200, {"success": True, "otpId": 42, "target": "+99361234567", "verifiedAt": "2026-01-01T00:00:00Z"}
|
|
75
|
+
)
|
|
76
|
+
result = self.client.verify_otp(phone="+99361234567", code="123456")
|
|
77
|
+
self.assertTrue(result.success)
|
|
78
|
+
self.assertEqual(result.otp_id, 42)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|