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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/otp_sanly/__init__.py
5
+ src/otp_sanly.egg-info/PKG-INFO
6
+ src/otp_sanly.egg-info/SOURCES.txt
7
+ src/otp_sanly.egg-info/dependency_links.txt
8
+ src/otp_sanly.egg-info/top_level.txt
9
+ tests/test_otp_sanly.py
@@ -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()