python-mytnb 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/PKG-INFO +14 -3
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/README.md +8 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/pyproject.toml +7 -3
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/__init__.py +4 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/cli.py +23 -25
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/client.py +32 -7
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/models.py +94 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_models.py +72 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/uv.lock +13 -7
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/.github/workflows/ci.yml +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/.github/workflows/publish.yml +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/.gitignore +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/LICENSE +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/__main__.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/auth.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/__init__.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/auth.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/config.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/legacy.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/rest.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/crypto.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/exceptions.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_auth.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_cli.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_client.py +0 -0
- {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_crypto.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mytnb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python library to interface with the myTNB API (Tenaga Nasional Berhad)
|
|
5
5
|
Project-URL: Repository, https://github.com/danieyal/python-mytnb
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,16 +16,19 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
|
-
Requires-Dist: click>=8.3.3
|
|
20
19
|
Requires-Dist: cryptography>=42.0
|
|
21
20
|
Requires-Dist: curl-cffi>=0.7
|
|
22
21
|
Requires-Dist: httpx>=0.27
|
|
23
22
|
Requires-Dist: pydantic>=2.0
|
|
24
|
-
|
|
23
|
+
Provides-Extra: cli
|
|
24
|
+
Requires-Dist: click>=8.3.3; extra == 'cli'
|
|
25
|
+
Requires-Dist: rich>=15.0.0; extra == 'cli'
|
|
25
26
|
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: click>=8.3.3; extra == 'dev'
|
|
26
28
|
Requires-Dist: pylint>=3.0; extra == 'dev'
|
|
27
29
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
30
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: rich>=15.0.0; extra == 'dev'
|
|
29
32
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
33
|
Description-Content-Type: text/markdown
|
|
31
34
|
|
|
@@ -79,6 +82,14 @@ asyncio.run(main())
|
|
|
79
82
|
|
|
80
83
|
## CLI
|
|
81
84
|
|
|
85
|
+
The `mytnb` command-line tool requires the optional `cli` extra (it pulls in
|
|
86
|
+
`click` and `rich`, which the library itself does not need). If you installed
|
|
87
|
+
without it, `mytnb` (or `python -m mytnb`) will fail to start:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install "python-mytnb[cli]"
|
|
91
|
+
```
|
|
92
|
+
|
|
82
93
|
Pass credentials directly:
|
|
83
94
|
|
|
84
95
|
```bash
|
|
@@ -48,6 +48,14 @@ asyncio.run(main())
|
|
|
48
48
|
|
|
49
49
|
## CLI
|
|
50
50
|
|
|
51
|
+
The `mytnb` command-line tool requires the optional `cli` extra (it pulls in
|
|
52
|
+
`click` and `rich`, which the library itself does not need). If you installed
|
|
53
|
+
without it, `mytnb` (or `python -m mytnb`) will fail to start:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install "python-mytnb[cli]"
|
|
57
|
+
```
|
|
58
|
+
|
|
51
59
|
Pass credentials directly:
|
|
52
60
|
|
|
53
61
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mytnb"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Python library to interface with the myTNB API (Tenaga Nasional Berhad)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -26,16 +26,20 @@ dependencies = [
|
|
|
26
26
|
"pydantic>=2.0",
|
|
27
27
|
"cryptography>=42.0",
|
|
28
28
|
"curl_cffi>=0.7",
|
|
29
|
-
"click>=8.3.3",
|
|
30
|
-
"rich>=15.0.0",
|
|
31
29
|
]
|
|
32
30
|
|
|
33
31
|
[project.optional-dependencies]
|
|
32
|
+
cli = [
|
|
33
|
+
"click>=8.3.3",
|
|
34
|
+
"rich>=15.0.0",
|
|
35
|
+
]
|
|
34
36
|
dev = [
|
|
35
37
|
"pytest>=7.0",
|
|
36
38
|
"pytest-asyncio>=0.21",
|
|
37
39
|
"pylint>=3.0",
|
|
38
40
|
"ruff>=0.6",
|
|
41
|
+
"click>=8.3.3",
|
|
42
|
+
"rich>=15.0.0",
|
|
39
43
|
]
|
|
40
44
|
|
|
41
45
|
[project.scripts]
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
from mytnb.client import MyTNBClient
|
|
4
4
|
from mytnb.crypto import EncryptedPayload, encrypt_request
|
|
5
5
|
from mytnb.models import (
|
|
6
|
+
AccountDueAmount,
|
|
6
7
|
AccountUsage,
|
|
8
|
+
BillHistoryEntry,
|
|
7
9
|
BillingMonth,
|
|
8
10
|
CostMetric,
|
|
9
11
|
CustomerAccount,
|
|
@@ -19,7 +21,9 @@ __all__ = [
|
|
|
19
21
|
"MyTNBClient",
|
|
20
22
|
"EncryptedPayload",
|
|
21
23
|
"encrypt_request",
|
|
24
|
+
"AccountDueAmount",
|
|
22
25
|
"AccountUsage",
|
|
26
|
+
"BillHistoryEntry",
|
|
23
27
|
"BillingMonth",
|
|
24
28
|
"CostMetric",
|
|
25
29
|
"CustomerAccount",
|
|
@@ -118,9 +118,14 @@ def _build_credentials(cfg: dict) -> Credentials:
|
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
def _to_json(data: object) -> str:
|
|
121
|
-
"""Serialize any object to JSON string."""
|
|
121
|
+
"""Serialize any object (or list of models) to JSON string."""
|
|
122
122
|
if hasattr(data, "model_dump"):
|
|
123
123
|
data = data.model_dump()
|
|
124
|
+
elif isinstance(data, list):
|
|
125
|
+
data = [
|
|
126
|
+
item.model_dump() if hasattr(item, "model_dump") else item
|
|
127
|
+
for item in data
|
|
128
|
+
]
|
|
124
129
|
elif hasattr(data, "__dict__") and not isinstance(data, dict):
|
|
125
130
|
data = data.__dict__
|
|
126
131
|
return json.dumps(data, indent=2, default=str)
|
|
@@ -293,16 +298,12 @@ def due_amount(ctx, account, as_json):
|
|
|
293
298
|
_print_json(result)
|
|
294
299
|
return
|
|
295
300
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
title=f"Due Amount — {account}",
|
|
303
|
-
))
|
|
304
|
-
else:
|
|
305
|
-
_print_json(result)
|
|
301
|
+
amount = "--" if result.amount_due is None else f"{result.amount_due:.2f}"
|
|
302
|
+
due_date = result.due_date.isoformat() if result.due_date else "--"
|
|
303
|
+
console.print(Panel(
|
|
304
|
+
f"[bold green]RM {amount}[/]\nDue by [cyan]{due_date}[/]",
|
|
305
|
+
title=f"Due Amount — {account}",
|
|
306
|
+
))
|
|
306
307
|
|
|
307
308
|
_run_async(_due())
|
|
308
309
|
|
|
@@ -324,20 +325,17 @@ def bill_history(ctx, account, as_json):
|
|
|
324
325
|
_print_json(result)
|
|
325
326
|
return
|
|
326
327
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
console.print(table)
|
|
339
|
-
else:
|
|
340
|
-
_print_json(result)
|
|
328
|
+
table = Table(title=f"Bill History — {account}")
|
|
329
|
+
table.add_column("Date", style="cyan")
|
|
330
|
+
table.add_column("Bill No")
|
|
331
|
+
table.add_column("Amount (RM)", justify="right", style="green")
|
|
332
|
+
for bill in result:
|
|
333
|
+
table.add_row(
|
|
334
|
+
bill.date.isoformat() if bill.date else "--",
|
|
335
|
+
bill.billing_no or "--",
|
|
336
|
+
"--" if bill.amount is None else f"{bill.amount:.2f}",
|
|
337
|
+
)
|
|
338
|
+
console.print(table)
|
|
341
339
|
|
|
342
340
|
_run_async(_history())
|
|
343
341
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from typing import Any, Optional
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -14,7 +15,16 @@ from mytnb.client.legacy import _LegacyTransport
|
|
|
14
15
|
from mytnb.client.rest import _RestTransport
|
|
15
16
|
from mytnb.crypto import encrypt_request
|
|
16
17
|
from mytnb.exceptions import APIError
|
|
17
|
-
from mytnb.models import
|
|
18
|
+
from mytnb.models import (
|
|
19
|
+
AccountDueAmount,
|
|
20
|
+
AccountUsage,
|
|
21
|
+
BillHistoryEntry,
|
|
22
|
+
BREligibility,
|
|
23
|
+
CustomerAccount,
|
|
24
|
+
SMRAccount,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
18
28
|
|
|
19
29
|
|
|
20
30
|
class MyTNBClient:
|
|
@@ -229,30 +239,45 @@ class MyTNBClient:
|
|
|
229
239
|
account_number: str,
|
|
230
240
|
*,
|
|
231
241
|
is_owner: bool = True,
|
|
232
|
-
) ->
|
|
233
|
-
"""Get account due amount."""
|
|
242
|
+
) -> AccountDueAmount:
|
|
243
|
+
"""Get account due amount as a typed model."""
|
|
234
244
|
data = {
|
|
235
245
|
"contractAccount": account_number,
|
|
236
246
|
"isOwnedAccount": "true" if is_owner else "false",
|
|
237
247
|
"usrInf": self._legacy_transport.base_user_info(),
|
|
238
248
|
}
|
|
239
249
|
result = await self._legacy_transport.post("GetAccountDueAmount", data)
|
|
240
|
-
return result.get("data"
|
|
250
|
+
return AccountDueAmount.from_api_response(result.get("data", result))
|
|
241
251
|
|
|
242
252
|
async def get_bill_history(
|
|
243
253
|
self,
|
|
244
254
|
account_number: str,
|
|
245
255
|
*,
|
|
246
256
|
is_owner: bool = True,
|
|
247
|
-
) ->
|
|
248
|
-
"""Get bill payment history."""
|
|
257
|
+
) -> list[BillHistoryEntry]:
|
|
258
|
+
"""Get bill payment history as typed models (most recent first)."""
|
|
249
259
|
data = {
|
|
250
260
|
"contractAccount": account_number,
|
|
251
261
|
"isOwnedAccount": "true" if is_owner else "false",
|
|
252
262
|
"usrInf": self._legacy_transport.base_user_info(),
|
|
253
263
|
}
|
|
254
264
|
result = await self._legacy_transport.post("GetBillHistory", data)
|
|
255
|
-
|
|
265
|
+
raw = result.get("data")
|
|
266
|
+
if isinstance(raw, list):
|
|
267
|
+
return [
|
|
268
|
+
BillHistoryEntry.model_validate(item)
|
|
269
|
+
for item in raw
|
|
270
|
+
if isinstance(item, dict)
|
|
271
|
+
]
|
|
272
|
+
# ``data`` absent/empty is a legitimate "no bill history"; anything else
|
|
273
|
+
# is an unexpected shape worth surfacing when debugging (but not worth
|
|
274
|
+
# failing the whole account fetch over).
|
|
275
|
+
if raw is not None:
|
|
276
|
+
logger.debug(
|
|
277
|
+
"Unexpected GetBillHistory 'data' payload type: %s",
|
|
278
|
+
type(raw).__name__,
|
|
279
|
+
)
|
|
280
|
+
return []
|
|
256
281
|
|
|
257
282
|
async def get_current_usage(self, account_number: str) -> dict:
|
|
258
283
|
"""Get a simplified summary of current usage."""
|
|
@@ -2,10 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from datetime import date as date_cls
|
|
6
|
+
from datetime import datetime
|
|
5
7
|
from typing import Any, Optional
|
|
6
8
|
|
|
7
9
|
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
|
8
10
|
|
|
11
|
+
# myTNB returns dates as DD/MM/YYYY; keep ISO variants as fallbacks.
|
|
12
|
+
_API_DATE_FORMATS = ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_api_date(value: Any) -> Optional[date_cls]:
|
|
16
|
+
"""Parse a myTNB date string (typically DD/MM/YYYY) into a date.
|
|
17
|
+
|
|
18
|
+
Returns None for empty/None/unparseable input so a single bad field never
|
|
19
|
+
breaks an entire response.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(value, datetime):
|
|
22
|
+
return value.date()
|
|
23
|
+
if isinstance(value, date_cls):
|
|
24
|
+
return value
|
|
25
|
+
if not isinstance(value, str) or not value.strip():
|
|
26
|
+
return None
|
|
27
|
+
for fmt in _API_DATE_FORMATS:
|
|
28
|
+
try:
|
|
29
|
+
return datetime.strptime(value.strip(), fmt).date()
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_optional_float(value: Any) -> Optional[float]:
|
|
36
|
+
"""Coerce a myTNB numeric string to float, or None if absent/invalid."""
|
|
37
|
+
if value is None or value == "":
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
return float(value)
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
9
44
|
|
|
10
45
|
class TariffBlock(BaseModel):
|
|
11
46
|
"""A tariff block within a billing period."""
|
|
@@ -372,3 +407,62 @@ class CustomerAccount(BaseModel):
|
|
|
372
407
|
"""isPaid as a boolean."""
|
|
373
408
|
# pylint: disable=no-member
|
|
374
409
|
return self.is_paid.lower() == "true"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class BillHistoryEntry(BaseModel):
|
|
413
|
+
"""A single bill payment history entry from GetBillHistory.
|
|
414
|
+
|
|
415
|
+
Raw shape: {"DtBill": "31/05/2026", "AmPayable": "87.50", "BillingNo": "12345"}
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
date: Optional[date_cls] = Field(default=None, alias="DtBill")
|
|
419
|
+
amount: Optional[float] = Field(default=None, alias="AmPayable")
|
|
420
|
+
billing_no: str = Field(default="", alias="BillingNo")
|
|
421
|
+
|
|
422
|
+
model_config = {"populate_by_name": True, "extra": "ignore"}
|
|
423
|
+
|
|
424
|
+
@field_validator("date", mode="before")
|
|
425
|
+
@classmethod
|
|
426
|
+
def _parse_date(cls, v: Any) -> Optional[date_cls]:
|
|
427
|
+
return parse_api_date(v)
|
|
428
|
+
|
|
429
|
+
@field_validator("amount", mode="before")
|
|
430
|
+
@classmethod
|
|
431
|
+
def _parse_amount(cls, v: Any) -> Optional[float]:
|
|
432
|
+
return _parse_optional_float(v)
|
|
433
|
+
|
|
434
|
+
@field_validator("billing_no", mode="before")
|
|
435
|
+
@classmethod
|
|
436
|
+
def _coerce_billing_no(cls, v: Any) -> str:
|
|
437
|
+
return "" if v is None else str(v)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class AccountDueAmount(BaseModel):
|
|
441
|
+
"""Outstanding balance for an account from GetAccountDueAmount.
|
|
442
|
+
|
|
443
|
+
Raw shape: {"AccountAmountDue": {"amountDue": "12.34", "billDueDate": "..."}}
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
amount_due: Optional[float] = Field(default=None, alias="amountDue")
|
|
447
|
+
due_date: Optional[date_cls] = Field(default=None, alias="billDueDate")
|
|
448
|
+
|
|
449
|
+
model_config = {"populate_by_name": True, "extra": "ignore"}
|
|
450
|
+
|
|
451
|
+
@field_validator("amount_due", mode="before")
|
|
452
|
+
@classmethod
|
|
453
|
+
def _parse_amount(cls, v: Any) -> Optional[float]:
|
|
454
|
+
return _parse_optional_float(v)
|
|
455
|
+
|
|
456
|
+
@field_validator("due_date", mode="before")
|
|
457
|
+
@classmethod
|
|
458
|
+
def _parse_due_date(cls, v: Any) -> Optional[date_cls]:
|
|
459
|
+
return parse_api_date(v)
|
|
460
|
+
|
|
461
|
+
@classmethod
|
|
462
|
+
def from_api_response(cls, data: Any) -> "AccountDueAmount":
|
|
463
|
+
"""Parse the (optionally AccountAmountDue-wrapped) due payload."""
|
|
464
|
+
if isinstance(data, dict):
|
|
465
|
+
inner = data.get("AccountAmountDue", data)
|
|
466
|
+
if isinstance(inner, dict):
|
|
467
|
+
return cls.model_validate(inner)
|
|
468
|
+
return cls()
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
# pylint: disable=duplicate-code
|
|
4
4
|
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
|
|
5
7
|
from mytnb.models import (
|
|
8
|
+
AccountDueAmount,
|
|
6
9
|
AccountUsage,
|
|
10
|
+
BillHistoryEntry,
|
|
7
11
|
BillingMonth,
|
|
8
12
|
BREligibility,
|
|
9
13
|
CustomerAccount,
|
|
@@ -511,3 +515,71 @@ class TestTariffBlockLegendGroup:
|
|
|
511
515
|
def test_empty(self):
|
|
512
516
|
group = TariffBlockLegendGroup.model_validate({"TariffBlocksLegend": []})
|
|
513
517
|
assert group.items == []
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class TestBillHistoryEntry:
|
|
521
|
+
def test_parse_ddmmyyyy(self):
|
|
522
|
+
entry = BillHistoryEntry.model_validate(
|
|
523
|
+
{"DtBill": "31/05/2026", "AmPayable": "87.50", "BillingNo": "12345"}
|
|
524
|
+
)
|
|
525
|
+
assert entry.date == date(2026, 5, 31)
|
|
526
|
+
assert entry.amount == 87.50
|
|
527
|
+
assert entry.billing_no == "12345"
|
|
528
|
+
|
|
529
|
+
def test_parse_iso_fallback(self):
|
|
530
|
+
entry = BillHistoryEntry.model_validate(
|
|
531
|
+
{"DtBill": "2026-05-31", "AmPayable": "10"}
|
|
532
|
+
)
|
|
533
|
+
assert entry.date == date(2026, 5, 31)
|
|
534
|
+
assert entry.amount == 10.0
|
|
535
|
+
assert entry.billing_no == ""
|
|
536
|
+
|
|
537
|
+
def test_unparseable_date_is_none(self):
|
|
538
|
+
entry = BillHistoryEntry.model_validate(
|
|
539
|
+
{"DtBill": "not-a-date", "AmPayable": "5.0"}
|
|
540
|
+
)
|
|
541
|
+
assert entry.date is None
|
|
542
|
+
|
|
543
|
+
def test_missing_and_null_fields(self):
|
|
544
|
+
entry = BillHistoryEntry.model_validate(
|
|
545
|
+
{"DtBill": None, "AmPayable": "", "BillingNo": None}
|
|
546
|
+
)
|
|
547
|
+
assert entry.date is None
|
|
548
|
+
assert entry.amount is None
|
|
549
|
+
assert entry.billing_no == ""
|
|
550
|
+
|
|
551
|
+
def test_populate_by_name(self):
|
|
552
|
+
entry = BillHistoryEntry(date=date(2026, 1, 1), amount=1.0, billing_no="9")
|
|
553
|
+
assert entry.date == date(2026, 1, 1)
|
|
554
|
+
|
|
555
|
+
def test_datetime_input_narrowed_to_date(self):
|
|
556
|
+
# A datetime must be narrowed to date (it subclasses date), not leak through.
|
|
557
|
+
entry = BillHistoryEntry.model_validate(
|
|
558
|
+
{"DtBill": datetime(2026, 5, 31, 14, 30), "AmPayable": "1.0"}
|
|
559
|
+
)
|
|
560
|
+
assert entry.date == date(2026, 5, 31)
|
|
561
|
+
assert not isinstance(entry.date, datetime)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class TestAccountDueAmount:
|
|
565
|
+
def test_from_wrapped_response(self):
|
|
566
|
+
due = AccountDueAmount.from_api_response(
|
|
567
|
+
{"AccountAmountDue": {"amountDue": "12.34", "billDueDate": "30/06/2026"}}
|
|
568
|
+
)
|
|
569
|
+
assert due.amount_due == 12.34
|
|
570
|
+
assert due.due_date == date(2026, 6, 30)
|
|
571
|
+
|
|
572
|
+
def test_from_unwrapped_response(self):
|
|
573
|
+
due = AccountDueAmount.from_api_response(
|
|
574
|
+
{"amountDue": "5.00", "billDueDate": "2026-06-30"}
|
|
575
|
+
)
|
|
576
|
+
assert due.amount_due == 5.0
|
|
577
|
+
assert due.due_date == date(2026, 6, 30)
|
|
578
|
+
|
|
579
|
+
def test_empty_and_invalid(self):
|
|
580
|
+
assert AccountDueAmount.from_api_response(None).amount_due is None
|
|
581
|
+
due = AccountDueAmount.from_api_response(
|
|
582
|
+
{"amountDue": None, "billDueDate": "bad"}
|
|
583
|
+
)
|
|
584
|
+
assert due.amount_due is None
|
|
585
|
+
assert due.due_date is None
|
|
@@ -270,7 +270,7 @@ name = "exceptiongroup"
|
|
|
270
270
|
version = "1.3.1"
|
|
271
271
|
source = { registry = "https://pypi.org/simple" }
|
|
272
272
|
dependencies = [
|
|
273
|
-
{ name = "typing-extensions"
|
|
273
|
+
{ name = "typing-extensions" },
|
|
274
274
|
]
|
|
275
275
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
276
276
|
wheels = [
|
|
@@ -600,28 +600,33 @@ wheels = [
|
|
|
600
600
|
|
|
601
601
|
[[package]]
|
|
602
602
|
name = "python-mytnb"
|
|
603
|
-
version = "0.
|
|
603
|
+
version = "0.4.0"
|
|
604
604
|
source = { editable = "." }
|
|
605
605
|
dependencies = [
|
|
606
|
-
{ name = "click" },
|
|
607
606
|
{ name = "cryptography" },
|
|
608
607
|
{ name = "curl-cffi" },
|
|
609
608
|
{ name = "httpx" },
|
|
610
609
|
{ name = "pydantic" },
|
|
611
|
-
{ name = "rich" },
|
|
612
610
|
]
|
|
613
611
|
|
|
614
612
|
[package.optional-dependencies]
|
|
613
|
+
cli = [
|
|
614
|
+
{ name = "click" },
|
|
615
|
+
{ name = "rich" },
|
|
616
|
+
]
|
|
615
617
|
dev = [
|
|
618
|
+
{ name = "click" },
|
|
616
619
|
{ name = "pylint" },
|
|
617
620
|
{ name = "pytest" },
|
|
618
621
|
{ name = "pytest-asyncio" },
|
|
622
|
+
{ name = "rich" },
|
|
619
623
|
{ name = "ruff" },
|
|
620
624
|
]
|
|
621
625
|
|
|
622
626
|
[package.metadata]
|
|
623
627
|
requires-dist = [
|
|
624
|
-
{ name = "click", specifier = ">=8.3.3" },
|
|
628
|
+
{ name = "click", marker = "extra == 'cli'", specifier = ">=8.3.3" },
|
|
629
|
+
{ name = "click", marker = "extra == 'dev'", specifier = ">=8.3.3" },
|
|
625
630
|
{ name = "cryptography", specifier = ">=42.0" },
|
|
626
631
|
{ name = "curl-cffi", specifier = ">=0.7" },
|
|
627
632
|
{ name = "httpx", specifier = ">=0.27" },
|
|
@@ -629,10 +634,11 @@ requires-dist = [
|
|
|
629
634
|
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=3.0" },
|
|
630
635
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
|
631
636
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" },
|
|
632
|
-
{ name = "rich", specifier = ">=15.0.0" },
|
|
637
|
+
{ name = "rich", marker = "extra == 'cli'", specifier = ">=15.0.0" },
|
|
638
|
+
{ name = "rich", marker = "extra == 'dev'", specifier = ">=15.0.0" },
|
|
633
639
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
|
|
634
640
|
]
|
|
635
|
-
provides-extras = ["dev"]
|
|
641
|
+
provides-extras = ["cli", "dev"]
|
|
636
642
|
|
|
637
643
|
[[package]]
|
|
638
644
|
name = "rich"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|