virtualsmslabs 1.0.0__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.
virtualsms/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ from virtualsms.client import VirtualSMSClient
2
+ from virtualsms.constants import ActivationStatus, PoolProvider
3
+ from virtualsms.exceptions import (
4
+ ActivationException,
5
+ AuthenticationException,
6
+ InsufficientBalanceException,
7
+ NoNumbersException,
8
+ RateLimitException,
9
+ ServerException,
10
+ ValidationException,
11
+ VirtualSMSException,
12
+ )
13
+ from virtualsms.response import BalanceResponse, NumberResponse, StatusResponse
14
+ from virtualsms.transport import Response, Transport, UrllibTransport
15
+
16
+ __all__ = [
17
+ "VirtualSMSClient",
18
+ "ActivationStatus",
19
+ "PoolProvider",
20
+ "VirtualSMSException",
21
+ "AuthenticationException",
22
+ "InsufficientBalanceException",
23
+ "NoNumbersException",
24
+ "ValidationException",
25
+ "ActivationException",
26
+ "RateLimitException",
27
+ "ServerException",
28
+ "BalanceResponse",
29
+ "NumberResponse",
30
+ "StatusResponse",
31
+ "Transport",
32
+ "UrllibTransport",
33
+ "Response",
34
+ ]
virtualsms/client.py ADDED
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import platform
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional, Type
8
+ from urllib.parse import urlencode
9
+
10
+ from virtualsms.constants import PoolProvider
11
+ from virtualsms.exceptions import VirtualSMSException
12
+ from virtualsms.parser import TextResponseParser
13
+ from virtualsms.response import BalanceResponse, NumberResponse, StatusResponse
14
+ from virtualsms.transport import Response, Transport, UrllibTransport
15
+
16
+
17
+ class VirtualSMSClient:
18
+ VERSION = "1.0.0"
19
+ SDK_LANGUAGE = "python"
20
+
21
+ def __init__(
22
+ self,
23
+ api_key: str,
24
+ base_url: str = "https://api.virtualsms.de",
25
+ transport: Optional[Transport] = None,
26
+ ):
27
+ self._api_key = api_key
28
+ self._base_url = base_url.rstrip("/")
29
+ self._transport = transport if transport is not None else UrllibTransport()
30
+ self._parser = TextResponseParser()
31
+ self._machine_id = hashlib.sha256(
32
+ (platform.platform() + sys.implementation.name).encode("utf-8")
33
+ ).hexdigest()[:32]
34
+
35
+ def get_balance(self) -> BalanceResponse:
36
+ response = self._call_api("getBalance")
37
+ parsed = self._parser.parse(response.body)
38
+ self._ensure_success(parsed, response)
39
+ return BalanceResponse(balance=parsed.data["balance"])
40
+
41
+ def get_countries(self, pool_provider: Optional[str] = None) -> Dict[str, Any]:
42
+ response = self._call_api("getCountries", {"poolProvider": pool_provider})
43
+ parsed = self._parser.parse(response.body)
44
+ self._ensure_success(parsed, response)
45
+ return parsed.data["decoded"]
46
+
47
+ def get_services_list(
48
+ self,
49
+ country: Optional[int] = None,
50
+ lang: Optional[str] = None,
51
+ ) -> Dict[str, Any]:
52
+ response = self._call_api(
53
+ "getServicesList",
54
+ {"country": country, "lang": lang},
55
+ )
56
+ parsed = self._parser.parse(response.body)
57
+ self._ensure_success(parsed, response)
58
+ return parsed.data["decoded"]
59
+
60
+ def get_operators(
61
+ self,
62
+ country: int,
63
+ pool_provider: Optional[str] = None,
64
+ ) -> List[str]:
65
+ response = self._call_api(
66
+ "getOperators",
67
+ {"country": country, "poolProvider": pool_provider},
68
+ )
69
+ parsed = self._parser.parse(response.body)
70
+ self._ensure_success(parsed, response)
71
+ decoded = parsed.data["decoded"]
72
+ return decoded.get("countryOperators", decoded)
73
+
74
+ def get_prices(
75
+ self,
76
+ service: Optional[str] = None,
77
+ country: Optional[int] = None,
78
+ pool_provider: Optional[str] = None,
79
+ ) -> Dict[str, Any]:
80
+ response = self._call_api(
81
+ "getPrices",
82
+ {"service": service, "country": country, "poolProvider": pool_provider},
83
+ )
84
+ parsed = self._parser.parse(response.body)
85
+ self._ensure_success(parsed, response)
86
+ return parsed.data["decoded"]
87
+
88
+ def get_prices_extended(
89
+ self,
90
+ service: Optional[str] = None,
91
+ country: Optional[int] = None,
92
+ free_price: Optional[bool] = None,
93
+ pool_provider: Optional[str] = None,
94
+ ) -> Dict[str, Any]:
95
+ response = self._call_api(
96
+ "getPricesExtended",
97
+ {
98
+ "service": service,
99
+ "country": country,
100
+ "freePrice": "true" if free_price is True else ("false" if free_price is False else None),
101
+ "poolProvider": pool_provider,
102
+ },
103
+ )
104
+ parsed = self._parser.parse(response.body)
105
+ self._ensure_success(parsed, response)
106
+ return parsed.data["decoded"]
107
+
108
+ def get_prices_verification(
109
+ self,
110
+ service: Optional[str] = None,
111
+ pool_provider: Optional[str] = None,
112
+ ) -> Dict[str, Any]:
113
+ response = self._call_api(
114
+ "getPricesVerification",
115
+ {"service": service, "poolProvider": pool_provider},
116
+ )
117
+ parsed = self._parser.parse(response.body)
118
+ self._ensure_success(parsed, response)
119
+ return parsed.data["decoded"]
120
+
121
+ def get_numbers_status(
122
+ self,
123
+ country: int,
124
+ operator: Optional[str] = None,
125
+ pool_provider: Optional[str] = None,
126
+ ) -> Dict[str, int]:
127
+ response = self._call_api(
128
+ "getNumbersStatus",
129
+ {"country": country, "operator": operator, "poolProvider": pool_provider},
130
+ )
131
+ parsed = self._parser.parse(response.body)
132
+ self._ensure_success(parsed, response)
133
+ return parsed.data["decoded"]
134
+
135
+ def get_number(
136
+ self,
137
+ service: str,
138
+ country: int,
139
+ max_price: Optional[float] = None,
140
+ operator: Optional[str] = None,
141
+ phone_exception: Optional[str] = None,
142
+ forward: Optional[bool] = None,
143
+ activation_type: Optional[int] = None,
144
+ language: Optional[str] = None,
145
+ use_cashback: Optional[bool] = None,
146
+ user_id: Optional[str] = None,
147
+ ref: Optional[str] = None,
148
+ pool_provider: Optional[str] = None,
149
+ ) -> NumberResponse:
150
+ response = self._call_api(
151
+ "getNumber",
152
+ {
153
+ "service": service,
154
+ "country": country,
155
+ "maxPrice": max_price,
156
+ "operator": operator,
157
+ "phoneException": phone_exception,
158
+ "forward": "1" if forward is True else ("0" if forward is False else None),
159
+ "activationType": activation_type,
160
+ "language": language,
161
+ "useCashBack": "true" if use_cashback is True else ("false" if use_cashback is False else None),
162
+ "userId": user_id,
163
+ "ref": ref,
164
+ "poolProvider": pool_provider,
165
+ },
166
+ )
167
+ parsed = self._parser.parse(response.body)
168
+ self._ensure_success(parsed, response)
169
+ return NumberResponse(
170
+ activation_id=parsed.data["activation_id"],
171
+ phone_number=parsed.data["phone_number"],
172
+ )
173
+
174
+ def get_number_v2(
175
+ self,
176
+ service: str,
177
+ country: int,
178
+ max_price: Optional[float] = None,
179
+ operator: Optional[str] = None,
180
+ phone_exception: Optional[str] = None,
181
+ forward: Optional[bool] = None,
182
+ activation_type: Optional[int] = None,
183
+ language: Optional[str] = None,
184
+ use_cashback: Optional[bool] = None,
185
+ user_id: Optional[str] = None,
186
+ ref: Optional[str] = None,
187
+ order_id: Optional[str] = None,
188
+ pool_provider: Optional[str] = None,
189
+ ) -> Dict[str, Any]:
190
+ response = self._call_api(
191
+ "getNumberV2",
192
+ {
193
+ "service": service,
194
+ "country": country,
195
+ "maxPrice": max_price,
196
+ "operator": operator,
197
+ "phoneException": phone_exception,
198
+ "forward": "1" if forward is True else ("0" if forward is False else None),
199
+ "activationType": activation_type,
200
+ "language": language,
201
+ "useCashBack": "true" if use_cashback is True else ("false" if use_cashback is False else None),
202
+ "userId": user_id,
203
+ "ref": ref,
204
+ "orderId": order_id,
205
+ "poolProvider": pool_provider,
206
+ },
207
+ )
208
+ parsed = self._parser.parse(response.body)
209
+ self._ensure_success(parsed, response)
210
+ return parsed.data["decoded"]
211
+
212
+ def set_status(self, id: int, status: int) -> str:
213
+ response = self._call_api("setStatus", {"id": id, "status": status})
214
+ parsed = self._parser.parse(response.body)
215
+ self._ensure_success(parsed, response)
216
+ return parsed.data["status"]
217
+
218
+ def get_status(self, id: int) -> StatusResponse:
219
+ response = self._call_api("getStatus", {"id": id})
220
+ parsed = self._parser.parse(response.body)
221
+ self._ensure_success(parsed, response)
222
+ return StatusResponse(
223
+ status=parsed.data["status"],
224
+ code=parsed.data.get("code"),
225
+ sms_text=parsed.data.get("sms_text"),
226
+ )
227
+
228
+ def get_status_v2(self, id: int) -> Dict[str, Any]:
229
+ response = self._call_api("getStatusV2", {"id": id})
230
+ parsed = self._parser.parse(response.body)
231
+ self._ensure_success(parsed, response)
232
+ return parsed.data["decoded"]
233
+
234
+ def get_active_activations(self) -> List[Dict[str, Any]]:
235
+ response = self._call_api("getActiveActivations")
236
+ parsed = self._parser.parse(response.body)
237
+ self._ensure_success(parsed, response)
238
+ decoded = parsed.data["decoded"]
239
+ return decoded.get("activeActivations", [])
240
+
241
+ def check_extra_activation(self, id: int) -> Dict[str, Any]:
242
+ response = self._call_api("checkExtraActivation", {"id": id})
243
+ parsed = self._parser.parse(response.body)
244
+ self._ensure_success(parsed, response)
245
+ return parsed.data["decoded"]
246
+
247
+ def get_extra_activation(self, id: int) -> NumberResponse:
248
+ response = self._call_api("getExtraActivation", {"id": id})
249
+ parsed = self._parser.parse(response.body)
250
+ self._ensure_success(parsed, response)
251
+ return NumberResponse(
252
+ activation_id=parsed.data["activation_id"],
253
+ phone_number=parsed.data["phone_number"],
254
+ )
255
+
256
+ def get_top_countries_by_service(self, service: str) -> List[Dict[str, Any]]:
257
+ response = self._call_api("getListOfTopCountriesByService", {"service": service})
258
+ parsed = self._parser.parse(response.body)
259
+ self._ensure_success(parsed, response)
260
+ return parsed.data["decoded"]
261
+
262
+ def get_notifications(self) -> Dict[str, Any]:
263
+ response = self._call_api("getNotifications")
264
+ parsed = self._parser.parse(response.body)
265
+ self._ensure_success(parsed, response)
266
+ return parsed.data["decoded"]
267
+
268
+ def _call_api(self, action: str, params: Optional[Dict[str, Any]] = None) -> Response:
269
+ query_params: Dict[str, Any] = {"api_key": self._api_key, "action": action}
270
+ if params:
271
+ for key, value in params.items():
272
+ if value is not None:
273
+ query_params[key] = value
274
+
275
+ url = self._base_url + "/stubs/handler_api?" + urlencode(query_params)
276
+ headers = self._build_headers()
277
+ return self._transport.send(url, headers)
278
+
279
+ def _build_headers(self) -> Dict[str, str]:
280
+ return {
281
+ "X-SDK-Version": self.VERSION,
282
+ "X-SDK-Language": self.SDK_LANGUAGE,
283
+ "X-SDK-Machine-Id": self._machine_id,
284
+ "X-SDK-Timestamp": datetime.now(timezone.utc).isoformat(),
285
+ }
286
+
287
+ def _ensure_success(self, parsed, response: Response) -> None:
288
+ if not parsed.is_success:
289
+ retry_after = None
290
+ if response.headers.get("Retry-After"):
291
+ retry_after = int(response.headers["Retry-After"])
292
+ raise VirtualSMSException.from_error_code(
293
+ error_code=parsed.error_code or "UNKNOWN",
294
+ http_status=response.status_code,
295
+ retry_after=retry_after,
296
+ )
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ActivationStatus:
5
+ READY = 1
6
+ RETRY = 3
7
+ COMPLETE = 6
8
+ CANCEL = 8
9
+
10
+
11
+ class PoolProvider:
12
+ ALPHA = "alpha"
13
+ PRIME = "prime"
14
+ GAMMA = "gamma"
15
+ ZETA = "zeta"
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class VirtualSMSException(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ error_code: str,
11
+ http_status: int,
12
+ retry_after: Optional[int] = None,
13
+ ):
14
+ super().__init__(message)
15
+ self.error_code = error_code
16
+ self.http_status = http_status
17
+ self.retry_after = retry_after
18
+
19
+ @staticmethod
20
+ def from_error_code(
21
+ error_code: str,
22
+ http_status: int,
23
+ retry_after: Optional[int] = None,
24
+ ) -> "VirtualSMSException":
25
+ message = VirtualSMSException._message_for_code(error_code)
26
+
27
+ if error_code in ("BAD_KEY", "BANNED", "PURCHASE_RESTRICTED", "SERVICE_RESTRICTED"):
28
+ return AuthenticationException(message, error_code, http_status)
29
+
30
+ if error_code == "NO_BALANCE":
31
+ return InsufficientBalanceException(message, error_code, http_status)
32
+
33
+ if error_code == "NO_NUMBERS":
34
+ return NoNumbersException(message, error_code, http_status)
35
+
36
+ if error_code in (
37
+ "WRONG_SERVICE",
38
+ "WRONG_COUNTRY",
39
+ "BAD_ACTION",
40
+ "BAD_STATUS",
41
+ "NO_PRICES",
42
+ "INVALID_PROVIDER",
43
+ "WRONG_EXCEPTION_PHONE",
44
+ "WRONG_SECURITY",
45
+ ):
46
+ return ValidationException(message, error_code, http_status)
47
+
48
+ if error_code in (
49
+ "NO_ACTIVATION",
50
+ "WRONG_ACTIVATION_ID",
51
+ "EARLY_CANCEL_DENIED",
52
+ "RENEW_ACTIVATION_NOT_AVAILABLE",
53
+ "NEW_ACTIVATION_IMPOSSIBLE",
54
+ "SIM_OFFLINE",
55
+ ):
56
+ return ActivationException(message, error_code, http_status)
57
+
58
+ if error_code == "CONCURRENT_LIMIT":
59
+ return RateLimitException(message, error_code, http_status, retry_after or 0)
60
+
61
+ return ServerException(message, error_code, http_status)
62
+
63
+ @staticmethod
64
+ def _message_for_code(error_code: str) -> str:
65
+ messages = {
66
+ "BAD_KEY": "Invalid or missing API key.",
67
+ "BANNED": "Account is banned or IP is blocked.",
68
+ "PURCHASE_RESTRICTED": "User is restricted from purchasing numbers.",
69
+ "SERVICE_RESTRICTED": "This service is restricted for your account.",
70
+ "NO_BALANCE": "Insufficient balance to complete the operation.",
71
+ "NO_NUMBERS": "No phone numbers available for the requested service and country.",
72
+ "WRONG_SERVICE": "Invalid or missing service code.",
73
+ "WRONG_COUNTRY": "Invalid or missing country ID.",
74
+ "BAD_ACTION": "Invalid or unknown action.",
75
+ "BAD_STATUS": "Invalid status code provided to setStatus.",
76
+ "NO_PRICES": "No pricing data available for the requested parameters.",
77
+ "INVALID_PROVIDER": "The specified pool provider was not found or is disabled.",
78
+ "WRONG_EXCEPTION_PHONE": "Invalid phone exception parameter.",
79
+ "WRONG_SECURITY": "Security validation failed.",
80
+ "NO_ACTIVATION": "Activation not found or does not belong to this account.",
81
+ "WRONG_ACTIVATION_ID": "Invalid activation ID.",
82
+ "EARLY_CANCEL_DENIED": "Cannot cancel within the first 5 minutes of activation.",
83
+ "RENEW_ACTIVATION_NOT_AVAILABLE": "This number is not available for reactivation.",
84
+ "NEW_ACTIVATION_IMPOSSIBLE": "Cannot create an additional activation on this number.",
85
+ "SIM_OFFLINE": "The SIM card is currently offline.",
86
+ "CONCURRENT_LIMIT": "Too many concurrent activations.",
87
+ "ERROR_SQL": "An internal server error occurred.",
88
+ "NO_METRICS": "Not enough data available for this service.",
89
+ "NO_ACTIVATIONS": "No active activations found.",
90
+ }
91
+ return messages.get(error_code, "An unexpected error occurred.")
92
+
93
+
94
+ class AuthenticationException(VirtualSMSException):
95
+ pass
96
+
97
+
98
+ class InsufficientBalanceException(VirtualSMSException):
99
+ pass
100
+
101
+
102
+ class NoNumbersException(VirtualSMSException):
103
+ pass
104
+
105
+
106
+ class ValidationException(VirtualSMSException):
107
+ pass
108
+
109
+
110
+ class ActivationException(VirtualSMSException):
111
+ pass
112
+
113
+
114
+ class RateLimitException(VirtualSMSException):
115
+ def __init__(
116
+ self,
117
+ message: str,
118
+ error_code: str,
119
+ http_status: int,
120
+ retry_after: int = 0,
121
+ ):
122
+ super().__init__(message, error_code, http_status, retry_after=retry_after)
123
+
124
+
125
+ class ServerException(VirtualSMSException):
126
+ pass
virtualsms/parser.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ _ERROR_CODES: List[str] = [
9
+ "BAD_KEY",
10
+ "BANNED",
11
+ "NO_BALANCE",
12
+ "NO_NUMBERS",
13
+ "WRONG_SERVICE",
14
+ "WRONG_COUNTRY",
15
+ "NO_ACTIVATION",
16
+ "BAD_ACTION",
17
+ "BAD_STATUS",
18
+ "EARLY_CANCEL_DENIED",
19
+ "WRONG_ACTIVATION_ID",
20
+ "RENEW_ACTIVATION_NOT_AVAILABLE",
21
+ "PURCHASE_RESTRICTED",
22
+ "SERVICE_RESTRICTED",
23
+ "CONCURRENT_LIMIT",
24
+ "NO_PRICES",
25
+ "INVALID_PROVIDER",
26
+ "ERROR_SQL",
27
+ "NO_METRICS",
28
+ "NO_ACTIVATIONS",
29
+ "NEW_ACTIVATION_IMPOSSIBLE",
30
+ "SIM_OFFLINE",
31
+ "WRONG_EXCEPTION_PHONE",
32
+ "WRONG_SECURITY",
33
+ ]
34
+
35
+ _STATUS_TYPES = ["STATUS_WAIT_CODE", "STATUS_WAIT_RETRY", "STATUS_CANCEL"]
36
+ _SET_STATUS_TYPES = ["ACCESS_READY", "ACCESS_RETRY_GET", "ACCESS_ACTIVATION", "ACCESS_CANCEL"]
37
+
38
+
39
+ @dataclass
40
+ class ParsedResponse:
41
+ is_success: bool = False
42
+ type: str = ""
43
+ data: Dict[str, Any] = field(default_factory=dict)
44
+ error_code: Optional[str] = None
45
+ error_message: Optional[str] = None
46
+
47
+
48
+ class TextResponseParser:
49
+ def parse(self, body: str) -> ParsedResponse:
50
+ body = body.strip()
51
+
52
+ if body == "":
53
+ return ParsedResponse(
54
+ is_success=False,
55
+ error_code="UNKNOWN",
56
+ error_message="Empty response body.",
57
+ )
58
+
59
+ try:
60
+ decoded = json.loads(body)
61
+ if isinstance(decoded, (dict, list)):
62
+ return self._parse_json(decoded, body)
63
+ except (json.JSONDecodeError, ValueError):
64
+ pass
65
+
66
+ if body.startswith("ACCESS_BALANCE:"):
67
+ balance = float(body[len("ACCESS_BALANCE:") :])
68
+ return ParsedResponse(
69
+ is_success=True,
70
+ type="balance",
71
+ data={"balance": balance},
72
+ )
73
+
74
+ if body.startswith("ACCESS_NUMBER:"):
75
+ parts = body.split(":", 2)
76
+ if len(parts) == 3:
77
+ return ParsedResponse(
78
+ is_success=True,
79
+ type="number",
80
+ data={
81
+ "activation_id": int(parts[1]),
82
+ "phone_number": parts[2],
83
+ },
84
+ )
85
+
86
+ if body.startswith("STATUS_OK:"):
87
+ code = body[len("STATUS_OK:") :]
88
+ return ParsedResponse(
89
+ is_success=True,
90
+ type="status",
91
+ data={
92
+ "status": "STATUS_OK",
93
+ "code": code if code != "" else None,
94
+ "sms_text": None,
95
+ },
96
+ )
97
+
98
+ if body == "STATUS_OK":
99
+ return ParsedResponse(
100
+ is_success=True,
101
+ type="status",
102
+ data={"status": "STATUS_OK", "code": None, "sms_text": None},
103
+ )
104
+
105
+ if body.startswith("FULL_SMS:"):
106
+ sms_text = body[len("FULL_SMS:") :]
107
+ return ParsedResponse(
108
+ is_success=True,
109
+ type="status",
110
+ data={"status": "FULL_SMS", "code": None, "sms_text": sms_text},
111
+ )
112
+
113
+ for status in _STATUS_TYPES:
114
+ if body == status:
115
+ return ParsedResponse(
116
+ is_success=True,
117
+ type="status",
118
+ data={"status": status, "code": None, "sms_text": None},
119
+ )
120
+
121
+ for status in _SET_STATUS_TYPES:
122
+ if body == status:
123
+ return ParsedResponse(
124
+ is_success=True,
125
+ type="set_status",
126
+ data={"status": status},
127
+ )
128
+
129
+ error_code = self._detect_error_code(body)
130
+ if error_code is not None:
131
+ return ParsedResponse(is_success=False, error_code=error_code)
132
+
133
+ return ParsedResponse(
134
+ is_success=False,
135
+ error_code="UNKNOWN",
136
+ error_message=body,
137
+ )
138
+
139
+ def _parse_json(self, decoded: Any, raw: str) -> ParsedResponse:
140
+ if isinstance(decoded, dict):
141
+ if decoded.get("status") == "error":
142
+ error_code = decoded.get("errorCode", "UNKNOWN")
143
+ error_message = decoded.get("error", "Unknown error.")
144
+ return ParsedResponse(
145
+ is_success=False,
146
+ error_code=error_code,
147
+ error_message=error_message,
148
+ )
149
+
150
+ return ParsedResponse(
151
+ is_success=True,
152
+ type="json",
153
+ data={"raw": raw, "decoded": decoded},
154
+ )
155
+
156
+ def _detect_error_code(self, body: str) -> Optional[str]:
157
+ for code in _ERROR_CODES:
158
+ if body == code:
159
+ return code
160
+ return None
virtualsms/response.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class BalanceResponse:
9
+ balance: float
10
+
11
+
12
+ @dataclass
13
+ class NumberResponse:
14
+ activation_id: int
15
+ phone_number: str
16
+
17
+
18
+ @dataclass
19
+ class StatusResponse:
20
+ status: str
21
+ code: Optional[str] = None
22
+ sms_text: Optional[str] = None
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.error
4
+ import urllib.request
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, Optional
8
+
9
+
10
+ @dataclass
11
+ class Response:
12
+ status_code: int
13
+ body: str
14
+ headers: Dict[str, str] = field(default_factory=dict)
15
+
16
+
17
+ class Transport(ABC):
18
+ @abstractmethod
19
+ def send(self, url: str, headers: dict) -> Response:
20
+ ...
21
+
22
+
23
+ class UrllibTransport(Transport):
24
+ def __init__(self, timeout: int = 30):
25
+ self.timeout = timeout
26
+
27
+ def send(self, url: str, headers: dict) -> Response:
28
+ request = urllib.request.Request(url, method="GET")
29
+ for key, value in headers.items():
30
+ request.add_header(key, value)
31
+
32
+ try:
33
+ with urllib.request.urlopen(request, timeout=self.timeout) as resp:
34
+ body = resp.read().decode("utf-8")
35
+ status_code = resp.getcode()
36
+ response_headers = {k: v for k, v in resp.getheaders()}
37
+ return Response(status_code, body, response_headers)
38
+ except urllib.error.HTTPError as e:
39
+ body = e.read().decode("utf-8") if e.fp else ""
40
+ response_headers = {k: v for k, v in e.headers.items()} if e.headers else {}
41
+ return Response(e.code, body, response_headers)
@@ -0,0 +1,352 @@
1
+ Metadata-Version: 2.4
2
+ Name: virtualsmslabs
3
+ Version: 1.0.0
4
+ Summary: Python SDK for the VirtualSMS Consumer API — SMS verification, phone number rental, and activation management.
5
+ Project-URL: Homepage, https://virtualsms.de
6
+ Project-URL: Documentation, https://virtualsms.de/docs/sdk
7
+ Project-URL: Repository, https://github.com/VirtualSMSLabs/python-sdk
8
+ Project-URL: Issues, https://github.com/VirtualSMSLabs/python-sdk/issues
9
+ Author-email: VirtualSMS Labs <contact@virtualsms.de>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: otp,phone,sms,sms-activate,verification,virtual,virtualsms
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Communications :: Telephony
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+
27
+ # VirtualSMS Python SDK
28
+
29
+ Python SDK for the VirtualSMS Consumer API — SMS verification, phone number rental, and activation management.
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/virtualsms)](https://pypi.org/project/virtualsms/)
32
+ [![License](https://img.shields.io/github/license/VirtualSMSLabs/python-sdk)](LICENSE)
33
+ [![Python](https://img.shields.io/pypi/pyversions/virtualsms)](https://www.python.org/)
34
+
35
+ ## Requirements
36
+
37
+ - Python 3.9 or higher
38
+ - No external dependencies (uses standard library only)
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install virtualsms
44
+ ```
45
+
46
+ ## Quick Start
47
+
48
+ ```python
49
+ from virtualsms import VirtualSMSClient, ActivationStatus
50
+
51
+ client = VirtualSMSClient("YOUR_API_KEY", "https://api.virtualsms.de")
52
+
53
+ # Check balance
54
+ balance = client.get_balance()
55
+ print(f"Balance: ${balance.balance:.2f}")
56
+
57
+ # Order a WhatsApp number in Brazil
58
+ number = client.get_number("wa", 73, max_price=2.00)
59
+ print(f"Number: {number.phone_number} (ID: {number.activation_id})")
60
+
61
+ # Set status to ready (SMS sent)
62
+ client.set_status(number.activation_id, ActivationStatus.READY)
63
+
64
+ # Poll for SMS code
65
+ status = client.get_status(number.activation_id)
66
+ if status.code is not None:
67
+ print(f"SMS code: {status.code}")
68
+ # Complete the activation
69
+ client.set_status(number.activation_id, ActivationStatus.COMPLETE)
70
+ ```
71
+
72
+ ## API Reference
73
+
74
+ ### Client Constructor
75
+
76
+ ```python
77
+ client = VirtualSMSClient(
78
+ api_key="YOUR_API_KEY", # Your API key (required)
79
+ base_url="https://api.virtualsms.de", # API base URL (optional)
80
+ transport=None, # Optional custom transport
81
+ )
82
+ ```
83
+
84
+ ### Methods
85
+
86
+ #### Account
87
+
88
+ ##### `get_balance() -> BalanceResponse`
89
+
90
+ Returns the current account balance.
91
+
92
+ ```python
93
+ balance = client.get_balance()
94
+ print(balance.balance) # 10.50
95
+ ```
96
+
97
+ #### Information & Pricing
98
+
99
+ ##### `get_countries(pool_provider=None) -> dict`
100
+
101
+ Returns all available countries.
102
+
103
+ ##### `get_services_list(country=None, lang=None) -> dict`
104
+
105
+ Returns available services for a country.
106
+
107
+ ##### `get_operators(country, pool_provider=None) -> list`
108
+
109
+ Returns available mobile operators for a country.
110
+
111
+ ##### `get_prices(service=None, country=None, pool_provider=None) -> dict`
112
+
113
+ Returns pricing data organized by country and service.
114
+
115
+ ##### `get_prices_extended(service=None, country=None, free_price=None, pool_provider=None) -> dict`
116
+
117
+ Returns extended pricing with price tiers.
118
+
119
+ ##### `get_prices_verification(service=None, pool_provider=None) -> dict`
120
+
121
+ Returns pricing in inverted format (service → country).
122
+
123
+ ##### `get_numbers_status(country, operator=None, pool_provider=None) -> dict`
124
+
125
+ Returns available phone quantity per service.
126
+
127
+ ##### `get_top_countries_by_service(service) -> list`
128
+
129
+ Returns top 10 countries for a service, ranked by purchase share and success rate.
130
+
131
+ #### Ordering Numbers
132
+
133
+ ##### `get_number(service, country, **options) -> NumberResponse`
134
+
135
+ Orders a phone number. Returns text format response.
136
+
137
+ ```python
138
+ number = client.get_number(
139
+ service="wa",
140
+ country=73,
141
+ max_price=2.00,
142
+ operator="claro",
143
+ forward=True,
144
+ )
145
+ print(number.activation_id) # 123
146
+ print(number.phone_number) # 447777777777
147
+ ```
148
+
149
+ **Options:**
150
+
151
+ | Parameter | Type | Description |
152
+ |-----------|------|-------------|
153
+ | `max_price` | `float` | Maximum price willing to pay |
154
+ | `operator` | `str` | Mobile operator filter |
155
+ | `phone_exception` | `str` | Phone prefixes to exclude (comma-separated) |
156
+ | `forward` | `bool` | Enable call forwarding |
157
+ | `activation_type` | `int` | Activation type: 0=SMS, 1=number, 2=voice |
158
+ | `language` | `str` | Language for voice activation |
159
+ | `use_cashback` | `bool` | Use cashback balance first |
160
+ | `user_id` | `str` | End-user ID for tracking |
161
+ | `ref` | `str` | Referral ID |
162
+ | `pool_provider` | `str` | Pool provider: alpha, prime, gamma, zeta |
163
+
164
+ ##### `get_number_v2(service, country, **options) -> dict`
165
+
166
+ Same as `get_number` but returns JSON with additional fields. Supports `order_id` for idempotency.
167
+
168
+ #### Activation Management
169
+
170
+ ##### `set_status(id, status) -> str`
171
+
172
+ Changes activation status.
173
+
174
+ ```python
175
+ from virtualsms import ActivationStatus
176
+
177
+ client.set_status(activation_id, ActivationStatus.READY) # 1 - SMS sent
178
+ client.set_status(activation_id, ActivationStatus.RETRY) # 3 - Request another SMS
179
+ client.set_status(activation_id, ActivationStatus.COMPLETE) # 6 - Finish
180
+ client.set_status(activation_id, ActivationStatus.CANCEL) # 8 - Cancel
181
+ ```
182
+
183
+ ##### `get_status(id) -> StatusResponse`
184
+
185
+ Returns activation status in text format.
186
+
187
+ ```python
188
+ status = client.get_status(activation_id)
189
+ print(status.status) # STATUS_OK, STATUS_WAIT_CODE, STATUS_CANCEL
190
+ print(status.code) # 123456 (None if not yet received)
191
+ ```
192
+
193
+ ##### `get_status_v2(id) -> dict`
194
+
195
+ Returns activation status in JSON format with SMS/call details.
196
+
197
+ ##### `get_active_activations() -> list`
198
+
199
+ Returns all currently active activations.
200
+
201
+ ##### `check_extra_activation(id) -> dict`
202
+
203
+ Checks if a number is available for reactivation.
204
+
205
+ ##### `get_extra_activation(id) -> NumberResponse`
206
+
207
+ Creates an extra activation on a previously used number.
208
+
209
+ #### Notifications
210
+
211
+ ##### `get_notifications() -> dict`
212
+
213
+ Returns user notifications including penalties, low balance alerts, and admin messages.
214
+
215
+ ### Constants
216
+
217
+ #### ActivationStatus
218
+
219
+ ```python
220
+ ActivationStatus.READY # 1 - SMS has been sent to the number
221
+ ActivationStatus.RETRY # 3 - Request another SMS code
222
+ ActivationStatus.COMPLETE # 6 - Finish activation
223
+ ActivationStatus.CANCEL # 8 - Cancel activation
224
+ ```
225
+
226
+ #### PoolProvider
227
+
228
+ ```python
229
+ PoolProvider.ALPHA # 'alpha'
230
+ PoolProvider.PRIME # 'prime'
231
+ PoolProvider.GAMMA # 'gamma'
232
+ PoolProvider.ZETA # 'zeta'
233
+ ```
234
+
235
+ ## Error Handling
236
+
237
+ The SDK raises typed exceptions for all API errors. Each error code maps to a specific exception class:
238
+
239
+ ```python
240
+ from virtualsms import (
241
+ VirtualSMSClient,
242
+ VirtualSMSException,
243
+ AuthenticationException,
244
+ InsufficientBalanceException,
245
+ NoNumbersException,
246
+ ValidationException,
247
+ ActivationException,
248
+ RateLimitException,
249
+ ServerException,
250
+ )
251
+
252
+ try:
253
+ number = client.get_number("wa", 73)
254
+ except AuthenticationException:
255
+ # BAD_KEY, BANNED, PURCHASE_RESTRICTED, SERVICE_RESTRICTED
256
+ pass
257
+ except InsufficientBalanceException:
258
+ # NO_BALANCE
259
+ pass
260
+ except NoNumbersException:
261
+ # NO_NUMBERS
262
+ pass
263
+ except ValidationException:
264
+ # WRONG_SERVICE, WRONG_COUNTRY, BAD_ACTION, BAD_STATUS, NO_PRICES, INVALID_PROVIDER
265
+ pass
266
+ except ActivationException:
267
+ # NO_ACTIVATION, WRONG_ACTIVATION_ID, EARLY_CANCEL_DENIED, RENEW_ACTIVATION_NOT_AVAILABLE
268
+ pass
269
+ except RateLimitException as e:
270
+ # CONCURRENT_LIMIT — check e.retry_after
271
+ pass
272
+ except ServerException:
273
+ # ERROR_SQL, unknown errors
274
+ pass
275
+ ```
276
+
277
+ ### Error Code Reference
278
+
279
+ | Error Code | Exception | Description |
280
+ |-----------|-----------|-------------|
281
+ | `BAD_KEY` | `AuthenticationException` | Invalid API key |
282
+ | `BANNED` | `AuthenticationException` | Account banned or IP blocked |
283
+ | `PURCHASE_RESTRICTED` | `AuthenticationException` | User restricted from purchasing |
284
+ | `SERVICE_RESTRICTED` | `AuthenticationException` | Service restricted for account |
285
+ | `NO_BALANCE` | `InsufficientBalanceException` | Insufficient balance |
286
+ | `NO_NUMBERS` | `NoNumbersException` | No numbers available |
287
+ | `WRONG_SERVICE` | `ValidationException` | Invalid service code |
288
+ | `WRONG_COUNTRY` | `ValidationException` | Invalid country ID |
289
+ | `BAD_ACTION` | `ValidationException` | Invalid action |
290
+ | `BAD_STATUS` | `ValidationException` | Invalid status code |
291
+ | `NO_PRICES` | `ValidationException` | No pricing data available |
292
+ | `INVALID_PROVIDER` | `ValidationException` | Invalid pool provider |
293
+ | `NO_ACTIVATION` | `ActivationException` | Activation not found |
294
+ | `WRONG_ACTIVATION_ID` | `ActivationException` | Invalid activation ID |
295
+ | `EARLY_CANCEL_DENIED` | `ActivationException` | Cannot cancel within 5 minutes |
296
+ | `RENEW_ACTIVATION_NOT_AVAILABLE` | `ActivationException` | Number not available for reactivation |
297
+ | `CONCURRENT_LIMIT` | `RateLimitException` | Too many concurrent activations |
298
+ | `ERROR_SQL` | `ServerException` | Internal server error |
299
+
300
+ ## Tracking Headers
301
+
302
+ The SDK sends anonymous tracking headers with every request for analytics and debugging:
303
+
304
+ | Header | Value | Privacy |
305
+ |--------|-------|---------|
306
+ | `X-SDK-Version` | `1.0.0` | SDK version string |
307
+ | `X-SDK-Language` | `python` | SDK language |
308
+ | `X-SDK-Machine-Id` | SHA-256 hash of `platform.platform()` + `sys.implementation.name` (truncated to 32 chars) | Irreversible hash — no hostname or IP exposed |
309
+ | `X-SDK-Timestamp` | ISO 8601 UTC timestamp | Request time |
310
+
311
+ No personally identifiable information is transmitted. The machine ID is a one-way hash and cannot be reversed to identify the source machine.
312
+
313
+ ## Custom Transport
314
+
315
+ By default, the SDK uses `urllib.request` for HTTP requests. You can provide your own transport implementation:
316
+
317
+ ```python
318
+ from virtualsms import VirtualSMSClient, Transport, Response
319
+
320
+ class MyTransport(Transport):
321
+ def send(self, url: str, headers: dict) -> Response:
322
+ # Your HTTP implementation
323
+ return Response(status_code=200, body="ACCESS_BALANCE:10.50")
324
+
325
+ client = VirtualSMSClient("API_KEY", "https://api.example.com", transport=MyTransport())
326
+ ```
327
+
328
+ ## Examples
329
+
330
+ See the `examples/` directory:
331
+
332
+ - [`balance.py`](examples/balance.py) — Check account balance
333
+ - [`order_number.py`](examples/order_number.py) — Order a phone number
334
+ - [`full_workflow.py`](examples/full_workflow.py) — Complete SMS verification workflow
335
+
336
+ ## Testing
337
+
338
+ ```bash
339
+ pip install pytest
340
+ pytest
341
+ ```
342
+
343
+ ## License
344
+
345
+ MIT — see [LICENSE](LICENSE).
346
+
347
+ ## Links
348
+
349
+ - [PyPI](https://pypi.org/project/virtualsms/)
350
+ - [GitHub](https://github.com/VirtualSMSLabs/python-sdk)
351
+ - [API Documentation](https://virtualsms.de/docs)
352
+ - [MCP Integration](https://virtualsms.de/docs/mcp)
@@ -0,0 +1,11 @@
1
+ virtualsms/__init__.py,sha256=tpdXEuFgdolHHxQ4Kp8PJsLMcSvS73fAbJK4kdHgjJo,935
2
+ virtualsms/client.py,sha256=FKlMvuKc-uWVFXXzVPhKhY_wj1Ka2HQ5KuVZ3eFXHU0,11209
3
+ virtualsms/constants.py,sha256=dtpxUVPPd3cJ8Ml3SGRkvPwrGsWpm0W-ILulGuJf-bQ,221
4
+ virtualsms/exceptions.py,sha256=W5evMaR-vyjd59CE93XuofzIy5IJ9NFD0-aViK1qN5g,4525
5
+ virtualsms/parser.py,sha256=a0z50BbgKfRWGceBHP0gIvqET0AVSVkpfAJINpzPiOU,4802
6
+ virtualsms/response.py,sha256=G6UV5lj6lvrA2lXj0VuVH5Ngr2XZUeSO9mEUMGymHb8,350
7
+ virtualsms/transport.py,sha256=fuX61xh5HUF1FWixXWRYdaQUwxVehUm6fUFBfbmm9nk,1322
8
+ virtualsmslabs-1.0.0.dist-info/METADATA,sha256=RKvmLV1TwWwiBsyo4_pViNYkX8ejeb1F3YhVVzP-kZQ,11053
9
+ virtualsmslabs-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ virtualsmslabs-1.0.0.dist-info/licenses/LICENSE,sha256=TiJ43kHbayLnloMYbsfwBE-dnVUuta3a6mhbzsXo6Y4,1072
11
+ virtualsmslabs-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VirtualSMS Labs
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.