python-mytnb 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.
- mytnb/__init__.py +26 -0
- mytnb/__main__.py +5 -0
- mytnb/auth.py +67 -0
- mytnb/cli.py +379 -0
- mytnb/client/__init__.py +3 -0
- mytnb/client/auth.py +159 -0
- mytnb/client/client.py +239 -0
- mytnb/client/config.py +35 -0
- mytnb/client/legacy.py +120 -0
- mytnb/client/rest.py +117 -0
- mytnb/crypto.py +142 -0
- mytnb/exceptions.py +47 -0
- mytnb/models.py +236 -0
- python_mytnb-0.1.0.dist-info/METADATA +165 -0
- python_mytnb-0.1.0.dist-info/RECORD +18 -0
- python_mytnb-0.1.0.dist-info/WHEEL +4 -0
- python_mytnb-0.1.0.dist-info/entry_points.txt +2 -0
- python_mytnb-0.1.0.dist-info/licenses/LICENSE +21 -0
mytnb/exceptions.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Custom exceptions for the myTNB API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MyTNBError(Exception):
|
|
7
|
+
"""Base exception for myTNB API errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, error_code: str | None = None):
|
|
10
|
+
self.error_code = error_code
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(MyTNBError):
|
|
15
|
+
"""Raised when authentication fails."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIError(MyTNBError):
|
|
19
|
+
"""Raised when the API returns an error response."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
error_code: str | None = None,
|
|
25
|
+
display_message: str | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.display_message = display_message
|
|
28
|
+
super().__init__(message, error_code)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimitError(MyTNBError):
|
|
32
|
+
"""Raised when rate limited by the API."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GeoBlockedError(MyTNBError):
|
|
36
|
+
"""Raised when the request is blocked due to geographic restrictions.
|
|
37
|
+
|
|
38
|
+
The myTNB API only allows connections from Malaysian IP addresses
|
|
39
|
+
and blocks most VPNs.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
super().__init__(
|
|
44
|
+
"Access denied — the myTNB API is restricted to Malaysian IP addresses "
|
|
45
|
+
"and blocks VPN connections. Connect from a Malaysian network without a VPN.",
|
|
46
|
+
error_code="403",
|
|
47
|
+
)
|
mytnb/models.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Data models for myTNB API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TariffBlock(BaseModel):
|
|
11
|
+
"""A tariff block within a billing period."""
|
|
12
|
+
|
|
13
|
+
block_id: str = Field(alias="BlockId")
|
|
14
|
+
amount: float = Field(alias="Amount")
|
|
15
|
+
usage: float = Field(alias="Usage")
|
|
16
|
+
is_block_available: bool = Field(alias="IsBlockAvailable")
|
|
17
|
+
block_pricing: Optional[str] = Field(default=None, alias="BlockPricing")
|
|
18
|
+
peak_usage: Optional[str] = Field(default=None, alias="PeakUsage")
|
|
19
|
+
off_peak_usage: Optional[str] = Field(default=None, alias="OffPeakUsage")
|
|
20
|
+
start_date: Optional[str] = Field(default=None, alias="StartDate")
|
|
21
|
+
end_date: Optional[str] = Field(default=None, alias="EndDate")
|
|
22
|
+
|
|
23
|
+
model_config = {"populate_by_name": True}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Metric(BaseModel):
|
|
27
|
+
"""A usage or cost metric returned by the myTNB API."""
|
|
28
|
+
|
|
29
|
+
key: str = Field(alias="Key")
|
|
30
|
+
title: str = Field(alias="Title")
|
|
31
|
+
sub_title: str = Field(alias="SubTitle")
|
|
32
|
+
value: str = Field(alias="Value")
|
|
33
|
+
value_unit: str = Field(alias="ValueUnit")
|
|
34
|
+
value_indicator: str = Field(default="", alias="ValueIndicator")
|
|
35
|
+
|
|
36
|
+
model_config = {"populate_by_name": True}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def numeric_value(self) -> float:
|
|
40
|
+
try:
|
|
41
|
+
return float(self.value)
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
return 0.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
UsageMetric = Metric # backward-compatible alias
|
|
47
|
+
CostMetric = Metric # backward-compatible alias
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DailyUsage(BaseModel):
|
|
51
|
+
"""Daily electricity usage data."""
|
|
52
|
+
|
|
53
|
+
date: str = Field(alias="Date")
|
|
54
|
+
year: str = Field(alias="Year")
|
|
55
|
+
month: str = Field(alias="Month")
|
|
56
|
+
day: str = Field(alias="Day")
|
|
57
|
+
consumption: str = Field(alias="Consumption")
|
|
58
|
+
amount: str = Field(alias="Amount")
|
|
59
|
+
is_estimated_reading: bool = Field(default=False, alias="IsEstimatedReading")
|
|
60
|
+
is_missing_reading: bool = Field(default=False, alias="IsMissingReading")
|
|
61
|
+
is_current_bill_cycle: bool = Field(default=False, alias="IsCurrentBillCycle")
|
|
62
|
+
tariff_blocks: list[TariffBlock] = Field(default_factory=list, alias="tariffBlocks")
|
|
63
|
+
|
|
64
|
+
model_config = {"populate_by_name": True}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def consumption_kwh(self) -> float:
|
|
68
|
+
return float(self.consumption)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def amount_rm(self) -> float:
|
|
72
|
+
return float(self.amount)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DailyUsageWeek(BaseModel):
|
|
76
|
+
"""A week range of daily usage data."""
|
|
77
|
+
|
|
78
|
+
range: str = Field(alias="Range")
|
|
79
|
+
days: list[DailyUsage] = Field(default_factory=list, alias="Days")
|
|
80
|
+
|
|
81
|
+
model_config = {"populate_by_name": True}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RP4Usage(BaseModel):
|
|
85
|
+
"""RP4 tariff usage breakdown."""
|
|
86
|
+
|
|
87
|
+
block_id: str = Field(alias="BlockId")
|
|
88
|
+
amount: str = Field(alias="Amount")
|
|
89
|
+
usage: str = Field(alias="Usage")
|
|
90
|
+
is_block_available: bool = Field(default=False, alias="IsBlockAvailable")
|
|
91
|
+
peak_usage: Optional[str] = Field(default=None, alias="PeakUsage")
|
|
92
|
+
off_peak_usage: Optional[str] = Field(default=None, alias="OffPeakUsage")
|
|
93
|
+
start_date: Optional[str] = Field(default=None, alias="StartDate")
|
|
94
|
+
end_date: Optional[str] = Field(default=None, alias="EndDate")
|
|
95
|
+
|
|
96
|
+
model_config = {"populate_by_name": True}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MonthlyTariffBlock(BaseModel):
|
|
100
|
+
"""Tariff block for monthly billing."""
|
|
101
|
+
|
|
102
|
+
block_id: str = Field(alias="BlockId")
|
|
103
|
+
amount: float = Field(alias="Amount")
|
|
104
|
+
usage: float = Field(alias="Usage")
|
|
105
|
+
is_block_available: bool = Field(alias="IsBlockAvailable")
|
|
106
|
+
block_pricing: Optional[str] = Field(default=None, alias="BlockPricing")
|
|
107
|
+
rp4_usage: Optional[list[RP4Usage]] = Field(default=None, alias="RP4Usage")
|
|
108
|
+
|
|
109
|
+
model_config = {"populate_by_name": True}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BillingMonth(BaseModel):
|
|
113
|
+
"""Monthly billing data."""
|
|
114
|
+
|
|
115
|
+
billing_no: Optional[str] = Field(default=None, alias="BillingNo")
|
|
116
|
+
date: str = Field(alias="Date")
|
|
117
|
+
year: str = Field(alias="Year")
|
|
118
|
+
month: str = Field(alias="Month")
|
|
119
|
+
day: str = Field(alias="Day")
|
|
120
|
+
amount_total: str = Field(alias="AmountTotal")
|
|
121
|
+
usage_total: str = Field(alias="UsageTotal")
|
|
122
|
+
currency: str = Field(default="RM", alias="Currency")
|
|
123
|
+
usage_unit: str = Field(default="kWh", alias="UsageUnit")
|
|
124
|
+
is_estimated_reading: bool = Field(default=False, alias="IsEstimatedReading")
|
|
125
|
+
is_unbilled: bool = Field(default=False, alias="IsUnbilled")
|
|
126
|
+
tariff_blocks: list[MonthlyTariffBlock] = Field(
|
|
127
|
+
default_factory=list, alias="tariffBlocks"
|
|
128
|
+
)
|
|
129
|
+
billing_start_date: Optional[str] = Field(default=None, alias="BillingStartDate")
|
|
130
|
+
billing_end_date: Optional[str] = Field(default=None, alias="BillingEndDate")
|
|
131
|
+
has_periodic_billing: bool = Field(default=False, alias="HasPeriodicBilling")
|
|
132
|
+
|
|
133
|
+
model_config = {"populate_by_name": True}
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def amount_rm(self) -> float:
|
|
137
|
+
return float(self.amount_total)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def usage_kwh(self) -> float:
|
|
141
|
+
return float(self.usage_total)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ByMonthData(BaseModel):
|
|
145
|
+
"""Monthly billing history."""
|
|
146
|
+
|
|
147
|
+
range: str = Field(alias="Range")
|
|
148
|
+
months: list[BillingMonth] = Field(default_factory=list, alias="Months")
|
|
149
|
+
|
|
150
|
+
model_config = {"populate_by_name": True}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AccountUsage(BaseModel):
|
|
154
|
+
"""Full account usage response from GetAccountUsageSmart."""
|
|
155
|
+
|
|
156
|
+
usage_metrics: list[Metric] = Field(default_factory=list)
|
|
157
|
+
cost_metrics: list[Metric] = Field(default_factory=list)
|
|
158
|
+
current_cycle_start_date: Optional[str] = None
|
|
159
|
+
by_month: Optional[ByMonthData] = None
|
|
160
|
+
by_day: list[DailyUsageWeek] = Field(default_factory=list)
|
|
161
|
+
start_date: Optional[str] = None
|
|
162
|
+
end_date: Optional[str] = None
|
|
163
|
+
date_range: Optional[str] = None
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_api_response(cls, data: dict) -> "AccountUsage":
|
|
167
|
+
"""Parse the API response data into an AccountUsage model."""
|
|
168
|
+
other_usage = data.get("OtherUsageMetrics", {})
|
|
169
|
+
usage_list = other_usage.get("Usage", [])
|
|
170
|
+
cost_list = other_usage.get("Cost", [])
|
|
171
|
+
|
|
172
|
+
usage_metrics = [Metric.model_validate(u) for u in usage_list]
|
|
173
|
+
cost_metrics = [Metric.model_validate(c) for c in cost_list]
|
|
174
|
+
|
|
175
|
+
by_month_raw = data.get("ByMonth")
|
|
176
|
+
by_month = ByMonthData.model_validate(by_month_raw) if by_month_raw else None
|
|
177
|
+
|
|
178
|
+
by_day_raw = data.get("ByDay", [])
|
|
179
|
+
by_day = [DailyUsageWeek.model_validate(w) for w in by_day_raw]
|
|
180
|
+
|
|
181
|
+
return cls(
|
|
182
|
+
usage_metrics=usage_metrics,
|
|
183
|
+
cost_metrics=cost_metrics,
|
|
184
|
+
current_cycle_start_date=other_usage.get("CurrentCycleStartDate"),
|
|
185
|
+
by_month=by_month,
|
|
186
|
+
by_day=by_day,
|
|
187
|
+
start_date=data.get("StartDate"),
|
|
188
|
+
end_date=data.get("EndDate"),
|
|
189
|
+
date_range=data.get("DateRange"),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def current_usage_kwh(self) -> Optional[float]:
|
|
194
|
+
for m in self.usage_metrics:
|
|
195
|
+
if m.key == "CURRENTUSAGE":
|
|
196
|
+
return m.numeric_value
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def current_cost_rm(self) -> Optional[float]:
|
|
201
|
+
for m in self.cost_metrics:
|
|
202
|
+
if m.key == "CURRENTCOST":
|
|
203
|
+
return m.numeric_value
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def projected_cost_rm(self) -> Optional[float]:
|
|
208
|
+
for m in self.cost_metrics:
|
|
209
|
+
if m.key == "PROJECTEDCOST":
|
|
210
|
+
return m.numeric_value
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class SMRAccount(BaseModel):
|
|
215
|
+
"""Smart Meter Reading account status."""
|
|
216
|
+
|
|
217
|
+
contract_account: str = Field(alias="ContractAccount")
|
|
218
|
+
smr_eligibility: str = Field(alias="SMREligibility")
|
|
219
|
+
is_tagged_smr: str = Field(alias="IsTaggedSMR")
|
|
220
|
+
|
|
221
|
+
model_config = {"populate_by_name": True}
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def is_smart_meter(self) -> bool:
|
|
225
|
+
return self.is_tagged_smr.lower() == "true" # pylint: disable=no-member
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class BREligibility(BaseModel):
|
|
229
|
+
"""Bill Rendering eligibility indicator."""
|
|
230
|
+
|
|
231
|
+
ca_no: str = Field(alias="caNo")
|
|
232
|
+
is_owner_over_rule: bool = Field(default=False, alias="isOwnerOverRule")
|
|
233
|
+
is_owner_already_opt_in: bool = Field(default=False, alias="isOwnerAlreadyOptIn")
|
|
234
|
+
is_tenant_already_opt_in: bool = Field(default=False, alias="isTenantAlreadyOptIn")
|
|
235
|
+
|
|
236
|
+
model_config = {"populate_by_name": True}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-mytnb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library to interface with the myTNB API (Tenaga Nasional Berhad)
|
|
5
|
+
Project-URL: Repository, https://github.com/danieyal/python-mytnb
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: click>=8.3.3
|
|
20
|
+
Requires-Dist: cryptography>=42.0
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Requires-Dist: rich>=15.0.0
|
|
24
|
+
Requires-Dist: tls-client>=1.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pylint>=3.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# python-mytnb
|
|
33
|
+
|
|
34
|
+
A Python library to interface with the myTNB (Tenaga Nasional Berhad) API for electricity account management and usage monitoring in Malaysia.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv add python-mytnb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with pip:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install python-mytnb
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
from mytnb import MyTNBClient
|
|
53
|
+
|
|
54
|
+
async def main():
|
|
55
|
+
client = await MyTNBClient.login("user@example.com", "your-password")
|
|
56
|
+
|
|
57
|
+
async with client:
|
|
58
|
+
# Get smart meter usage & billing history
|
|
59
|
+
usage = await client.get_account_usage_smart("220123456789")
|
|
60
|
+
|
|
61
|
+
# Monthly billing breakdown
|
|
62
|
+
for month in usage.by_month.months:
|
|
63
|
+
print(f"{month.month} {month.year}: {month.usage_total} kWh — RM {month.amount_total}")
|
|
64
|
+
|
|
65
|
+
# Bill history & due amount
|
|
66
|
+
history = await client.get_bill_history("220123456789")
|
|
67
|
+
due = await client.get_account_due_amount("220123456789")
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## CLI
|
|
73
|
+
|
|
74
|
+
Pass credentials directly:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
mytnb --email user@example.com --password yourpass usage 220123456789
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or via environment variables:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export MYTNB_EMAIL=user@example.com
|
|
84
|
+
export MYTNB_PASSWORD=yourpass
|
|
85
|
+
mytnb usage 220123456789
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
All commands:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
mytnb login # Test login, show user info
|
|
92
|
+
mytnb usage <account> # Monthly usage & billing summary
|
|
93
|
+
mytnb usage --daily <account> # Daily usage breakdown
|
|
94
|
+
mytnb usage --json <account> # Full usage data as JSON
|
|
95
|
+
mytnb current-usage <account> # Simplified current usage summary
|
|
96
|
+
mytnb due-amount <account> # Outstanding balance
|
|
97
|
+
mytnb bill-history <account> # Payment history
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Global options: `--debug` for full tracebacks, `--version`.
|
|
101
|
+
|
|
102
|
+
Use a config file instead of flags (`--config <path>`, `mytnb.json`, or `~/.config/mytnb/config.json`):
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
mytnb init-config # Generate a starter config file
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## API Architecture
|
|
109
|
+
|
|
110
|
+
myTNB uses two API backends, both handled transparently by this library:
|
|
111
|
+
|
|
112
|
+
| Backend | Domain | Auth | Used for |
|
|
113
|
+
| ----------- | --------------------- | ------------------------------------------- | ----------------------------------- |
|
|
114
|
+
| REST | `api.mytnb.com.my` | JWT + API key | Account listing, eligibility checks |
|
|
115
|
+
| Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
|
|
116
|
+
|
|
117
|
+
Request encryption for the ASMX API is automatic — just pass plaintext parameters.
|
|
118
|
+
|
|
119
|
+
## Data Models
|
|
120
|
+
|
|
121
|
+
| Model | Description |
|
|
122
|
+
| --------------- | ---------------------------------------------------- |
|
|
123
|
+
| `AccountUsage` | Full usage response: metrics, monthly and daily data |
|
|
124
|
+
| `UsageMetric` | Current/average usage (kWh) |
|
|
125
|
+
| `CostMetric` | Current/projected cost (RM) |
|
|
126
|
+
| `BillingMonth` | Monthly billing record with tariff blocks |
|
|
127
|
+
| `DailyUsage` | Daily consumption and cost |
|
|
128
|
+
| `TariffBlock` | Tariff pricing block details |
|
|
129
|
+
| `SMRAccount` | Smart Meter Reading eligibility status |
|
|
130
|
+
| `BREligibility` | Bill rendering opt-in status |
|
|
131
|
+
|
|
132
|
+
## Geographic Restrictions
|
|
133
|
+
|
|
134
|
+
The myTNB API only accepts connections from **Malaysian IP addresses** and blocks most VPN services. If you get a `GeoBlockedError` (HTTP 403), make sure you are connecting from a Malaysian network without a VPN.
|
|
135
|
+
|
|
136
|
+
## Error Handling
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from mytnb.exceptions import MyTNBError, APIError, AuthenticationError, GeoBlockedError
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
client = await MyTNBClient.login("user@example.com", "password")
|
|
143
|
+
async with client:
|
|
144
|
+
usage = await client.get_account_usage_smart("220123456789")
|
|
145
|
+
except GeoBlockedError:
|
|
146
|
+
print("Blocked — connect from a Malaysian IP without VPN")
|
|
147
|
+
except AuthenticationError:
|
|
148
|
+
print("Invalid email or password")
|
|
149
|
+
except APIError as e:
|
|
150
|
+
print(f"API error {e.error_code}: {e.display_message}")
|
|
151
|
+
except MyTNBError as e:
|
|
152
|
+
print(f"Error: {e}")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
uv sync --extra dev
|
|
159
|
+
uv run pytest
|
|
160
|
+
uv run pylint src/mytnb/
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
mytnb/__init__.py,sha256=XTMrHOGpHL5XSpQ7_jh-cuuwMNrdZAH3eh4QXkuuwjw,489
|
|
2
|
+
mytnb/__main__.py,sha256=JmAjczPoUDep52BmNaMngOBE1IZSfGtQPAvMPzNbpHA,78
|
|
3
|
+
mytnb/auth.py,sha256=KygAcTVb_AXP3ZIcmNt4Jb5vCKfLAPMu2OC9wTaFZBE,1620
|
|
4
|
+
mytnb/cli.py,sha256=2wPLzo75Q6_ZrzPm-Hy2PxIishgt1E4tl1ke6L2KL-w,12649
|
|
5
|
+
mytnb/crypto.py,sha256=E39ZRHQwTl0jbKtITp9vyRMjXjwFVTbscSc-Z1wIYog,5102
|
|
6
|
+
mytnb/exceptions.py,sha256=lpttDB08gRo74e3RoO8ee7AYMdnsRekoWo0VZ2WwiBQ,1283
|
|
7
|
+
mytnb/models.py,sha256=BcC56KkriM1RXmCdT9guVghhqGtX2Gvt_u_jLvbLPCo,8150
|
|
8
|
+
mytnb/client/__init__.py,sha256=k5TEcrevXSts0rcT1Vvzv9qRSawgCfJLKwRWOkCw-y0,71
|
|
9
|
+
mytnb/client/auth.py,sha256=l-BUha0suxncRhMD8bgP4ECZ9ZTHBB3OCC-MZZyG3eE,4861
|
|
10
|
+
mytnb/client/client.py,sha256=zsdv6j_ESMuMK5-qyJs_AL1QueB0FTy2XjmyoFTvtNg,8476
|
|
11
|
+
mytnb/client/config.py,sha256=bQIg3vOjHCXZw3LFrgkopXcm9qA3jBgUyhjM_IHK0kQ,1243
|
|
12
|
+
mytnb/client/legacy.py,sha256=A4etA3ViDafsfulKGSq59OdwpNqX7bXVaNw0yHVmDwk,3739
|
|
13
|
+
mytnb/client/rest.py,sha256=mW9Und9e4cvemTy64uuNRmN4h7Kx0CQ5-K4uRoVnQGI,3794
|
|
14
|
+
python_mytnb-0.1.0.dist-info/METADATA,sha256=fpmak3IGBqg8DcIqjXiA-rNAeWWimi2W08Ct0esPdMQ,5508
|
|
15
|
+
python_mytnb-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
python_mytnb-0.1.0.dist-info/entry_points.txt,sha256=8Fm6WPzObtoBS3j0vIXSansgPZBREAVgysltc3ypvTk,41
|
|
17
|
+
python_mytnb-0.1.0.dist-info/licenses/LICENSE,sha256=m-85-NGNTFu9N-mdpF0sYLupMloWk6_kcGbQlEmTfpQ,1071
|
|
18
|
+
python_mytnb-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Latiff Danieyal
|
|
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.
|