bml-connect-python 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.
@@ -0,0 +1,79 @@
1
+ """
2
+ BML Connect Python SDK
3
+ =====================
4
+
5
+ Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support.
6
+
7
+ Features:
8
+ - Create, retrieve, and list transactions
9
+ - Verify webhook signatures
10
+ - Supports both production and sandbox environments
11
+ - Full sync/async compatibility
12
+
13
+ Basic Usage:
14
+ from bml_connect import BMLConnect, Environment
15
+
16
+ # Sync client
17
+ client = BMLConnect('your-api-key', 'your-app-id', Environment.SANDBOX)
18
+ transaction = client.transactions.create_transaction({...})
19
+
20
+ # Async client
21
+ async_client = BMLConnect('your-api-key', 'your-app-id', Environment.SANDBOX, async_mode=True)
22
+ transaction = await async_client.transactions.create_transaction({...})
23
+
24
+ For detailed documentation and examples, visit:
25
+ https://github.com/quillfires/bml-connect-python
26
+ """
27
+
28
+ __version__ = "1.0.0"
29
+ __author__ = "Ali Fayaz"
30
+ __email__ = "fayaz.quill@gmail.com"
31
+
32
+ from .client import (
33
+ BMLConnect,
34
+ Transaction,
35
+ QRCode,
36
+ PaginatedResponse,
37
+ Environment,
38
+ SignMethod,
39
+ TransactionState,
40
+ BMLConnectError,
41
+ AuthenticationError,
42
+ ValidationError,
43
+ NotFoundError,
44
+ ServerError,
45
+ RateLimitError,
46
+ SignatureUtils
47
+ )
48
+
49
+ __all__ = [
50
+ 'BMLConnect',
51
+ 'Transaction',
52
+ 'QRCode',
53
+ 'PaginatedResponse',
54
+ 'Environment',
55
+ 'SignMethod',
56
+ 'TransactionState',
57
+ 'BMLConnectError',
58
+ 'AuthenticationError',
59
+ 'ValidationError',
60
+ 'NotFoundError',
61
+ 'ServerError',
62
+ 'RateLimitError',
63
+ 'SignatureUtils'
64
+ ]
65
+
66
+ BMLConnect.__module__ = "bml_connect"
67
+ Transaction.__module__ = "bml_connect"
68
+ QRCode.__module__ = "bml_connect"
69
+ PaginatedResponse.__module__ = "bml_connect"
70
+ Environment.__module__ = "bml_connect"
71
+ SignMethod.__module__ = "bml_connect"
72
+ TransactionState.__module__ = "bml_connect"
73
+ BMLConnectError.__module__ = "bml_connect"
74
+ AuthenticationError.__module__ = "bml_connect"
75
+ ValidationError.__module__ = "bml_connect"
76
+ NotFoundError.__module__ = "bml_connect"
77
+ ServerError.__module__ = "bml_connect"
78
+ RateLimitError.__module__ = "bml_connect"
79
+ SignatureUtils.__module__ = "bml_connect"
bml_connect/client.py ADDED
@@ -0,0 +1,536 @@
1
+ """
2
+ BML Connect Python SDK
3
+ ======================
4
+
5
+ Robust Python SDK for Bank of Maldives Connect API with comprehensive sync/async support.
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ import base64
11
+ import json
12
+ import uuid
13
+ from typing import Optional, Dict, Any, Union, List
14
+ from urllib.parse import urlencode
15
+ import requests
16
+ import aiohttp
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+ import logging
20
+
21
+ logger = logging.getLogger("bml_connect")
22
+ logger.addHandler(logging.NullHandler())
23
+
24
+ SDK_VERSION = "1.0.0"
25
+ USER_AGENT = f"BML-Connect-Python/{SDK_VERSION}"
26
+
27
+
28
+ class Environment(Enum):
29
+ SANDBOX = "sandbox"
30
+ PRODUCTION = "production"
31
+
32
+ @property
33
+ def base_url(self) -> str:
34
+ return {
35
+ Environment.SANDBOX: "https://api.uat.merchants.bankofmaldives.com.mv/public",
36
+ Environment.PRODUCTION: "https://api.merchants.bankofmaldives.com.mv/public"
37
+ }[self]
38
+
39
+
40
+ class SignMethod(Enum):
41
+ SHA1 = "sha1"
42
+ MD5 = "md5"
43
+
44
+ @classmethod
45
+ def _missing_(cls, value: str) -> 'SignMethod':
46
+ value = value.lower()
47
+ for member in cls:
48
+ if member.value == value:
49
+ return member
50
+ return cls.SHA1 # Default to SHA1
51
+
52
+
53
+ class TransactionState(Enum):
54
+ CREATED = "CREATED"
55
+ QR_CODE_GENERATED = "QR_CODE_GENERATED"
56
+ CONFIRMED = "CONFIRMED"
57
+ CANCELLED = "CANCELLED"
58
+ FAILED = "FAILED"
59
+ EXPIRED = "EXPIRED"
60
+
61
+
62
+ @dataclass
63
+ class QRCode:
64
+ url: str
65
+ image: Optional[str] = None # Base64 encoded image if available
66
+
67
+
68
+ @dataclass
69
+ class Transaction:
70
+ transaction_id: Optional[str] = None
71
+ local_id: Optional[str] = None
72
+ customer_reference: Optional[str] = None
73
+ amount: Optional[int] = None
74
+ currency: Optional[str] = None
75
+ provider: Optional[str] = None
76
+ state: Optional[TransactionState] = None
77
+ created: Optional[str] = None
78
+ signature: Optional[str] = None
79
+ url: Optional[str] = None
80
+ qr_code: Optional[QRCode] = None
81
+ redirect_url: Optional[str] = None
82
+ app_version: Optional[str] = None
83
+ api_version: Optional[str] = None
84
+ device_id: Optional[str] = None
85
+ sign_method: Optional[SignMethod] = None
86
+ expires_at: Optional[str] = None
87
+
88
+ @classmethod
89
+ def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
90
+ qr_code = None
91
+ qr_data = data.get('qrCode', {})
92
+ if qr_data and 'url' in qr_data:
93
+ qr_code = QRCode(
94
+ url=qr_data['url'],
95
+ image=qr_data.get('image')
96
+ )
97
+
98
+ # Parse state enum
99
+ state = None
100
+ if 'state' in data:
101
+ try:
102
+ state = TransactionState(data['state'])
103
+ except ValueError:
104
+ logger.warning(f"Unknown transaction state: {data['state']}")
105
+ state = None
106
+
107
+ # Parse sign method enum
108
+ sign_method = None
109
+ if 'signMethod' in data:
110
+ try:
111
+ sign_method = SignMethod(data['signMethod'])
112
+ except ValueError:
113
+ logger.warning(f"Unknown sign method: {data['signMethod']}")
114
+ sign_method = None
115
+
116
+ return cls(
117
+ transaction_id=data.get('transactionId'),
118
+ local_id=data.get('localId'),
119
+ customer_reference=data.get('customerReference'),
120
+ amount=data.get('amount'),
121
+ currency=data.get('currency'),
122
+ provider=data.get('provider'),
123
+ state=state,
124
+ created=data.get('created'),
125
+ signature=data.get('signature'),
126
+ url=data.get('url'),
127
+ qr_code=qr_code,
128
+ redirect_url=data.get('redirectUrl'),
129
+ app_version=data.get('appVersion'),
130
+ api_version=data.get('apiVersion'),
131
+ device_id=data.get('deviceId'),
132
+ sign_method=sign_method,
133
+ expires_at=data.get('expiresAt')
134
+ )
135
+
136
+
137
+ @dataclass
138
+ class PaginatedResponse:
139
+ count: int
140
+ items: List[Transaction]
141
+ current_page: int
142
+ total_pages: int
143
+
144
+ @classmethod
145
+ def from_dict(cls, data: Dict[str, Any]) -> 'PaginatedResponse':
146
+ items = [Transaction.from_dict(item) for item in data.get('items', [])]
147
+ return cls(
148
+ count=data.get('count', 0),
149
+ items=items,
150
+ current_page=data.get('currentPage', 1),
151
+ total_pages=data.get('totalPages', 1)
152
+ )
153
+
154
+
155
+ class BMLConnectError(Exception):
156
+ """Base exception for BML Connect errors"""
157
+ def __init__(self, message: str, code: Optional[str] = None, status_code: Optional[int] = None):
158
+ self.message = message
159
+ self.code = code
160
+ self.status_code = status_code
161
+ super().__init__(self.message)
162
+
163
+ def __str__(self) -> str:
164
+ if self.code:
165
+ return f"[{self.code}] {self.message}"
166
+ return self.message
167
+
168
+
169
+ class AuthenticationError(BMLConnectError):
170
+ pass
171
+
172
+
173
+ class ValidationError(BMLConnectError):
174
+ pass
175
+
176
+
177
+ class NotFoundError(BMLConnectError):
178
+ pass
179
+
180
+
181
+ class ServerError(BMLConnectError):
182
+ pass
183
+
184
+
185
+ class RateLimitError(BMLConnectError):
186
+ pass
187
+
188
+
189
+ class SignatureUtils:
190
+ @staticmethod
191
+ def generate_signature(data: Dict[str, Any], api_key: str, method: Union[SignMethod, str] = SignMethod.SHA1) -> str:
192
+ """Generate signature with proper key sorting and encoding"""
193
+ if isinstance(method, str):
194
+ try:
195
+ method = SignMethod(method)
196
+ except ValueError:
197
+ method = SignMethod.SHA1
198
+ logger.warning(f"Invalid sign method '{method}', defaulting to SHA1")
199
+
200
+ # Filtering None values and empty strings
201
+ filtered_data = {k: v for k, v in data.items() if v is not None and v != ""}
202
+
203
+ sorted_params = sorted(filtered_data.items(), key=lambda x: x[0].lower())
204
+ query_string = urlencode(sorted_params, doseq=True)
205
+
206
+ signature_string = f"{query_string}&apiKey={api_key}"
207
+
208
+ if method == SignMethod.SHA1:
209
+ return hashlib.sha1(signature_string.encode('utf-8')).hexdigest()
210
+ elif method == SignMethod.MD5:
211
+ return base64.b64encode(hashlib.md5(signature_string.encode('utf-8')).digest()).decode()
212
+ else:
213
+ raise ValueError(f"Unsupported signature method: {method}")
214
+
215
+ @staticmethod
216
+ def verify_signature(data: Dict[str, Any], signature: str, api_key: str, method: Union[SignMethod, str] = SignMethod.SHA1) -> bool:
217
+ """Secure signature verification with constant-time comparison"""
218
+ expected = SignatureUtils.generate_signature(data, api_key, method)
219
+ return hmac.compare_digest(expected, signature)
220
+
221
+
222
+ class BaseClient:
223
+ def __init__(self, api_key: str, app_id: str, environment: Environment = Environment.PRODUCTION):
224
+ self.api_key = api_key
225
+ self.app_id = app_id
226
+ self.environment = environment
227
+ self.base_url = environment.base_url
228
+ self.session = None # Will be set in child classes
229
+ logger.info(f"Initialized BML Client for {environment.name} environment")
230
+
231
+ def _get_headers(self) -> Dict[str, str]:
232
+ return {
233
+ 'Authorization': f"Bearer {self.api_key}",
234
+ 'Content-Type': 'application/json',
235
+ 'User-Agent': USER_AGENT,
236
+ 'X-App-Id': self.app_id
237
+ }
238
+
239
+ def _prepare_transaction_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
240
+ """Prepare transaction data with required defaults"""
241
+ prepared = data.copy()
242
+ prepared.setdefault('apiVersion', '2.0')
243
+ prepared.setdefault('signMethod', 'sha1')
244
+ prepared.setdefault('appVersion', USER_AGENT)
245
+ prepared.setdefault('deviceId', str(uuid.uuid4()))
246
+ return prepared
247
+
248
+ def _handle_response(self, response: Union[requests.Response, aiohttp.ClientResponse], response_data: Dict[str, Any]):
249
+ """Handle API response with proper error mapping"""
250
+ status_code = response.status if isinstance(response, aiohttp.ClientResponse) else response.status_code
251
+ message = response_data.get('message', 'Unknown error')
252
+ code = response_data.get('code')
253
+
254
+ logger.debug(f"API Response: {status_code} - {message}")
255
+
256
+ if status_code == 400:
257
+ raise ValidationError(message, code, status_code)
258
+ elif status_code == 401:
259
+ raise AuthenticationError(message, code, status_code)
260
+ elif status_code == 404:
261
+ raise NotFoundError(message, code, status_code)
262
+ elif status_code == 429:
263
+ raise RateLimitError(message, code, status_code)
264
+ elif status_code >= 500:
265
+ raise ServerError(message, code, status_code)
266
+ elif status_code >= 400:
267
+ raise BMLConnectError(message, code, status_code)
268
+
269
+
270
+ class SyncClient(BaseClient):
271
+ def __init__(self, *args, **kwargs):
272
+ super().__init__(*args, **kwargs)
273
+ self.session = requests.Session()
274
+ self.session.headers.update(self._get_headers())
275
+ logger.debug("Initialized synchronous HTTP session")
276
+
277
+ def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
278
+ url = f"{self.base_url}{endpoint}"
279
+ logger.debug(f"Request: {method} {url} {kwargs.get('params')}")
280
+
281
+ try:
282
+ response = self.session.request(
283
+ method,
284
+ url,
285
+ timeout=30,
286
+ **kwargs
287
+ )
288
+
289
+ try:
290
+ response_data = response.json()
291
+ except json.JSONDecodeError:
292
+ logger.error(f"Invalid JSON response: {response.text[:500]}")
293
+ raise ServerError("Invalid JSON response", status_code=response.status_code)
294
+
295
+ logger.debug(f"Response: {response.status_code} - {response_data}")
296
+
297
+ if not response.ok:
298
+ self._handle_response(response, response_data)
299
+
300
+ return response_data
301
+ except requests.exceptions.RequestException as e:
302
+ logger.error(f"Network error: {str(e)}")
303
+ raise BMLConnectError(f"Network error: {str(e)}")
304
+
305
+ def create_transaction(self, data: Dict[str, Any]) -> Transaction:
306
+ logger.info("Creating new transaction")
307
+ data = self._prepare_transaction_data(data)
308
+
309
+ try:
310
+ sign_method = SignMethod(data.get('signMethod', 'sha1'))
311
+ except ValueError:
312
+ sign_method = SignMethod.SHA1
313
+ logger.warning(f"Invalid sign method, defaulting to SHA1")
314
+
315
+ data['signature'] = SignatureUtils.generate_signature(data, self.api_key, sign_method)
316
+
317
+ response = self._request('POST', '/transactions', json=data)
318
+ return Transaction.from_dict(response)
319
+
320
+ def get_transaction(self, transaction_id: str) -> Transaction:
321
+ logger.info(f"Fetching transaction: {transaction_id}")
322
+ response = self._request('GET', f'/transactions/{transaction_id}')
323
+ return Transaction.from_dict(response)
324
+
325
+ def list_transactions(
326
+ self,
327
+ page: int = 1,
328
+ per_page: int = 20,
329
+ state: Optional[str] = None,
330
+ provider: Optional[str] = None,
331
+ start_date: Optional[str] = None,
332
+ end_date: Optional[str] = None
333
+ ) -> PaginatedResponse:
334
+ logger.info(f"Listing transactions: page={page}, per_page={per_page}")
335
+ params = {'page': page, 'perPage': per_page}
336
+
337
+ # Add filters
338
+ if state:
339
+ params['state'] = state
340
+ if provider:
341
+ params['provider'] = provider
342
+ if start_date:
343
+ params['startDate'] = start_date
344
+ if end_date:
345
+ params['endDate'] = end_date
346
+
347
+ response = self._request('GET', '/transactions', params=params)
348
+ return PaginatedResponse.from_dict(response)
349
+
350
+ def close(self):
351
+ """Close the HTTP session"""
352
+ if self.session:
353
+ self.session.close()
354
+ logger.debug("Closed synchronous HTTP session")
355
+
356
+
357
+ class AsyncClient(BaseClient):
358
+ def __init__(self, *args, **kwargs):
359
+ super().__init__(*args, **kwargs)
360
+ self.session = aiohttp.ClientSession(
361
+ headers=self._get_headers(),
362
+ timeout=aiohttp.ClientTimeout(total=30)
363
+ )
364
+ logger.debug("Initialized asynchronous HTTP session")
365
+
366
+ async def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
367
+ url = f"{self.base_url}{endpoint}"
368
+ logger.debug(f"Async Request: {method} {url} {kwargs.get('params')}")
369
+
370
+ try:
371
+ async with self.session.request(method, url, **kwargs) as response:
372
+ try:
373
+ response_data = await response.json()
374
+ except aiohttp.ContentTypeError:
375
+ text = await response.text()
376
+ logger.error(f"Invalid JSON response: {text[:500]}")
377
+ raise ServerError(f"Invalid JSON: {text[:200]}", status_code=response.status)
378
+
379
+ logger.debug(f"Async Response: {response.status} - {response_data}")
380
+
381
+ if not response.ok:
382
+ self._handle_response(response, response_data)
383
+
384
+ return response_data
385
+ except aiohttp.ClientError as e:
386
+ logger.error(f"Network error: {str(e)}")
387
+ raise BMLConnectError(f"Network error: {str(e)}")
388
+
389
+ async def create_transaction(self, data: Dict[str, Any]) -> Transaction:
390
+ logger.info("Creating new transaction (async)")
391
+ data = self._prepare_transaction_data(data)
392
+
393
+ try:
394
+ sign_method = SignMethod(data.get('signMethod', 'sha1'))
395
+ except ValueError:
396
+ sign_method = SignMethod.SHA1
397
+ logger.warning(f"Invalid sign method, defaulting to SHA1")
398
+
399
+ data['signature'] = SignatureUtils.generate_signature(data, self.api_key, sign_method)
400
+
401
+ response = await self._request('POST', '/transactions', json=data)
402
+ return Transaction.from_dict(response)
403
+
404
+ async def get_transaction(self, transaction_id: str) -> Transaction:
405
+ logger.info(f"Fetching transaction (async): {transaction_id}")
406
+ response = await self._request('GET', f'/transactions/{transaction_id}')
407
+ return Transaction.from_dict(response)
408
+
409
+ async def list_transactions(
410
+ self,
411
+ page: int = 1,
412
+ per_page: int = 20,
413
+ state: Optional[str] = None,
414
+ provider: Optional[str] = None,
415
+ start_date: Optional[str] = None,
416
+ end_date: Optional[str] = None
417
+ ) -> PaginatedResponse:
418
+ logger.info(f"Listing transactions (async): page={page}, per_page={per_page}")
419
+ params = {'page': page, 'perPage': per_page}
420
+
421
+ # Add filters
422
+ if state:
423
+ params['state'] = state
424
+ if provider:
425
+ params['provider'] = provider
426
+ if start_date:
427
+ params['startDate'] = start_date
428
+ if end_date:
429
+ params['endDate'] = end_date
430
+
431
+ response = await self._request('GET', '/transactions', params=params)
432
+ return PaginatedResponse.from_dict(response)
433
+
434
+ async def close(self):
435
+ """Close the HTTP session"""
436
+ if self.session and not self.session.closed:
437
+ await self.session.close()
438
+ logger.debug("Closed asynchronous HTTP session")
439
+
440
+
441
+ class BMLConnect:
442
+ def __init__(
443
+ self,
444
+ api_key: str,
445
+ app_id: str,
446
+ environment: Union[Environment, str] = Environment.PRODUCTION,
447
+ async_mode: bool = False
448
+ ):
449
+ """
450
+ Initialize BML Connect client
451
+
452
+ Args:
453
+ api_key: Your API key from BML merchant portal
454
+ app_id: Your application ID from BML merchant portal
455
+ environment: 'production' or 'sandbox' (default: production)
456
+ async_mode: Whether to use async operations (default: False)
457
+ """
458
+ self.api_key = api_key
459
+ self.app_id = app_id
460
+
461
+ if isinstance(environment, str):
462
+ try:
463
+ self.environment = Environment[environment.upper()]
464
+ except KeyError:
465
+ raise ValueError(f"Invalid environment: {environment}. Use 'production' or 'sandbox'")
466
+ else:
467
+ self.environment = environment
468
+
469
+ self.async_mode = async_mode
470
+
471
+ if async_mode:
472
+ self.client = AsyncClient(api_key, app_id, self.environment)
473
+ else:
474
+ self.client = SyncClient(api_key, app_id, self.environment)
475
+
476
+ @property
477
+ def transactions(self):
478
+ return self.client
479
+
480
+ def verify_webhook_signature(
481
+ self,
482
+ payload: Union[Dict[str, Any], str],
483
+ signature: str,
484
+ method: Union[SignMethod, str] = SignMethod.SHA1
485
+ ) -> bool:
486
+ """
487
+ Verify webhook signature for data integrity
488
+
489
+ Args:
490
+ payload: Webhook payload data (dict or JSON string)
491
+ signature: Received signature to verify
492
+ method: Signature method (default: SHA1)
493
+
494
+ Returns:
495
+ bool: True if signature is valid, False otherwise
496
+ """
497
+ if isinstance(payload, str):
498
+ try:
499
+ payload = json.loads(payload)
500
+ except json.JSONDecodeError:
501
+ raise ValidationError("Invalid JSON payload")
502
+
503
+ # Create a copy and remove signature if present
504
+ verification_payload = payload.copy()
505
+ if 'signature' in verification_payload:
506
+ del verification_payload['signature']
507
+
508
+ return SignatureUtils.verify_signature(verification_payload, signature, self.api_key, method)
509
+
510
+ def close(self):
511
+ """Clean up resources (synchronous)"""
512
+ if not self.async_mode and hasattr(self.client, 'close'):
513
+ self.client.close()
514
+
515
+ async def aclose(self):
516
+ """Clean up resources (asynchronous)"""
517
+ if self.async_mode and hasattr(self.client, 'close'):
518
+ await self.client.close()
519
+
520
+
521
+ __all__ = [
522
+ 'BMLConnect',
523
+ 'Transaction',
524
+ 'QRCode',
525
+ 'PaginatedResponse',
526
+ 'Environment',
527
+ 'SignMethod',
528
+ 'TransactionState',
529
+ 'BMLConnectError',
530
+ 'AuthenticationError',
531
+ 'ValidationError',
532
+ 'NotFoundError',
533
+ 'ServerError',
534
+ 'RateLimitError',
535
+ 'SignatureUtils'
536
+ ]
@@ -0,0 +1,279 @@
1
+ Metadata-Version: 2.4
2
+ Name: bml-connect-python
3
+ Version: 1.0.0
4
+ Summary: Python SDK for Bank of Maldives Connect API with sync/async support
5
+ Author-email: Ali Fayaz <fayaz.quill@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/quillfires/bml-connect-python
8
+ Project-URL: Documentation, https://bml-connect-python.readthedocs.io
9
+ Project-URL: Repository, https://github.com/quillfires/bml-connect-python
10
+ Project-URL: Issues, https://github.com/quillfires/bml-connect-python/issues
11
+ Project-URL: Changelog, https://github.com/quillfires/bml-connect-python/releases
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.7
17
+ Classifier: Programming Language :: Python :: 3.8
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: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
25
+ Requires-Python: >=3.7
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: requests>=2.28.0
29
+ Requires-Dist: aiohttp>=3.8.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
32
+ Requires-Dist: pytest-asyncio>=0.20.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
34
+ Requires-Dist: black>=22.0.0; extra == "dev"
35
+ Requires-Dist: flake8>=5.0.0; extra == "dev"
36
+ Requires-Dist: mypy>=0.990; extra == "dev"
37
+ Requires-Dist: pre-commit>=2.20.0; extra == "dev"
38
+ Requires-Dist: types-requests>=2.28.0; extra == "dev"
39
+ Provides-Extra: django
40
+ Requires-Dist: django>=3.2; extra == "django"
41
+ Provides-Extra: flask
42
+ Requires-Dist: flask>=2.0; extra == "flask"
43
+ Provides-Extra: fastapi
44
+ Requires-Dist: fastapi>=0.68.0; extra == "fastapi"
45
+ Requires-Dist: uvicorn>=0.15.0; extra == "fastapi"
46
+ Provides-Extra: sanic
47
+ Requires-Dist: sanic>=22.0.0; extra == "sanic"
48
+ Dynamic: license-file
49
+
50
+ # BML Connect Python SDK
51
+
52
+ [![PyPI Version](https://img.shields.io/pypi/v/bml-connect-python.svg)](https://pypi.org/project/bml-connect-python/)
53
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bml-connect-python.svg)](https://pypi.org/project/bml-connect-python/)
54
+ [![License](https://img.shields.io/pypi/l/bml-connect-python.svg)](https://opensource.org/licenses/MIT)
55
+
56
+ Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support.
57
+ Compatible with all Python frameworks including Django, Flask, FastAPI, and Sanic.
58
+
59
+ ---
60
+
61
+ ## Features
62
+
63
+ - **Sync/Async Support:** Choose your preferred programming style
64
+ - **Full API Coverage:** Transactions, webhooks, and signature verification
65
+ - **Type Annotations:** Full type hint support for better development experience
66
+ - **Error Handling:** Comprehensive error hierarchy for easy debugging
67
+ - **Framework Agnostic:** Works with any Python web framework
68
+ - **MIT Licensed:** Open source and free to use
69
+
70
+ ---
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install bml-connect-python
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Quick Start
81
+
82
+ ### Synchronous Client
83
+
84
+ ```python
85
+ from bml_connect import BMLConnect, Environment
86
+
87
+ client = BMLConnect(
88
+ api_key="your_api_key",
89
+ app_id="your_app_id",
90
+ environment=Environment.SANDBOX
91
+ )
92
+
93
+ try:
94
+ transaction = client.transactions.create_transaction({
95
+ "amount": 1500, # 15.00 MVR
96
+ "currency": "MVR",
97
+ "provider": "alipay",
98
+ "redirectUrl": "https://yourstore.com/success",
99
+ "localId": "order_123",
100
+ "customerReference": "Customer #456"
101
+ })
102
+ print(f"Transaction ID: {transaction.transaction_id}")
103
+ print(f"Payment URL: {transaction.url}")
104
+ except Exception as e:
105
+ print(f"Error: {e}")
106
+ finally:
107
+ client.close()
108
+ ```
109
+
110
+ ### Asynchronous Client
111
+
112
+ ```python
113
+ import asyncio
114
+ from bml_connect import BMLConnect, Environment
115
+
116
+ async def main():
117
+ client = BMLConnect(
118
+ api_key="your_api_key",
119
+ app_id="your_app_id",
120
+ environment=Environment.SANDBOX,
121
+ async_mode=True
122
+ )
123
+ try:
124
+ transaction = await client.transactions.create_transaction({
125
+ "amount": 2000,
126
+ "currency": "MVR",
127
+ "provider": "wechat",
128
+ "redirectUrl": "https://yourstore.com/success"
129
+ })
130
+ print(f"Transaction ID: {transaction.transaction_id}")
131
+ finally:
132
+ await client.aclose()
133
+
134
+ asyncio.run(main())
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Webhook Verification
140
+
141
+ ### Flask Example
142
+
143
+ ```python
144
+ from flask import Flask, request, jsonify
145
+ from bml_connect import BMLConnect
146
+
147
+ app = Flask(__name__)
148
+ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
149
+
150
+ @app.route('/webhook', methods=['POST'])
151
+ def webhook():
152
+ payload = request.get_json()
153
+ signature = payload.get('signature')
154
+ if client.verify_webhook_signature(payload, signature):
155
+ # Process webhook
156
+ return jsonify({"status": "success"}), 200
157
+ else:
158
+ return jsonify({"error": "Invalid signature"}), 403
159
+ ```
160
+
161
+ ### FastAPI Example
162
+
163
+ ```python
164
+ from fastapi import FastAPI, Request, HTTPException
165
+ from bml_connect import BMLConnect
166
+
167
+ app = FastAPI()
168
+ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
169
+
170
+ @app.post("/webhook")
171
+ async def handle_webhook(request: Request):
172
+ payload = await request.json()
173
+ signature = payload.get("signature")
174
+ if client.verify_webhook_signature(payload, signature):
175
+ return {"status": "success"}
176
+ else:
177
+ raise HTTPException(403, "Invalid signature")
178
+ ```
179
+
180
+ ### Sanic Example
181
+
182
+ ```python
183
+ from sanic import Sanic, response
184
+ from bml_connect import BMLConnect
185
+
186
+ app = Sanic("BMLWebhook")
187
+ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
188
+
189
+ @app.post('/webhook')
190
+ async def webhook(request):
191
+ payload = request.json
192
+ signature = payload.get('signature')
193
+ if client.verify_webhook_signature(payload, signature):
194
+ return response.json({"status": "success"})
195
+ else:
196
+ return response.json({"error": "Invalid signature"}, status=403)
197
+ ```
198
+
199
+ ---
200
+
201
+ ## API Reference
202
+
203
+ ### Main Classes
204
+
205
+ - `BMLConnect`: Main entry point for the SDK.
206
+ - `Transaction`: Transaction object.
207
+ - `QRCode`: QR code details.
208
+ - `PaginatedResponse`: For paginated transaction lists.
209
+ - `Environment`: Enum for `SANDBOX` and `PRODUCTION`.
210
+ - `SignMethod`: Enum for signature methods.
211
+ - `TransactionState`: Enum for transaction states.
212
+
213
+ ### Error Classes
214
+
215
+ - `BMLConnectError`
216
+ - `AuthenticationError`
217
+ - `ValidationError`
218
+ - `NotFoundError`
219
+ - `ServerError`
220
+ - `RateLimitError`
221
+
222
+ ### Utilities
223
+
224
+ - `SignatureUtils.generate_signature(data, api_key, method)`
225
+ - `SignatureUtils.verify_signature(data, signature, api_key, method)`
226
+
227
+ ---
228
+
229
+ ## Development
230
+
231
+ ### Requirements
232
+
233
+ - Python 3.7+
234
+ - See `requirements.txt` and `requirements-dev.txt` for dependencies.
235
+
236
+ ### Testing
237
+
238
+ ```bash
239
+ pip install -e .[dev]
240
+ pytest
241
+ ```
242
+
243
+ ### Formatting & Linting
244
+
245
+ ```bash
246
+ black .
247
+ flake8 .
248
+ mypy .
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Packaging
254
+
255
+ - Uses `pyproject.toml` for build configuration.
256
+ - Source code is in `src/bml_connect/`.
257
+ - Examples in `examples/`.
258
+ - Tests in `tests/`.
259
+
260
+ ---
261
+
262
+ ## License
263
+
264
+ MIT License. See `LICENSE` for details.
265
+
266
+ ---
267
+
268
+ ## Links
269
+
270
+ - [Homepage](https://github.com/bankofmaldives/bml-connect-python)
271
+ - [Documentation](https://bml-connect-python.readthedocs.io)
272
+ - [API Reference](docs/api_reference.md)
273
+ - [Changelog](https://github.com/bankofmaldives/bml-connect-python/releases)
274
+
275
+ ---
276
+
277
+ ## Contributing
278
+
279
+ Pull requests and issues are welcome! See the documentation for guidelines.
@@ -0,0 +1,7 @@
1
+ bml_connect/__init__.py,sha256=7dAgLGnl8f3GclavYhbyPsJY9KHskuKrDam59FTm1hE,2080
2
+ bml_connect/client.py,sha256=sEsO4pqBIs91f8AH0UJJLOlDZtqXARmT5KKIj4vym8U,18430
3
+ bml_connect_python-1.0.0.dist-info/licenses/LICENSE,sha256=CB2PmSDOuvgp-Rts5KdYBPZrjg5Vi2qkeoMMTGj1WiE,1104
4
+ bml_connect_python-1.0.0.dist-info/METADATA,sha256=q5RwJkQ6L24clG35tRsEsn8oCsLECtYrXyesWWTq1KY,7907
5
+ bml_connect_python-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ bml_connect_python-1.0.0.dist-info/top_level.txt,sha256=VfPbi_18vywwgVjitexLr0B1g1z0jjyAipDXCl_zh8I,12
7
+ bml_connect_python-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ali Fayaz (Quill) (quillfires)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ bml_connect