airalo-sdk 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.

Potentially problematic release.


This version of airalo-sdk might be problematic. Click here for more details.

@@ -0,0 +1,154 @@
1
+ """
2
+ Encryption Helper Module
3
+
4
+ This module handles encryption and decryption operations using the cryptography library.
5
+ Provides equivalent functionality to PHP's sodium library.
6
+ """
7
+
8
+ import base64
9
+ import os
10
+ from typing import Any, Union
11
+
12
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
13
+
14
+ from ..exceptions.airalo_exception import AiraloException
15
+
16
+
17
+ class Crypt:
18
+ """
19
+ Encryption/decryption utility class.
20
+
21
+ Uses ChaCha20-Poly1305 for authenticated encryption, which is equivalent
22
+ to sodium's crypto_secretbox functionality.
23
+ """
24
+
25
+ # Constants matching sodium's requirements
26
+ KEY_BYTES = 32 # SODIUM_CRYPTO_SECRETBOX_KEYBYTES
27
+ NONCE_BYTES = 12 # For ChaCha20-Poly1305
28
+
29
+ @staticmethod
30
+ def encrypt(data: str, key: str) -> str:
31
+ """
32
+ Encrypt data using ChaCha20-Poly1305.
33
+
34
+ Args:
35
+ data: Plain text data to encrypt
36
+ key: Encryption key (will be truncated/padded to 32 bytes)
37
+
38
+ Returns:
39
+ Base64-encoded encrypted data with nonce prepended
40
+ """
41
+ if not data or not key:
42
+ return data
43
+
44
+ # Prepare key (ensure it's exactly 32 bytes)
45
+ key_bytes = Crypt._prepare_key(key)
46
+
47
+ # Check if data is already encrypted
48
+ if Crypt.is_encrypted(data):
49
+ return data
50
+
51
+ # Generate random nonce
52
+ nonce = os.urandom(Crypt.NONCE_BYTES)
53
+
54
+ # Create cipher and encrypt
55
+ cipher = ChaCha20Poly1305(key_bytes)
56
+ encrypted = cipher.encrypt(nonce, data.encode("utf-8"), None)
57
+
58
+ # Combine nonce and encrypted data, then base64 encode
59
+ combined = nonce + encrypted
60
+ return base64.b64encode(combined).decode("ascii")
61
+
62
+ @staticmethod
63
+ def decrypt(data: str, key: str) -> str:
64
+ """
65
+ Decrypt data encrypted with ChaCha20-Poly1305.
66
+
67
+ Args:
68
+ data: Base64-encoded encrypted data
69
+ key: Decryption key
70
+
71
+ Returns:
72
+ Decrypted plain text
73
+
74
+ Raises:
75
+ AiraloException: If decryption fails
76
+ """
77
+ if not data or not key:
78
+ return data
79
+
80
+ # Prepare key
81
+ key_bytes = Crypt._prepare_key(key)
82
+
83
+ # Check if data is encrypted
84
+ if not Crypt.is_encrypted(data):
85
+ return data
86
+
87
+ try:
88
+ # Decode from base64
89
+ encrypted = base64.b64decode(data)
90
+
91
+ # Extract nonce and ciphertext
92
+ nonce = encrypted[: Crypt.NONCE_BYTES]
93
+ ciphertext = encrypted[Crypt.NONCE_BYTES :]
94
+
95
+ # Decrypt
96
+ cipher = ChaCha20Poly1305(key_bytes)
97
+ decrypted = cipher.decrypt(nonce, ciphertext, None)
98
+
99
+ return decrypted.decode("utf-8")
100
+ except Exception as e:
101
+ raise AiraloException(f"Decryption failed: {e}")
102
+
103
+ @staticmethod
104
+ def is_encrypted(data: Any) -> bool:
105
+ """
106
+ Check if data appears to be encrypted.
107
+
108
+ Args:
109
+ data: Data to check
110
+
111
+ Returns:
112
+ True if data appears to be encrypted, False otherwise
113
+ """
114
+ # Check for non-string types
115
+ if not isinstance(data, str):
116
+ return False
117
+
118
+ # Check minimum length (nonce + some ciphertext + base64 overhead)
119
+ if len(data) < 56:
120
+ return False
121
+
122
+ # Check if it's numeric (encrypted data shouldn't be purely numeric)
123
+ if data.isdigit():
124
+ return False
125
+
126
+ # Check if it's valid base64
127
+ try:
128
+ decoded = base64.b64decode(data, validate=True)
129
+ # Verify we can encode it back to the same string
130
+ return base64.b64encode(decoded).decode("ascii") == data
131
+ except Exception:
132
+ return False
133
+
134
+ @staticmethod
135
+ def _prepare_key(key: str) -> bytes:
136
+ """
137
+ Prepare encryption key to be exactly 32 bytes.
138
+
139
+ Args:
140
+ key: Raw key string
141
+
142
+ Returns:
143
+ 32-byte key
144
+ """
145
+ key_bytes = key.encode("utf-8")
146
+
147
+ # Truncate or pad to exactly 32 bytes
148
+ if len(key_bytes) > Crypt.KEY_BYTES:
149
+ return key_bytes[: Crypt.KEY_BYTES]
150
+ elif len(key_bytes) < Crypt.KEY_BYTES:
151
+ # Pad with zeros
152
+ return key_bytes.ljust(Crypt.KEY_BYTES, b"\0")
153
+
154
+ return key_bytes
@@ -0,0 +1,12 @@
1
+ # date_helper.py
2
+ from datetime import datetime
3
+
4
+
5
+ class DateHelper:
6
+ @staticmethod
7
+ def validate_date(date_str: str, date_format: str = "%Y-%m-%d") -> bool:
8
+ try:
9
+ datetime.strptime(date_str, date_format)
10
+ return True
11
+ except ValueError:
12
+ return False
@@ -0,0 +1,119 @@
1
+ """
2
+ Signature Helper Module
3
+
4
+ This module handles HMAC signature generation for API authentication.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ from typing import Any, Optional, Union
11
+
12
+
13
+ class Signature:
14
+ """
15
+ Signature generator for API authentication.
16
+
17
+ Generates HMAC-SHA512 signatures for request payload validation.
18
+ """
19
+
20
+ def __init__(self, secret: str):
21
+ """
22
+ Initialize signature generator.
23
+
24
+ Args:
25
+ secret: Secret key for HMAC generation
26
+ """
27
+ self._secret = secret
28
+
29
+ def get_signature(self, payload: Any) -> Optional[str]:
30
+ """
31
+ Generate HMAC signature for payload.
32
+
33
+ Args:
34
+ payload: Request payload (dict, string, or any JSON-serializable object)
35
+
36
+ Returns:
37
+ HMAC-SHA512 signature as hex string, or None if payload is empty
38
+ """
39
+ prepared_payload = self._prepare_payload(payload)
40
+ if not prepared_payload:
41
+ return None
42
+
43
+ return self._sign_data(prepared_payload)
44
+
45
+ def check_signature(self, hash_value: Optional[str], payload: Any) -> bool:
46
+ """
47
+ Verify HMAC signature.
48
+
49
+ Args:
50
+ hash_value: Expected signature
51
+ payload: Request payload
52
+
53
+ Returns:
54
+ True if signature matches, False otherwise
55
+ """
56
+ if not hash_value:
57
+ return False
58
+
59
+ prepared_payload = self._prepare_payload(payload)
60
+ if not prepared_payload:
61
+ return False
62
+
63
+ expected_signature = self._sign_data(prepared_payload)
64
+ return hmac.compare_digest(expected_signature, hash_value)
65
+
66
+ def _prepare_payload(self, payload: Any) -> Optional[str]:
67
+ """
68
+ Prepare payload for signing.
69
+
70
+ Args:
71
+ payload: Raw payload
72
+
73
+ Returns:
74
+ JSON string representation of payload, or None if empty
75
+ """
76
+ if not payload:
77
+ return None
78
+
79
+ if isinstance(payload, str):
80
+ # If it's already a string, ensure it's valid JSON by parsing and re-encoding
81
+ try:
82
+ payload = self._escape_forward_slashes(payload)
83
+
84
+ # Remove whitespaces by parsing and re-encoding
85
+ parsed = json.loads(payload)
86
+ return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
87
+ except json.JSONDecodeError:
88
+ # If not valid JSON, return as is
89
+ return payload
90
+
91
+ # Convert to JSON string
92
+ try:
93
+ json_encoded = json.dumps(
94
+ payload, separators=(",", ":"), ensure_ascii=False
95
+ )
96
+ return self._escape_forward_slashes(json_encoded)
97
+ except (TypeError, ValueError):
98
+ # If not JSON-serializable, convert to string
99
+ return str(payload)
100
+
101
+ def _escape_forward_slashes(self, payload: str) -> str:
102
+ return payload.replace("/", "\\/")
103
+
104
+ def _sign_data(self, payload: str, algo: str = "sha512") -> str:
105
+ """
106
+ Generate HMAC signature.
107
+
108
+ Args:
109
+ payload: Prepared payload string
110
+ algo: Hash algorithm (default: sha512)
111
+
112
+ Returns:
113
+ HMAC signature as hex string
114
+ """
115
+ return hmac.new(
116
+ self._secret.encode("utf-8"),
117
+ payload.encode("utf-8"),
118
+ getattr(hashlib, algo),
119
+ ).hexdigest()
@@ -0,0 +1,8 @@
1
+ """
2
+ HTTP resources for Airalo SDK.
3
+ """
4
+
5
+ from .http_resource import HttpResource
6
+ from .multi_http_resource import MultiHttpResource
7
+
8
+ __all__ = ["HttpResource", "MultiHttpResource"]
@@ -0,0 +1,324 @@
1
+ """
2
+ HTTP Resource Module
3
+
4
+ This module provides HTTP client functionality using urllib for making API requests.
5
+ """
6
+
7
+ import json
8
+ import ssl
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
+ from urllib.parse import urlencode
14
+
15
+ from ..config import Config
16
+ from ..constants.sdk_constants import SdkConstants
17
+ from ..exceptions.airalo_exception import NetworkError
18
+
19
+
20
+ class HttpResource:
21
+ """
22
+ HTTP client for making API requests.
23
+
24
+ Uses urllib for HTTP operations with support for GET, POST, and HEAD methods.
25
+ """
26
+
27
+ def __init__(self, config: Config, get_handler: bool = False):
28
+ """
29
+ Initialize HTTP resource.
30
+
31
+ Args:
32
+ config: SDK configuration
33
+ get_handler: If True, return request object instead of executing
34
+
35
+ Raises:
36
+ NetworkError: If urllib is not available
37
+ """
38
+ self._config = config
39
+ self._get_handler = get_handler
40
+ self._ignore_ssl = False
41
+ self._timeout = SdkConstants.DEFAULT_TIMEOUT
42
+ self._rfc = 1 # Default to RFC1738 for query encoding
43
+
44
+ # Response attributes
45
+ self.header: str = ""
46
+ self.code: int = 0
47
+ self.response_headers: Dict[str, str] = {}
48
+
49
+ # Request headers
50
+ self._request_headers: Dict[str, str] = {}
51
+ self._default_headers: Dict[str, str] = {
52
+ "User-Agent": f"Airalo-Python-SDK/{SdkConstants.VERSION}",
53
+ "airalo-python-sdk": f"{SdkConstants.VERSION}",
54
+ "Accept": "application/json",
55
+ }
56
+
57
+ # Initialize headers
58
+ self._init_headers()
59
+
60
+ def get(
61
+ self, url: str, params: Optional[Dict[str, Any]] = None
62
+ ) -> Union[str, urllib.request.Request]:
63
+ """
64
+ Perform GET request.
65
+
66
+ Args:
67
+ url: Request URL
68
+ params: Query parameters
69
+
70
+ Returns:
71
+ Response body as string or request object if get_handler is True
72
+ """
73
+ if params:
74
+ # Build query string
75
+ query_string = self._build_query_string(params)
76
+ url = f"{url.rstrip('?')}?{query_string}"
77
+
78
+ return self._request(url, method="GET")
79
+
80
+ def post(
81
+ self, url: str, params: Optional[Union[Dict[str, Any], str]] = None
82
+ ) -> Union[str, urllib.request.Request]:
83
+ """
84
+ Perform POST request.
85
+
86
+ Args:
87
+ url: Request URL
88
+ params: Request body (dict or string)
89
+
90
+ Returns:
91
+ Response body as string or request object if get_handler is True
92
+ """
93
+ data = None
94
+
95
+ if params:
96
+ if isinstance(params, dict):
97
+ # Check if we should send as JSON or form data
98
+ if (
99
+ "Content-Type" in self._request_headers
100
+ and "json" in self._request_headers["Content-Type"]
101
+ ):
102
+ data = json.dumps(params).encode("utf-8")
103
+ else:
104
+ data = urlencode(params).encode("utf-8")
105
+ else:
106
+ # Assume it's already a string (could be JSON or form-encoded)
107
+ data = params.encode("utf-8") if isinstance(params, str) else params
108
+
109
+ return self._request(url, data=data, method="POST")
110
+
111
+ def head(
112
+ self, url: str, params: Optional[Dict[str, Any]] = None
113
+ ) -> Union[str, urllib.request.Request]:
114
+ """
115
+ Perform HEAD request.
116
+
117
+ Args:
118
+ url: Request URL
119
+ params: Query parameters
120
+
121
+ Returns:
122
+ Response headers as string or request object if get_handler is True
123
+ """
124
+ if params:
125
+ query_string = self._build_query_string(params)
126
+ url = f"{url.rstrip('?')}?{query_string}"
127
+
128
+ return self._request(url, method="HEAD")
129
+
130
+ def set_headers(self, headers: Union[Dict[str, str], List[str]]) -> "HttpResource":
131
+ """
132
+ Set additional request headers.
133
+
134
+ Args:
135
+ headers: Dictionary or list of header strings
136
+
137
+ Returns:
138
+ Self for method chaining
139
+ """
140
+ if isinstance(headers, list):
141
+ # Parse list of header strings
142
+ for header in headers:
143
+ if ":" in header:
144
+ key, value = header.split(":", 1)
145
+ self._request_headers[key.strip()] = value.strip()
146
+ else:
147
+ # Merge dictionary
148
+ self._request_headers.update(headers)
149
+
150
+ return self
151
+
152
+ def set_timeout(self, timeout: int = 30) -> "HttpResource":
153
+ """
154
+ Set request timeout.
155
+
156
+ Args:
157
+ timeout: Timeout in seconds
158
+
159
+ Returns:
160
+ Self for method chaining
161
+ """
162
+ self._timeout = timeout
163
+ return self
164
+
165
+ def ignore_ssl(self) -> "HttpResource":
166
+ """
167
+ Ignore SSL certificate verification.
168
+
169
+ Returns:
170
+ Self for method chaining
171
+ """
172
+ self._ignore_ssl = True
173
+ return self
174
+
175
+ def use_rfc(self, rfc: int) -> "HttpResource":
176
+ """
177
+ Set RFC standard for URL encoding.
178
+
179
+ Args:
180
+ rfc: RFC standard (1 for RFC1738, 3 for RFC3986)
181
+
182
+ Returns:
183
+ Self for method chaining
184
+ """
185
+ self._rfc = rfc
186
+ return self
187
+
188
+ def set_basic_authentication(
189
+ self, username: str, password: str = ""
190
+ ) -> "HttpResource":
191
+ """
192
+ Set HTTP basic authentication.
193
+
194
+ Args:
195
+ username: Username
196
+ password: Password
197
+
198
+ Returns:
199
+ Self for method chaining
200
+ """
201
+ import base64
202
+
203
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode(
204
+ "ascii"
205
+ )
206
+ self._request_headers["Authorization"] = f"Basic {credentials}"
207
+ return self
208
+
209
+ def _request(
210
+ self, url: str, data: Optional[bytes] = None, method: str = "GET"
211
+ ) -> Union[str, urllib.request.Request]:
212
+ """
213
+ Execute HTTP request.
214
+
215
+ Args:
216
+ url: Request URL
217
+ data: Request body
218
+ method: HTTP method
219
+
220
+ Returns:
221
+ Response body or request object
222
+
223
+ Raises:
224
+ NetworkError: If request fails
225
+ """
226
+ # Create request object
227
+ request = urllib.request.Request(url, data=data, method=method)
228
+
229
+ # Add headers
230
+ for key, value in self._request_headers.items():
231
+ request.add_header(key, value)
232
+
233
+ # Return request object if handler mode
234
+ if self._get_handler:
235
+ return request
236
+
237
+ # Configure SSL context
238
+ context = None
239
+ if self._ignore_ssl:
240
+ context = ssl.create_default_context()
241
+ context.check_hostname = False
242
+ context.verify_mode = ssl.CERT_NONE
243
+
244
+ try:
245
+ # Execute request
246
+ response = urllib.request.urlopen(
247
+ request, timeout=self._timeout, context=context
248
+ )
249
+
250
+ # Read response
251
+ response_body = response.read().decode("utf-8")
252
+
253
+ # Store response details
254
+ self.code = response.getcode()
255
+ self.response_headers = dict(response.headers)
256
+ self.header = str(response.headers)
257
+
258
+ # Reset for next request
259
+ self._reset()
260
+
261
+ return response_body
262
+
263
+ except urllib.error.HTTPError as e:
264
+ # HTTP error (4xx, 5xx)
265
+ self.code = e.code
266
+ self.response_headers = dict(e.headers)
267
+ self.header = str(e.headers)
268
+
269
+ # Try to read error response
270
+ try:
271
+ error_body = e.read().decode("utf-8")
272
+ self._reset()
273
+ return error_body
274
+ except:
275
+ self._reset()
276
+ raise NetworkError(f"HTTP {e.code}: {e.reason}", http_status=e.code)
277
+
278
+ except urllib.error.URLError as e:
279
+ # Network error
280
+ self._reset()
281
+ raise NetworkError(f"Network error: {e.reason}")
282
+
283
+ except Exception as e:
284
+ # Other errors
285
+ self._reset()
286
+ raise NetworkError(f"Request failed: {str(e)}")
287
+
288
+ def _init_headers(self) -> None:
289
+ """Initialize request headers with defaults and config headers."""
290
+ self._request_headers = self._default_headers.copy()
291
+
292
+ # Add custom headers from config
293
+ config_headers = self._config.get_http_headers()
294
+ if isinstance(config_headers, list):
295
+ for header in config_headers:
296
+ if ":" in header:
297
+ key, value = header.split(":", 1)
298
+ self._request_headers[key.strip()] = value.strip()
299
+ elif isinstance(config_headers, dict):
300
+ self._request_headers.update(config_headers)
301
+
302
+ def _reset(self) -> None:
303
+ """Reset headers and state for next request."""
304
+ self._init_headers()
305
+ self._ignore_ssl = False
306
+ self._timeout = SdkConstants.DEFAULT_TIMEOUT
307
+
308
+ def _build_query_string(self, params: Dict[str, Any]) -> str:
309
+ """
310
+ Build query string from parameters.
311
+
312
+ Args:
313
+ params: Query parameters
314
+
315
+ Returns:
316
+ URL-encoded query string
317
+ """
318
+ # Use appropriate encoding based on RFC setting
319
+ if self._rfc == 3:
320
+ # RFC3986 - uses %20 for spaces
321
+ return urlencode(params, quote_via=urllib.parse.quote)
322
+ else:
323
+ # RFC1738 - uses + for spaces (default)
324
+ return urlencode(params)