airalo-sdk 1.0.1__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.
airalo/config.py ADDED
@@ -0,0 +1,146 @@
1
+ """
2
+ Configuration Module
3
+
4
+ This module handles SDK configuration including credentials, environment settings,
5
+ and HTTP headers.
6
+ """
7
+
8
+ import json
9
+ from typing import Any, Dict, List, Optional, Union
10
+ from urllib.parse import urlencode
11
+
12
+ from .constants.api_constants import ApiConstants
13
+ from .exceptions.airalo_exception import ConfigurationError
14
+
15
+
16
+ class Config:
17
+ """
18
+ Configuration class for Airalo SDK.
19
+
20
+ Handles SDK configuration including API credentials, environment selection,
21
+ and custom HTTP headers.
22
+ """
23
+
24
+ MANDATORY_CONFIG_KEYS = [
25
+ "client_id",
26
+ "client_secret",
27
+ ]
28
+
29
+ def __init__(self, data: Union[Dict[str, Any], str, object]):
30
+ """
31
+ Initialize configuration.
32
+
33
+ Args:
34
+ data: Configuration data as dict, JSON string, or object with attributes
35
+
36
+ Raises:
37
+ ConfigurationError: If configuration is invalid or missing required fields
38
+ """
39
+ self._data: Dict[str, Any] = {}
40
+
41
+ if not data:
42
+ raise ConfigurationError("Config data is not provided")
43
+
44
+ # Convert different input types to dictionary
45
+ if isinstance(data, str):
46
+ try:
47
+ self._data = json.loads(data)
48
+ except json.JSONDecodeError as e:
49
+ raise ConfigurationError(f"Invalid JSON config data: {e}")
50
+ elif isinstance(data, dict):
51
+ self._data = data.copy()
52
+ elif hasattr(data, "__dict__"):
53
+ # Convert object to dictionary
54
+ self._data = vars(data).copy()
55
+ else:
56
+ try:
57
+ # Try to serialize and deserialize to get a dict
58
+ self._data = json.loads(json.dumps(data, default=lambda o: o.__dict__))
59
+ except (TypeError, json.JSONDecodeError) as e:
60
+ raise ConfigurationError(f"Invalid config data provided: {e}")
61
+
62
+ self._validate()
63
+
64
+ def get(self, key: str, default: Any = None) -> Any:
65
+ """
66
+ Get configuration value by key.
67
+
68
+ Args:
69
+ key: Configuration key
70
+ default: Default value if key not found
71
+
72
+ Returns:
73
+ Configuration value or default
74
+ """
75
+ return self._data.get(key, default)
76
+
77
+ def get_config(self) -> Dict[str, Any]:
78
+ """
79
+ Get complete configuration dictionary.
80
+
81
+ Returns:
82
+ Configuration dictionary
83
+ """
84
+ return self._data.copy()
85
+
86
+ def get_credentials(self, as_string: bool = False) -> Union[Dict[str, str], str]:
87
+ """
88
+ Get API credentials.
89
+
90
+ Args:
91
+ as_string: If True, return as URL-encoded string
92
+
93
+ Returns:
94
+ Credentials as dictionary or URL-encoded string
95
+ """
96
+ credentials = {
97
+ "client_id": self._data["client_id"],
98
+ "client_secret": self._data["client_secret"],
99
+ }
100
+
101
+ if as_string:
102
+ return urlencode(credentials)
103
+
104
+ return credentials
105
+
106
+ def get_environment(self) -> str:
107
+ """
108
+ Get current environment.
109
+ """
110
+ return self._data.get("env", "production")
111
+
112
+ def get_url(self) -> str:
113
+ """
114
+ Get base API URL for current environment.
115
+
116
+ Returns:
117
+ Base API URL
118
+ """
119
+ return ApiConstants.PRODUCTION_URL
120
+
121
+ def get_http_headers(self) -> List[str]:
122
+ """
123
+ Get custom HTTP headers.
124
+
125
+ Returns:
126
+ List of HTTP header strings
127
+ """
128
+ return self._data.get("http_headers", [])
129
+
130
+ def _validate(self) -> None:
131
+ """
132
+ Validate configuration.
133
+
134
+ Raises:
135
+ ConfigurationError: If configuration is invalid
136
+ """
137
+ # Check mandatory fields
138
+ for key in self.MANDATORY_CONFIG_KEYS:
139
+ if key not in self._data or not self._data[key]:
140
+ raise ConfigurationError(
141
+ f"Mandatory field `{key}` is missing in the provided config data"
142
+ )
143
+
144
+ # Set default environment if not provided
145
+ if "env" not in self._data:
146
+ self._data["env"] = "production"
@@ -0,0 +1,8 @@
1
+ """
2
+ Constants package for Airalo SDK.
3
+ """
4
+
5
+ from .api_constants import ApiConstants
6
+ from .sdk_constants import SdkConstants
7
+
8
+ __all__ = ["ApiConstants", "SdkConstants"]
@@ -0,0 +1,49 @@
1
+ """
2
+ API Constants Module
3
+
4
+ This module contains all API endpoints and URLs used by the Airalo SDK.
5
+ """
6
+
7
+
8
+ class ApiConstants:
9
+ """API endpoints and URLs for Airalo SDK."""
10
+
11
+ PRODUCTION_URL = "https://partners-api.airalo.com/v2/"
12
+
13
+ # Authentication
14
+ TOKEN_SLUG = "token"
15
+
16
+ # Package endpoints
17
+ PACKAGES_SLUG = "packages"
18
+
19
+ # Order endpoints
20
+ ORDERS_SLUG = "orders"
21
+ ASYNC_ORDERS_SLUG = "orders-async"
22
+ TOPUPS_SLUG = "orders/topups"
23
+
24
+ # Voucher endpoints
25
+ VOUCHERS_SLUG = "voucher/airmoney"
26
+ VOUCHERS_ESIM_SLUG = "voucher/esim"
27
+
28
+ # SIM endpoints
29
+ SIMS_SLUG = "sims"
30
+ SIMS_USAGE = "usage"
31
+ SIMS_TOPUPS = "topups"
32
+ SIMS_PACKAGES = "packages"
33
+
34
+ # Instructions and compatibility
35
+ INSTRUCTIONS_SLUG = "instructions"
36
+ COMPATIBILITY_SLUG = "compatible-devices-lite"
37
+
38
+ # Finance endpoints
39
+ EXCHANGE_RATES_SLUG = "finance/exchange-rates"
40
+
41
+ # Future orders
42
+ FUTURE_ORDERS = "future-orders"
43
+ CANCEL_FUTURE_ORDERS = "cancel-future-orders"
44
+
45
+ # Catalog
46
+ OVERRIDE_SLUG = "packages/overrides"
47
+
48
+ # Notifications
49
+ NOTIFICATIONS_SLUG = "notifications"
@@ -0,0 +1,32 @@
1
+ """
2
+ SDK Constants Module
3
+
4
+ This module contains SDK-specific constants such as version, limits, and timeouts.
5
+ """
6
+
7
+
8
+ class SdkConstants:
9
+ """SDK-specific constants for Airalo SDK."""
10
+
11
+ # SDK Version
12
+ VERSION = "1.0.1"
13
+
14
+ # Order limits
15
+ BULK_ORDER_LIMIT = 50
16
+ ORDER_LIMIT = 50
17
+ FUTURE_ORDER_LIMIT = 50
18
+
19
+ # Voucher limits
20
+ VOUCHER_MAX_NUM = 100000
21
+ VOUCHER_MAX_QUANTITY = 100
22
+
23
+ # HTTP settings
24
+ DEFAULT_TIMEOUT = 60 # seconds
25
+ DEFAULT_RETRY_COUNT = 2
26
+
27
+ # Cache settings
28
+ DEFAULT_CACHE_TTL = 3600 # 1 hour in seconds
29
+ TOKEN_CACHE_TTL = 3600 # 1 hour in seconds
30
+
31
+ # Concurrency settings
32
+ MAX_CONCURRENT_REQUESTS = 5
@@ -0,0 +1,21 @@
1
+ """
2
+ Exceptions package for Airalo SDK.
3
+ """
4
+
5
+ from .airalo_exception import (
6
+ AiraloException,
7
+ ConfigurationError,
8
+ AuthenticationError,
9
+ ValidationError,
10
+ APIError,
11
+ NetworkError,
12
+ )
13
+
14
+ __all__ = [
15
+ "AiraloException",
16
+ "ConfigurationError",
17
+ "AuthenticationError",
18
+ "ValidationError",
19
+ "APIError",
20
+ "NetworkError",
21
+ ]
@@ -0,0 +1,64 @@
1
+ """
2
+ Airalo Exception Module
3
+
4
+ This module defines custom exceptions for the Airalo SDK.
5
+ """
6
+
7
+
8
+ class AiraloException(Exception):
9
+ """
10
+ Base exception class for Airalo SDK.
11
+
12
+ This exception is raised when SDK-specific errors occur,
13
+ such as configuration errors, API errors, or validation errors.
14
+ """
15
+
16
+ def __init__(self, message: str, error_code: str = None, http_status: int = None):
17
+ """
18
+ Initialize AiraloException.
19
+
20
+ Args:
21
+ message: Error message describing the exception
22
+ error_code: Optional error code from API
23
+ http_status: Optional HTTP status code
24
+ """
25
+ super().__init__(message)
26
+ self.message = message
27
+ self.error_code = error_code
28
+ self.http_status = http_status
29
+
30
+ def __str__(self) -> str:
31
+ """Return string representation of the exception."""
32
+ if self.error_code:
33
+ return f"[{self.error_code}] {self.message}"
34
+ return self.message
35
+
36
+
37
+ class ConfigurationError(AiraloException):
38
+ """Exception raised for configuration-related errors."""
39
+
40
+ pass
41
+
42
+
43
+ class AuthenticationError(AiraloException):
44
+ """Exception raised for authentication failures."""
45
+
46
+ pass
47
+
48
+
49
+ class ValidationError(AiraloException):
50
+ """Exception raised for validation errors."""
51
+
52
+ pass
53
+
54
+
55
+ class APIError(AiraloException):
56
+ """Exception raised for API-related errors."""
57
+
58
+ pass
59
+
60
+
61
+ class NetworkError(AiraloException):
62
+ """Exception raised for network-related errors."""
63
+
64
+ pass
@@ -0,0 +1,9 @@
1
+ """
2
+ Helper utilities for Airalo SDK.
3
+ """
4
+
5
+ from .cached import Cached
6
+ from .crypt import Crypt
7
+ from .signature import Signature
8
+
9
+ __all__ = ["Cached", "Crypt", "Signature"]
@@ -0,0 +1,177 @@
1
+ """
2
+ Caching Helper Module
3
+
4
+ This module provides file-based caching functionality for the SDK.
5
+ """
6
+
7
+ import hashlib
8
+ import os
9
+ import pickle
10
+ import tempfile
11
+ import time
12
+ from ..constants.sdk_constants import SdkConstants
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Optional, Union
15
+
16
+
17
+ class Cached:
18
+ """
19
+ File-based caching utility.
20
+
21
+ Provides simple file-based caching with TTL support using
22
+ project root cache directory.
23
+ """
24
+
25
+ CACHE_KEY_PREFIX = "airalo_"
26
+
27
+ _cache_path: Optional[Path] = None
28
+ _cache_name: str = ""
29
+
30
+ @classmethod
31
+ def get(
32
+ cls, work: Union[Callable[[], Any], Any], cache_name: str, ttl: int = 0
33
+ ) -> Any:
34
+ """
35
+ Get cached value or compute and cache it.
36
+
37
+ Args:
38
+ work: Callable that produces the value, or the value itself
39
+ cache_name: Unique name for this cache entry
40
+ ttl: Time-to-live in seconds (0 uses default)
41
+
42
+ Returns:
43
+ Cached or computed value
44
+ """
45
+ cls._init(cache_name)
46
+
47
+ cache_id = cls._get_id(cache_name)
48
+
49
+ # Try to get from cache
50
+ cached_result = cls._cache_get(cache_id, ttl)
51
+ if cached_result is not None:
52
+ return cached_result
53
+
54
+ # Compute result
55
+ if callable(work):
56
+ result = work()
57
+ else:
58
+ result = work
59
+
60
+ # Cache and return
61
+ return cls._cache_this(cache_id, result)
62
+
63
+ @classmethod
64
+ def clear_cache(cls) -> None:
65
+ """Clear all cache files."""
66
+ cls._init()
67
+
68
+ # Find and remove all cache files
69
+ cache_pattern = cls._cache_path / f"{cls.CACHE_KEY_PREFIX}*"
70
+ for cache_file in cls._cache_path.glob(f"{cls.CACHE_KEY_PREFIX}*"):
71
+ try:
72
+ cache_file.unlink()
73
+ except OSError:
74
+ pass # Ignore errors when deleting cache files
75
+
76
+ @classmethod
77
+ def _init(cls, cache_name: str = "") -> None:
78
+ """
79
+ Initialize cache directory and name.
80
+
81
+ Args:
82
+ cache_name: Optional cache name to set
83
+ """
84
+ if cls._cache_path is None:
85
+ # Use project root cache directory
86
+ cls._cache_path = Path(__file__).resolve().parent.parent.parent / ".cache"
87
+ cls._cache_path.mkdir(parents=True, exist_ok=True)
88
+
89
+ if cache_name:
90
+ cls._cache_name = cache_name
91
+
92
+ @classmethod
93
+ def _get_id(cls, key: str) -> str:
94
+ """
95
+ Generate cache file ID from key.
96
+
97
+ Args:
98
+ key: Cache key
99
+
100
+ Returns:
101
+ Cache file ID
102
+ """
103
+ return cls.CACHE_KEY_PREFIX + hashlib.md5(key.encode()).hexdigest()
104
+
105
+ @classmethod
106
+ def _cache_get(cls, cache_id: str, custom_ttl: int = 0) -> Optional[Any]:
107
+ """
108
+ Retrieve value from cache if valid.
109
+
110
+ Args:
111
+ cache_id: Cache file ID
112
+ custom_ttl: Custom TTL in seconds
113
+
114
+ Returns:
115
+ Cached value or None if not found/expired
116
+ """
117
+ cache_file = cls._cache_path / cache_id
118
+
119
+ if not cache_file.exists():
120
+ return None
121
+
122
+ # Check TTL
123
+ now = time.time()
124
+ file_mtime = cache_file.stat().st_mtime
125
+ ttl = custom_ttl if custom_ttl > 0 else SdkConstants.DEFAULT_CACHE_TTL
126
+
127
+ if now - file_mtime > ttl:
128
+ # Cache expired, remove file
129
+ try:
130
+ cache_file.unlink()
131
+ except OSError:
132
+ return None
133
+
134
+ # Read cached data
135
+ try:
136
+ with open(cache_file, "rb") as f:
137
+ return pickle.load(f)
138
+ except (OSError, pickle.PickleError):
139
+ # Corrupted cache file, remove it
140
+ try:
141
+ cache_file.unlink()
142
+ except OSError:
143
+ return None
144
+
145
+ @classmethod
146
+ def _cache_this(cls, cache_id: str, result: Any) -> Any:
147
+ """
148
+ Store value in cache.
149
+
150
+ Args:
151
+ cache_id: Cache file ID
152
+ result: Value to cache
153
+
154
+ Returns:
155
+ The cached value
156
+ """
157
+ if result is None:
158
+ return None
159
+
160
+ cache_file = cls._cache_path / cache_id
161
+
162
+ try:
163
+ # Write cache file
164
+ with open(cache_file, "wb") as f:
165
+ pickle.dump(result, f)
166
+
167
+ # Try to set permissions (may fail on some systems)
168
+ try:
169
+ cache_file.chmod(0o666)
170
+ except OSError:
171
+ pass # Ignore permission errors
172
+
173
+ except (OSError, pickle.PickleError) as e:
174
+ # Failed to cache, but return the result anyway
175
+ pass
176
+
177
+ return result
@@ -0,0 +1,89 @@
1
+ """
2
+ Cloud SIM Share Validator
3
+
4
+ Helper class for validating SIM cloud sharing data.
5
+ """
6
+
7
+ import re
8
+ import json
9
+ from typing import Any, Dict, List
10
+
11
+ from ..exceptions.airalo_exception import AiraloException
12
+
13
+
14
+ class CloudSimShareValidator:
15
+ """
16
+ Validator for SIM cloud sharing payloads.
17
+ """
18
+
19
+ _email_regex = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
20
+ _allowed_sharing_options = {"link", "pdf"}
21
+
22
+ @staticmethod
23
+ def validate(
24
+ sim_cloud_share: Dict[str, Any], required_fields: List[str] = None
25
+ ) -> bool:
26
+ """
27
+ Validate the SIM cloud sharing payload.
28
+
29
+ Args:
30
+ sim_cloud_share: The payload dictionary.
31
+ required_fields: List of required fields to validate.
32
+
33
+ Raises:
34
+ AiraloException: If validation fails.
35
+
36
+ Returns:
37
+ True if valid.
38
+ """
39
+ required_fields = required_fields or []
40
+
41
+ CloudSimShareValidator._check_required_fields(sim_cloud_share, required_fields)
42
+
43
+ # Validate 'to_email'
44
+ to_email = sim_cloud_share.get("to_email")
45
+ if to_email and not CloudSimShareValidator._email_regex.match(to_email):
46
+ raise AiraloException(
47
+ f"The to_email must be a valid email address, payload: {json.dumps(sim_cloud_share)}"
48
+ )
49
+
50
+ # Validate 'sharing_option'
51
+ for option in sim_cloud_share.get("sharing_option", []):
52
+ if option not in CloudSimShareValidator._allowed_sharing_options:
53
+ allowed = " or ".join(CloudSimShareValidator._allowed_sharing_options)
54
+ raise AiraloException(
55
+ f"The sharing_option may be {allowed} or both, payload: {json.dumps(sim_cloud_share)}"
56
+ )
57
+
58
+ # Validate 'copy_address' emails
59
+ for cc_email in sim_cloud_share.get("copy_address", []):
60
+ if not CloudSimShareValidator._email_regex.match(cc_email):
61
+ raise AiraloException(
62
+ f"The copy_address: {cc_email} must be a valid email address, payload: {json.dumps(sim_cloud_share)}"
63
+ )
64
+
65
+ return True
66
+
67
+ @staticmethod
68
+ def _check_required_fields(
69
+ sim_cloud_share: Dict[str, Any], required_fields: List[str]
70
+ ) -> bool:
71
+ """
72
+ Ensure required fields exist and are not empty.
73
+
74
+ Args:
75
+ sim_cloud_share: Payload dictionary.
76
+ required_fields: List of required keys.
77
+
78
+ Raises:
79
+ AiraloException: If a required field is missing or empty.
80
+
81
+ Returns:
82
+ True if all required fields are present.
83
+ """
84
+ for field in required_fields:
85
+ if not sim_cloud_share.get(field):
86
+ raise AiraloException(
87
+ f"The {field} field is required, payload: {json.dumps(sim_cloud_share)}"
88
+ )
89
+ return True