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.
Files changed (26) hide show
  1. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/PKG-INFO +14 -3
  2. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/README.md +8 -0
  3. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/pyproject.toml +7 -3
  4. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/__init__.py +4 -0
  5. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/cli.py +23 -25
  6. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/client.py +32 -7
  7. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/models.py +94 -0
  8. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_models.py +72 -0
  9. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/uv.lock +13 -7
  10. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/.github/workflows/ci.yml +0 -0
  11. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/.github/workflows/publish.yml +0 -0
  12. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/.gitignore +0 -0
  13. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/LICENSE +0 -0
  14. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/__main__.py +0 -0
  15. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/auth.py +0 -0
  16. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/__init__.py +0 -0
  17. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/auth.py +0 -0
  18. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/config.py +0 -0
  19. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/legacy.py +0 -0
  20. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/client/rest.py +0 -0
  21. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/crypto.py +0 -0
  22. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/src/mytnb/exceptions.py +0 -0
  23. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_auth.py +0 -0
  24. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_cli.py +0 -0
  25. {python_mytnb-0.3.0 → python_mytnb-0.5.0}/tests/test_client.py +0 -0
  26. {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.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
- Requires-Dist: rich>=15.0.0
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.3.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
- data = result.get("AccountAmountDue", result) if isinstance(result, dict) else result
297
- if isinstance(data, dict):
298
- amount = data.get("amountDue", "--")
299
- due_date = data.get("billDueDate", "--")
300
- console.print(Panel(
301
- f"[bold green]RM {amount}[/]\nDue by [cyan]{due_date}[/]",
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
- if isinstance(result, list):
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.get("DtBill", "--"),
335
- bill.get("BillingNo", "--"),
336
- bill.get("AmPayable", "--"),
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 AccountUsage, BREligibility, CustomerAccount, SMRAccount
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
- ) -> dict:
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") or result
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
- ) -> dict:
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
- return result.get("data") or result
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", marker = "python_full_version < '3.11'" },
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.3.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