mobiska 0.1.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.
- mobiska/__init__.py +95 -0
- mobiska/auth.py +32 -0
- mobiska/client.py +127 -0
- mobiska/config.py +73 -0
- mobiska/exceptions/__init__.py +39 -0
- mobiska/resources/__init__.py +6 -0
- mobiska/resources/payment.py +139 -0
- mobiska/resources/sms.py +68 -0
- mobiska-0.1.0.dist-info/METADATA +257 -0
- mobiska-0.1.0.dist-info/RECORD +13 -0
- mobiska-0.1.0.dist-info/WHEEL +5 -0
- mobiska-0.1.0.dist-info/licenses/LICENSE +21 -0
- mobiska-0.1.0.dist-info/top_level.txt +1 -0
mobiska/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Mobiska Python SDK.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
from mobiska import InitSMS, InitPayment
|
|
6
|
+
|
|
7
|
+
sms = InitSMS(username=…, password=…, service_id=…, sender_id=…)
|
|
8
|
+
payment = InitPayment(username=…, password=…, service_id=…)
|
|
9
|
+
|
|
10
|
+
Both factories also accept ``timeout``, ``retry_count`` and ``retry_delay``
|
|
11
|
+
keyword arguments. When called with no arguments they read credentials from
|
|
12
|
+
environment variables (``MOBISKA_USER_NAME``, ``MOBISKA_PASSWORD``,
|
|
13
|
+
``MOBISKA_SERVICE_ID``, ``MOBISKA_SMS_SENDER_ID``, ``MOBISKA_API_URL``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .client import BaseClient
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
APIError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
MobiskaError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
from .resources import PaymentClient, SMSClient
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"InitSMS",
|
|
33
|
+
"InitPayment",
|
|
34
|
+
"SMSClient",
|
|
35
|
+
"PaymentClient",
|
|
36
|
+
"BaseClient",
|
|
37
|
+
"MobiskaError",
|
|
38
|
+
"AuthenticationError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"ValidationError",
|
|
42
|
+
"APIError",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def InitSMS(
|
|
47
|
+
username: Optional[str] = None,
|
|
48
|
+
password: Optional[str] = None,
|
|
49
|
+
service_id: Optional[int] = None,
|
|
50
|
+
sender_id: Optional[str] = None,
|
|
51
|
+
api_url: Optional[str] = None,
|
|
52
|
+
timeout: int = 30,
|
|
53
|
+
retry_count: int = 0,
|
|
54
|
+
retry_delay: float = 0.5,
|
|
55
|
+
) -> SMSClient:
|
|
56
|
+
"""Create and return an :class:`SMSClient`.
|
|
57
|
+
|
|
58
|
+
All arguments are optional — when omitted the values are read from
|
|
59
|
+
environment variables.
|
|
60
|
+
"""
|
|
61
|
+
return SMSClient(
|
|
62
|
+
username=username,
|
|
63
|
+
password=password,
|
|
64
|
+
service_id=service_id,
|
|
65
|
+
sender_id=sender_id,
|
|
66
|
+
api_url=api_url,
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
retry_count=retry_count,
|
|
69
|
+
retry_delay=retry_delay,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def InitPayment(
|
|
74
|
+
username: Optional[str] = None,
|
|
75
|
+
password: Optional[str] = None,
|
|
76
|
+
service_id: Optional[int] = None,
|
|
77
|
+
api_url: Optional[str] = None,
|
|
78
|
+
timeout: int = 30,
|
|
79
|
+
retry_count: int = 0,
|
|
80
|
+
retry_delay: float = 0.5,
|
|
81
|
+
) -> PaymentClient:
|
|
82
|
+
"""Create and return a :class:`PaymentClient`.
|
|
83
|
+
|
|
84
|
+
All arguments are optional — when omitted the values are read from
|
|
85
|
+
environment variables.
|
|
86
|
+
"""
|
|
87
|
+
return PaymentClient(
|
|
88
|
+
username=username,
|
|
89
|
+
password=password,
|
|
90
|
+
service_id=service_id,
|
|
91
|
+
api_url=api_url,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
retry_count=retry_count,
|
|
94
|
+
retry_delay=retry_delay,
|
|
95
|
+
)
|
mobiska/auth.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Basic Authentication helper for the Mobiska API.
|
|
2
|
+
|
|
3
|
+
Mobiska uses HTTP Basic Auth: the client_key (username) and secret_key
|
|
4
|
+
(password) are combined as ``username:password``, base64-encoded, and sent
|
|
5
|
+
in the ``Authorization`` header prefixed with ``Basic``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
from typing import Dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BasicAuth:
|
|
13
|
+
"""Generates the Basic-auth Authorization header for API requests."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, username: str, password: str):
|
|
16
|
+
if not username or not password:
|
|
17
|
+
raise ValueError("username and password must not be empty.")
|
|
18
|
+
self.username = username
|
|
19
|
+
self.password = password
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def _encoded(self) -> str:
|
|
23
|
+
raw = f"{self.username}:{self.password}".encode("utf-8")
|
|
24
|
+
return base64.b64encode(raw).decode("ascii")
|
|
25
|
+
|
|
26
|
+
def get_headers(self) -> Dict[str, str]:
|
|
27
|
+
"""Return the auth headers to attach to every request."""
|
|
28
|
+
return {
|
|
29
|
+
"Authorization": f"Basic {self._encoded}",
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"Accept": "application/json",
|
|
32
|
+
}
|
mobiska/client.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Base HTTP client for the Mobiska API.
|
|
2
|
+
|
|
3
|
+
Provides the shared transport layer used by :class:`SMSClient` and
|
|
4
|
+
:class:`PaymentClient`:
|
|
5
|
+
|
|
6
|
+
* Configuration resolution (kwargs → environment variables)
|
|
7
|
+
* Basic-auth header setup
|
|
8
|
+
* Requests session with configurable timeout
|
|
9
|
+
* Automatic retry on transient failures (network errors and 5xx)
|
|
10
|
+
* Centralised response parsing and error mapping
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .auth import BasicAuth
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
APIError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT = 30
|
|
29
|
+
DEFAULT_RETRY_COUNT = 0
|
|
30
|
+
DEFAULT_RETRY_DELAY = 0.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseClient:
|
|
34
|
+
"""Shared HTTP client for all Mobiska API resources."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
username: Optional[str] = None,
|
|
40
|
+
password: Optional[str] = None,
|
|
41
|
+
service_id: Optional[int] = None,
|
|
42
|
+
sender_id: Optional[str] = None,
|
|
43
|
+
api_url: Optional[str] = None,
|
|
44
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
45
|
+
retry_count: int = DEFAULT_RETRY_COUNT,
|
|
46
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
47
|
+
):
|
|
48
|
+
self.config = Config.resolve(
|
|
49
|
+
username=username,
|
|
50
|
+
password=password,
|
|
51
|
+
service_id=service_id,
|
|
52
|
+
sender_id=sender_id,
|
|
53
|
+
api_url=api_url,
|
|
54
|
+
)
|
|
55
|
+
self.auth = BasicAuth(self.config.username, self.config.password)
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self.retry_count = retry_count
|
|
58
|
+
self.retry_delay = retry_delay
|
|
59
|
+
|
|
60
|
+
self._session = requests.Session()
|
|
61
|
+
self._session.headers.update(self.auth.get_headers())
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Helpers
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _request_time() -> str:
|
|
69
|
+
"""Return the current UTC timestamp in ISO-8601 format."""
|
|
70
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
71
|
+
|
|
72
|
+
def _build_payload(self, **extra: Any) -> Dict[str, Any]:
|
|
73
|
+
"""Start every payload with the common fields, then merge extras."""
|
|
74
|
+
payload: Dict[str, Any] = {
|
|
75
|
+
"service_id": self.config.service_id,
|
|
76
|
+
}
|
|
77
|
+
payload.update(extra)
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# HTTP
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
85
|
+
"""POST *payload* to *path*, retrying on transient failures."""
|
|
86
|
+
url = f"{self.config.api_url}/{path.lstrip('/')}"
|
|
87
|
+
last_exc: Optional[Exception] = None
|
|
88
|
+
|
|
89
|
+
for attempt in range(self.retry_count + 1):
|
|
90
|
+
try:
|
|
91
|
+
response = self._session.post(url, json=payload, timeout=self.timeout)
|
|
92
|
+
return self._handle_response(response)
|
|
93
|
+
except (APIError, requests.RequestException) as exc:
|
|
94
|
+
last_exc = exc
|
|
95
|
+
if attempt < self.retry_count:
|
|
96
|
+
time.sleep(self.retry_delay)
|
|
97
|
+
continue
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
# Should never reach here, but satisfy the type checker.
|
|
101
|
+
if last_exc:
|
|
102
|
+
raise last_exc
|
|
103
|
+
raise APIError("Request failed for an unknown reason.")
|
|
104
|
+
|
|
105
|
+
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
|
106
|
+
"""Map HTTP status codes to exceptions, otherwise return JSON."""
|
|
107
|
+
status = response.status_code
|
|
108
|
+
|
|
109
|
+
if status == 401:
|
|
110
|
+
raise AuthenticationError("Invalid or missing credentials.")
|
|
111
|
+
if status == 403:
|
|
112
|
+
raise AuthenticationError("Insufficient permissions.")
|
|
113
|
+
if status == 404:
|
|
114
|
+
raise NotFoundError(f"Resource not found: {response.url}")
|
|
115
|
+
if status == 429:
|
|
116
|
+
raise RateLimitError("API rate limit exceeded. Please slow down.")
|
|
117
|
+
if status >= 500:
|
|
118
|
+
raise APIError(
|
|
119
|
+
f"Server error ({status}): {response.text}",
|
|
120
|
+
status_code=status,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
|
|
125
|
+
if response.content:
|
|
126
|
+
return response.json()
|
|
127
|
+
return {}
|
mobiska/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Configuration management for the Mobiska SDK.
|
|
2
|
+
|
|
3
|
+
Supports two configuration strategies:
|
|
4
|
+
1. Environment variables (MOBISKA_USER_NAME, MOBISKA_PASSWORD, …)
|
|
5
|
+
2. Explicit keyword arguments passed to the client constructor.
|
|
6
|
+
|
|
7
|
+
Keyword arguments always take precedence; any missing value falls back to
|
|
8
|
+
the corresponding environment variable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_URL = "https://api.mobiska.com"
|
|
16
|
+
|
|
17
|
+
# Environment-variable names
|
|
18
|
+
ENV_USERNAME = "MOBISKA_USER_NAME"
|
|
19
|
+
ENV_PASSWORD = "MOBISKA_PASSWORD"
|
|
20
|
+
ENV_SERVICE_ID = "MOBISKA_SERVICE_ID"
|
|
21
|
+
ENV_SENDER_ID = "MOBISKA_SMS_SENDER_ID"
|
|
22
|
+
ENV_API_URL = "MOBISKA_API_URL"
|
|
23
|
+
|
|
24
|
+
_REQUIRED_MSG = (
|
|
25
|
+
"Missing required configuration: username, password, and service_id. "
|
|
26
|
+
"Pass them as keyword arguments or set the environment variables "
|
|
27
|
+
f"{ENV_USERNAME}, {ENV_PASSWORD}, {ENV_SERVICE_ID}."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Config:
|
|
33
|
+
"""Resolved SDK configuration."""
|
|
34
|
+
|
|
35
|
+
username: str
|
|
36
|
+
password: str
|
|
37
|
+
service_id: int
|
|
38
|
+
sender_id: Optional[str] = None
|
|
39
|
+
api_url: str = DEFAULT_API_URL
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def resolve(
|
|
43
|
+
cls,
|
|
44
|
+
username: Optional[str] = None,
|
|
45
|
+
password: Optional[str] = None,
|
|
46
|
+
service_id: Optional[int] = None,
|
|
47
|
+
sender_id: Optional[str] = None,
|
|
48
|
+
api_url: Optional[str] = None,
|
|
49
|
+
) -> "Config":
|
|
50
|
+
"""Build a Config from kwargs, falling back to env vars.
|
|
51
|
+
|
|
52
|
+
Raises ValueError if any required value is missing.
|
|
53
|
+
"""
|
|
54
|
+
username = username or os.environ.get(ENV_USERNAME)
|
|
55
|
+
password = password or os.environ.get(ENV_PASSWORD)
|
|
56
|
+
|
|
57
|
+
if service_id is None:
|
|
58
|
+
service_id_str = os.environ.get(ENV_SERVICE_ID)
|
|
59
|
+
service_id = int(service_id_str) if service_id_str else None
|
|
60
|
+
|
|
61
|
+
sender_id = sender_id or os.environ.get(ENV_SENDER_ID)
|
|
62
|
+
api_url = api_url or os.environ.get(ENV_API_URL, DEFAULT_API_URL)
|
|
63
|
+
|
|
64
|
+
if not username or not password or service_id is None:
|
|
65
|
+
raise ValueError(_REQUIRED_MSG)
|
|
66
|
+
|
|
67
|
+
return cls(
|
|
68
|
+
username=username,
|
|
69
|
+
password=password,
|
|
70
|
+
service_id=service_id,
|
|
71
|
+
sender_id=sender_id,
|
|
72
|
+
api_url=api_url.rstrip("/"),
|
|
73
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exception hierarchy for the Mobiska SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MobiskaError(Exception):
|
|
5
|
+
"""Base exception for all Mobiska SDK errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(MobiskaError):
|
|
11
|
+
"""Raised when API authentication fails (401/403)."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(MobiskaError):
|
|
17
|
+
"""Raised when a resource is not found (404)."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RateLimitError(MobiskaError):
|
|
23
|
+
"""Raised when the API rate limit is exceeded (429)."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(MobiskaError):
|
|
29
|
+
"""Raised when input validation fails before the request is sent."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIError(MobiskaError):
|
|
35
|
+
"""Raised for unexpected API errors (5xx or non-standard responses)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, status_code: int = None):
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status_code = status_code
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Payment resource — make payments, check status, and look up accounts."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from ..client import BaseClient
|
|
6
|
+
from ..exceptions import ValidationError
|
|
7
|
+
|
|
8
|
+
# Allowed enum-like values (kept as plain sets for zero-overhead validation)
|
|
9
|
+
_NETWORKS = {"MTN", "VOD", "AIR", "VIS", "MAS"}
|
|
10
|
+
_PAYMENT_OPTIONS = {"MOM", "CRD", "CRM"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PaymentClient(BaseClient):
|
|
14
|
+
"""Client for Mobiska Payment endpoints."""
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------
|
|
17
|
+
# make_payment → POST /make_payment
|
|
18
|
+
# ------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def make_payment(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
reference: str,
|
|
24
|
+
nickname: str,
|
|
25
|
+
transaction_id: str,
|
|
26
|
+
trans_type: str,
|
|
27
|
+
customer_number: str,
|
|
28
|
+
nw: str,
|
|
29
|
+
amount: float,
|
|
30
|
+
payment_option: str,
|
|
31
|
+
callback_url: str,
|
|
32
|
+
currency_code: str,
|
|
33
|
+
landing_page: Optional[str] = None,
|
|
34
|
+
currency_val: float = 1,
|
|
35
|
+
) -> Dict[str, str]:
|
|
36
|
+
"""Initiate a payment transaction.
|
|
37
|
+
|
|
38
|
+
:returns: The raw API response dict
|
|
39
|
+
(``response_code`` / ``response_message``).
|
|
40
|
+
"""
|
|
41
|
+
if nw not in _NETWORKS:
|
|
42
|
+
raise ValidationError(f"nw must be one of {sorted(_NETWORKS)}, got '{nw}'.")
|
|
43
|
+
if payment_option not in _PAYMENT_OPTIONS:
|
|
44
|
+
raise ValidationError(
|
|
45
|
+
f"payment_option must be one of {sorted(_PAYMENT_OPTIONS)}, "
|
|
46
|
+
f"got '{payment_option}'."
|
|
47
|
+
)
|
|
48
|
+
if amount <= 0:
|
|
49
|
+
raise ValidationError("amount must be greater than zero.")
|
|
50
|
+
|
|
51
|
+
extra: Dict[str, Any] = dict(
|
|
52
|
+
reference=reference,
|
|
53
|
+
nickname=nickname,
|
|
54
|
+
transaction_id=transaction_id,
|
|
55
|
+
trans_type=trans_type,
|
|
56
|
+
customer_number=customer_number,
|
|
57
|
+
nw=nw,
|
|
58
|
+
amount=amount,
|
|
59
|
+
payment_option=payment_option,
|
|
60
|
+
callback_url=callback_url,
|
|
61
|
+
currency_code=currency_code,
|
|
62
|
+
currency_val=currency_val,
|
|
63
|
+
request_time=self._request_time(),
|
|
64
|
+
)
|
|
65
|
+
if landing_page:
|
|
66
|
+
extra["landing_page"] = landing_page
|
|
67
|
+
|
|
68
|
+
payload = self._build_payload(**extra)
|
|
69
|
+
return self._post("make_payment", payload)
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# check_transaction_status → POST /check_transaction_status
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def check_transaction_status(self, transaction_id: str) -> Dict[str, Any]:
|
|
76
|
+
"""Check the status of a payment transaction.
|
|
77
|
+
|
|
78
|
+
:returns: The raw API response dict
|
|
79
|
+
(``response_data`` contains ``transaction_id``,
|
|
80
|
+
``status_desc``, ``status``).
|
|
81
|
+
"""
|
|
82
|
+
if not transaction_id:
|
|
83
|
+
raise ValidationError("transaction_id must not be empty.")
|
|
84
|
+
|
|
85
|
+
payload = self._build_payload(transaction_id=transaction_id)
|
|
86
|
+
return self._post("check_transaction_status", payload)
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# payment_account_lookup → POST /payment_account_lookup
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def payment_account_lookup(
|
|
93
|
+
self,
|
|
94
|
+
pan: Optional[str] = None,
|
|
95
|
+
nw: Optional[str] = None,
|
|
96
|
+
accounts: Optional[List[Dict[str, str]]] = None,
|
|
97
|
+
) -> Dict[str, Any]:
|
|
98
|
+
"""Look up payment account details (single or multiple).
|
|
99
|
+
|
|
100
|
+
Pass ``pan`` + ``nw`` for a single lookup, or ``accounts`` (a list
|
|
101
|
+
of ``{"pan": …, "nw": …}`` dicts) for a batch lookup.
|
|
102
|
+
|
|
103
|
+
:returns: The raw API response dict.
|
|
104
|
+
"""
|
|
105
|
+
if accounts is not None:
|
|
106
|
+
return self._lookup_multiple(accounts)
|
|
107
|
+
|
|
108
|
+
if not pan or not nw:
|
|
109
|
+
raise ValidationError(
|
|
110
|
+
"Provide pan + nw for a single lookup, or accounts for a "
|
|
111
|
+
"batch lookup."
|
|
112
|
+
)
|
|
113
|
+
if nw not in _NETWORKS:
|
|
114
|
+
raise ValidationError(f"nw must be one of {sorted(_NETWORKS)}, got '{nw}'.")
|
|
115
|
+
return self._lookup_single(pan, nw)
|
|
116
|
+
|
|
117
|
+
# -- private helpers ----------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _lookup_single(self, pan: str, nw: str) -> Dict[str, Any]:
|
|
120
|
+
payload = self._build_payload(pan=pan, nw=nw)
|
|
121
|
+
return self._post("payment_account_lookup", payload)
|
|
122
|
+
|
|
123
|
+
def _lookup_multiple(self, accounts: List[Dict[str, str]]) -> Dict[str, Any]:
|
|
124
|
+
if not accounts:
|
|
125
|
+
raise ValidationError("accounts list must not be empty.")
|
|
126
|
+
|
|
127
|
+
for idx, acct in enumerate(accounts):
|
|
128
|
+
if not acct.get("pan") or not acct.get("nw"):
|
|
129
|
+
raise ValidationError(
|
|
130
|
+
f"accounts[{idx}] must contain both 'pan' and 'nw'."
|
|
131
|
+
)
|
|
132
|
+
if acct["nw"] not in _NETWORKS:
|
|
133
|
+
raise ValidationError(
|
|
134
|
+
f"accounts[{idx}].nw must be one of "
|
|
135
|
+
f"{sorted(_NETWORKS)}, got '{acct['nw']}'."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
payload = self._build_payload(accounts=accounts)
|
|
139
|
+
return self._post("payment_account_lookup", payload)
|
mobiska/resources/sms.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""SMS resource — send messages and check balance."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, List, Union
|
|
6
|
+
|
|
7
|
+
from ..client import BaseClient
|
|
8
|
+
from ..exceptions import ValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SMSClient(BaseClient):
|
|
12
|
+
"""Client for Mobiska SMS endpoints."""
|
|
13
|
+
|
|
14
|
+
# ------------------------------------------------------------------
|
|
15
|
+
# send_sms → POST /send_sms
|
|
16
|
+
# ------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def send_sms(
|
|
19
|
+
self,
|
|
20
|
+
phone: Union[str, List[str]],
|
|
21
|
+
message: str,
|
|
22
|
+
) -> Dict[str, str]:
|
|
23
|
+
"""Send an SMS to one or many recipients.
|
|
24
|
+
|
|
25
|
+
:param phone: A single phone number or a list of phone numbers.
|
|
26
|
+
:param message: The body of the SMS message.
|
|
27
|
+
:returns: The raw API response dict
|
|
28
|
+
(``response_code`` / ``response_message``).
|
|
29
|
+
"""
|
|
30
|
+
if not phone:
|
|
31
|
+
raise ValidationError("phone must not be empty.")
|
|
32
|
+
if not message:
|
|
33
|
+
raise ValidationError("message must not be empty.")
|
|
34
|
+
|
|
35
|
+
if not self.config.sender_id:
|
|
36
|
+
raise ValidationError(
|
|
37
|
+
"sender_id is required. Set MOBISKA_SMS_SENDER_ID or pass "
|
|
38
|
+
"sender_id when initializing the client."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
recipients = [phone] if isinstance(phone, str) else list(phone)
|
|
42
|
+
|
|
43
|
+
payload = self._build_payload(
|
|
44
|
+
sender_id=self.config.sender_id,
|
|
45
|
+
msg_body=message,
|
|
46
|
+
recipient_number=recipients,
|
|
47
|
+
unique_id=str(uuid.uuid4()),
|
|
48
|
+
)
|
|
49
|
+
return self._post("send_sms", payload)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# check_balance → POST /check_sms_balance
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def check_balance(self, date: datetime = None) -> Dict[str, object]:
|
|
56
|
+
"""Retrieve the current SMS balance.
|
|
57
|
+
|
|
58
|
+
:param date: Optional datetime used to generate ``request_time``.
|
|
59
|
+
Defaults to the current UTC time.
|
|
60
|
+
:returns: The raw API response dict
|
|
61
|
+
(``response_code`` / ``response_message`` /
|
|
62
|
+
``response_data`` with ``sms_balance`` and ``service_id``).
|
|
63
|
+
"""
|
|
64
|
+
request_time = (
|
|
65
|
+
date.strftime("%Y-%m-%dT%H:%M:%SZ") if date else self._request_time()
|
|
66
|
+
)
|
|
67
|
+
payload = self._build_payload(request_time=request_time)
|
|
68
|
+
return self._post("check_sms_balance", payload)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobiska
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Mobiska REST API
|
|
5
|
+
Author-email: Mobiska <dev@mobiska.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Your Name
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/mobiska/mobiska-py
|
|
29
|
+
Project-URL: Documentation, https://github.com/mobiska/mobiska-py#readme
|
|
30
|
+
Project-URL: Issues, https://github.com/mobiska/mobiska-py/issues
|
|
31
|
+
Keywords: mobiska,sdk,api,client
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
37
|
+
Requires-Python: >=3.8
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: requests>=2.28
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
43
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
44
|
+
Requires-Dist: responses; extra == "dev"
|
|
45
|
+
Requires-Dist: black; extra == "dev"
|
|
46
|
+
Requires-Dist: ruff; extra == "dev"
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
# Mobiska Python SDK
|
|
50
|
+
|
|
51
|
+
The official Python SDK for the Mobiska REST API — payments and SMS for businesses across Africa.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install mobiska
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### Configuration
|
|
62
|
+
|
|
63
|
+
Credentials can be provided via keyword arguments **or** environment variables:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
MOBISKA_USER_NAME=your_client_key
|
|
67
|
+
MOBISKA_PASSWORD=your_secret_key
|
|
68
|
+
MOBISKA_SERVICE_ID=1
|
|
69
|
+
MOBISKA_SMS_SENDER_ID=YourSender # SMS only
|
|
70
|
+
MOBISKA_API_URL=https://api.mobiska.com # optional, this is the default
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### SMS
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from mobiska import InitSMS
|
|
77
|
+
|
|
78
|
+
# Uses env vars when no arguments are passed
|
|
79
|
+
sms = InitSMS()
|
|
80
|
+
|
|
81
|
+
# Or pass credentials explicitly
|
|
82
|
+
sms = InitSMS(
|
|
83
|
+
username="your_client_key",
|
|
84
|
+
password="your_secret_key",
|
|
85
|
+
service_id=1,
|
|
86
|
+
sender_id="YourSender",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Send a message
|
|
90
|
+
response = sms.send_sms("0540000000", "Welcome to Mobiska!")
|
|
91
|
+
print(response["response_code"])
|
|
92
|
+
|
|
93
|
+
# Send to multiple recipients
|
|
94
|
+
sms.send_sms(["0540000000", "0200000000"], "Batch message")
|
|
95
|
+
|
|
96
|
+
# Check balance
|
|
97
|
+
balance = sms.check_balance()
|
|
98
|
+
print(balance["response_data"]["sms_balance"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Payments
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from mobiska import InitPayment
|
|
105
|
+
|
|
106
|
+
payment = InitPayment(
|
|
107
|
+
username="your_client_key",
|
|
108
|
+
password="your_secret_key",
|
|
109
|
+
service_id=2,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Make a payment
|
|
113
|
+
result = payment.make_payment(
|
|
114
|
+
reference="Order #123",
|
|
115
|
+
nickname="MyShop",
|
|
116
|
+
transaction_id="txn_001",
|
|
117
|
+
trans_type="CTM",
|
|
118
|
+
customer_number="0541840988",
|
|
119
|
+
nw="MTN",
|
|
120
|
+
amount=100,
|
|
121
|
+
payment_option="MOM",
|
|
122
|
+
callback_url="https://example.com/webhook",
|
|
123
|
+
currency_code="GHS",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Check transaction status
|
|
127
|
+
status = payment.check_transaction_status("txn_001")
|
|
128
|
+
print(status["response_data"]["status"])
|
|
129
|
+
|
|
130
|
+
# Account lookup (single)
|
|
131
|
+
info = payment.payment_account_lookup(pan="0541840988", nw="MTN")
|
|
132
|
+
print(info["response_data"]["name"])
|
|
133
|
+
|
|
134
|
+
# Account lookup (batch)
|
|
135
|
+
accounts = payment.payment_account_lookup(accounts=[
|
|
136
|
+
{"pan": "0541840988", "nw": "MTN"},
|
|
137
|
+
{"pan": "0201234567", "nw": "VOD"},
|
|
138
|
+
])
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Advanced options
|
|
142
|
+
|
|
143
|
+
Both `InitSMS` and `InitPayment` accept `timeout`, `retry_count`, and `retry_delay`:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
sms = InitSMS(timeout=10, retry_count=3, retry_delay=0.5)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Error Handling
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from mobiska import (
|
|
153
|
+
AuthenticationError,
|
|
154
|
+
NotFoundError,
|
|
155
|
+
RateLimitError,
|
|
156
|
+
ValidationError,
|
|
157
|
+
APIError,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
payment.make_payment(...)
|
|
162
|
+
except ValidationError as e:
|
|
163
|
+
print(f"Invalid input: {e}")
|
|
164
|
+
except AuthenticationError:
|
|
165
|
+
print("Check your credentials.")
|
|
166
|
+
except RateLimitError:
|
|
167
|
+
print("Slow down — rate limit hit.")
|
|
168
|
+
except APIError as e:
|
|
169
|
+
print(f"Server error ({e.status_code}): {e}")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# Create a virtual environment and install
|
|
176
|
+
python3 -m venv .venv
|
|
177
|
+
source .venv/bin/activate
|
|
178
|
+
pip install -e ".[dev]"
|
|
179
|
+
|
|
180
|
+
# Run tests
|
|
181
|
+
pytest
|
|
182
|
+
|
|
183
|
+
# Format code
|
|
184
|
+
black mobiska tests
|
|
185
|
+
|
|
186
|
+
# Lint
|
|
187
|
+
ruff check mobiska tests
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
193
|
+
|
|
194
|
+
# Mobiska Python SDK
|
|
195
|
+
|
|
196
|
+
The official Python SDK for the Mobiska REST API.
|
|
197
|
+
|
|
198
|
+
## Installation
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
pip install mobiska
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Quick Start
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from mobiska import MobiskaClient
|
|
208
|
+
|
|
209
|
+
client = MobiskaClient(api_key="your-api-key")
|
|
210
|
+
|
|
211
|
+
# GET request
|
|
212
|
+
users = client.get("/users")
|
|
213
|
+
|
|
214
|
+
# POST request
|
|
215
|
+
new_user = client.post("/users", json={"name": "Alice"})
|
|
216
|
+
|
|
217
|
+
# PATCH request
|
|
218
|
+
client.patch(f"/users/{new_user['id']}", json={"name": "Alice Updated"})
|
|
219
|
+
|
|
220
|
+
# DELETE request
|
|
221
|
+
client.delete(f"/users/{new_user['id']}")
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Error Handling
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from mobiska import MobiskaClient, AuthenticationError, NotFoundError, RateLimitError
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
client.get("/users/999")
|
|
231
|
+
except AuthenticationError:
|
|
232
|
+
print("Check your API key.")
|
|
233
|
+
except NotFoundError:
|
|
234
|
+
print("Resource not found.")
|
|
235
|
+
except RateLimitError:
|
|
236
|
+
print("Slow down — rate limit hit.")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# Install with dev dependencies
|
|
243
|
+
pip install -e ".[dev]"
|
|
244
|
+
|
|
245
|
+
# Run tests
|
|
246
|
+
pytest
|
|
247
|
+
|
|
248
|
+
# Format code
|
|
249
|
+
black mobiska tests
|
|
250
|
+
|
|
251
|
+
# Lint
|
|
252
|
+
ruff check mobiska tests
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## License
|
|
256
|
+
|
|
257
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
mobiska/__init__.py,sha256=BFHHkfFvKuzto3eFdCflC9W5DK3xe0x7vglquWXTAVw,2408
|
|
2
|
+
mobiska/auth.py,sha256=nD-lN7S1BsbTYzEu-LxE2MJHWg5EwThZzDYeZ1bGon8,1075
|
|
3
|
+
mobiska/client.py,sha256=s-FHhHWnrvvz8RcBmkFze0sU8GGfWw0tp4UYF-i7bBU,4250
|
|
4
|
+
mobiska/config.py,sha256=a17isBrBH6MWDH6yyzghBp4arnP9kMevTcy_oN9sJq8,2228
|
|
5
|
+
mobiska/exceptions/__init__.py,sha256=KHrcKeZE6e2UR19bmgK3Bxtyqa6sZxJD09IN9PDqHZM,829
|
|
6
|
+
mobiska/resources/__init__.py,sha256=VXwQazZx-4Bdob8-59ZU11iRuFHgE581loXGwLT2UCo,145
|
|
7
|
+
mobiska/resources/payment.py,sha256=F-FgM_aTiHbesrjro0AQNjAnqyht5I79t6wPe6gKzMo,5183
|
|
8
|
+
mobiska/resources/sms.py,sha256=99X67DTK071bMHbf2fJMgXa_Q4NRFUZNaYuZY9ypaqM,2481
|
|
9
|
+
mobiska-0.1.0.dist-info/licenses/LICENSE,sha256=v2spsd7N1pKFFh2G8wGP_45iwe5S0DYiJzG4im8Rupc,1066
|
|
10
|
+
mobiska-0.1.0.dist-info/METADATA,sha256=CIAcQkTE_p38p4_fRs_-Ow13Uls5eoHSBG-KCSci0aI,6134
|
|
11
|
+
mobiska-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
mobiska-0.1.0.dist-info/top_level.txt,sha256=47440n5ee_9T0Xgot0DuzYlR-JtLfS01mFs11gEJCPo,8
|
|
13
|
+
mobiska-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
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 @@
|
|
|
1
|
+
mobiska
|