switch2 0.1.1__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Async client library for the Switch2 energy portal
5
5
  Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
6
6
  License-Expression: Apache-2.0
@@ -38,16 +38,17 @@ import asyncio
38
38
  from switch2 import Switch2ApiClient
39
39
 
40
40
  async def main():
41
- client = Switch2ApiClient("you@example.com", "your-password")
42
- try:
41
+ async with Switch2ApiClient("you@example.com", "your-password") as client:
43
42
  data = await client.fetch_data()
44
43
  print(f"Customer: {data.customer.name}")
44
+ if data.account_balance is not None:
45
+ print(f"Balance: £{data.account_balance.balance}")
45
46
  for reading in data.readings:
46
47
  print(f" {reading.date}: {reading.amount} {reading.unit}")
47
48
  for bill in data.bills:
48
49
  print(f" {bill.date}: £{bill.amount}")
49
- finally:
50
- await client.close()
50
+ detail = await client.fetch_bill_detail(bill)
51
+ print(f" invoice {detail.invoice_number}, total £{detail.total}")
51
52
 
52
53
  asyncio.run(main())
53
54
  ```
@@ -15,16 +15,17 @@ import asyncio
15
15
  from switch2 import Switch2ApiClient
16
16
 
17
17
  async def main():
18
- client = Switch2ApiClient("you@example.com", "your-password")
19
- try:
18
+ async with Switch2ApiClient("you@example.com", "your-password") as client:
20
19
  data = await client.fetch_data()
21
20
  print(f"Customer: {data.customer.name}")
21
+ if data.account_balance is not None:
22
+ print(f"Balance: £{data.account_balance.balance}")
22
23
  for reading in data.readings:
23
24
  print(f" {reading.date}: {reading.amount} {reading.unit}")
24
25
  for bill in data.bills:
25
26
  print(f" {bill.date}: £{bill.amount}")
26
- finally:
27
- await client.close()
27
+ detail = await client.fetch_bill_detail(bill)
28
+ print(f" invoice {detail.invoice_number}, total £{detail.total}")
28
29
 
29
30
  asyncio.run(main())
30
31
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "switch2"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Async client library for the Switch2 energy portal"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -33,6 +33,9 @@ Homepage = "https://github.com/jelmer/python-switch2"
33
33
  Repository = "https://github.com/jelmer/python-switch2"
34
34
  Issues = "https://github.com/jelmer/python-switch2/issues"
35
35
 
36
+ [tool.setuptools.package-data]
37
+ switch2 = ["py.typed"]
38
+
36
39
  [tool.mypy]
37
40
  packages = ["switch2"]
38
41
  strict = true
@@ -1,6 +1,7 @@
1
1
  """Standalone client library for the Switch2 energy portal."""
2
2
 
3
3
  from .api import (
4
+ AccountBalance,
4
5
  Bill,
5
6
  BillCharge,
6
7
  BillDetail,
@@ -13,6 +14,7 @@ from .api import (
13
14
  )
14
15
 
15
16
  __all__ = [
17
+ "AccountBalance",
16
18
  "Bill",
17
19
  "BillCharge",
18
20
  "BillDetail",
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime
9
+ from types import TracebackType
8
10
 
9
11
  import aiohttp
10
12
  from bs4 import BeautifulSoup, Tag
@@ -16,6 +18,8 @@ LOGIN_URL = f"{BASE_URL}/Login"
16
18
  METER_HISTORY_URL = f"{BASE_URL}/MeterReadings/History"
17
19
  BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
18
20
 
21
+ DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60)
22
+
19
23
 
20
24
  class Switch2AuthError(Exception):
21
25
  """Raised when authentication fails."""
@@ -81,6 +85,14 @@ class BillDetail:
81
85
  download_url: str
82
86
 
83
87
 
88
+ @dataclass
89
+ class AccountBalance:
90
+ """Current account balance from the Switch2 portal."""
91
+
92
+ balance: float
93
+ last_updated: datetime
94
+
95
+
84
96
  @dataclass
85
97
  class Switch2Data:
86
98
  """All data fetched from the Switch2 portal."""
@@ -89,6 +101,7 @@ class Switch2Data:
89
101
  readings: list[MeterReading]
90
102
  registers: dict[str, str] # register_id -> register_name
91
103
  bills: list[Bill]
104
+ account_balance: AccountBalance | None
92
105
 
93
106
 
94
107
  def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
@@ -109,7 +122,7 @@ class Switch2ApiClient:
109
122
 
110
123
  async def _ensure_session(self) -> aiohttp.ClientSession:
111
124
  if self._session is None or self._session.closed:
112
- self._session = aiohttp.ClientSession()
125
+ self._session = aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT)
113
126
  return self._session
114
127
 
115
128
  async def close(self) -> None:
@@ -117,8 +130,19 @@ class Switch2ApiClient:
117
130
  if self._session and not self._session.closed:
118
131
  await self._session.close()
119
132
 
120
- async def authenticate(self) -> CustomerInfo:
121
- """Log in to the Switch2 portal and return customer info."""
133
+ async def __aenter__(self) -> "Switch2ApiClient":
134
+ return self
135
+
136
+ async def __aexit__(
137
+ self,
138
+ exc_type: type[BaseException] | None,
139
+ exc: BaseException | None,
140
+ tb: TracebackType | None,
141
+ ) -> None:
142
+ await self.close()
143
+
144
+ async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
145
+ """Log in to the Switch2 portal and return customer info and dashboard soup."""
122
146
  session = await self._ensure_session()
123
147
 
124
148
  try:
@@ -156,7 +180,7 @@ class Switch2ApiClient:
156
180
  if not customer.name:
157
181
  raise Switch2AuthError("Login failed: no customer info returned")
158
182
 
159
- return customer
183
+ return customer, soup
160
184
 
161
185
  except aiohttp.ClientError as err:
162
186
  raise Switch2ConnectionError(
@@ -186,7 +210,8 @@ class Switch2ApiClient:
186
210
 
187
211
  async def fetch_data(self) -> Switch2Data:
188
212
  """Authenticate and fetch all meter data."""
189
- customer = await self.authenticate()
213
+ customer, dashboard_soup = await self.authenticate()
214
+ account_balance = _parse_account_balance(dashboard_soup)
190
215
  session = await self._ensure_session()
191
216
 
192
217
  try:
@@ -246,6 +271,7 @@ class Switch2ApiClient:
246
271
  readings=readings,
247
272
  registers=registers,
248
273
  bills=bills,
274
+ account_balance=account_balance,
249
275
  )
250
276
 
251
277
  except aiohttp.ClientError as err:
@@ -347,6 +373,47 @@ def _parse_currency(text: str) -> float:
347
373
  return -value if negative else value
348
374
 
349
375
 
376
+ def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None:
377
+ """Extract the current account balance from the dashboard page."""
378
+ amount_el = soup.select_one(".dashboard-credit-amount-desktop")
379
+ if not amount_el:
380
+ return None
381
+ balance = _parse_currency(amount_el.text)
382
+ updated_el = soup.select_one(".dashboard-credit-lastUpdated")
383
+ if not updated_el:
384
+ return None
385
+ updated_text = updated_el.text.strip()
386
+ # Text is like "Last updated 27/02/2026 10:13"
387
+ if updated_text.lower().startswith("last updated"):
388
+ updated_text = updated_text[len("last updated") :].strip()
389
+ last_updated = datetime.strptime(updated_text, "%d/%m/%Y %H:%M")
390
+ return AccountBalance(balance=balance, last_updated=last_updated)
391
+
392
+
393
+ def _has_class(tag: Tag, class_name: str) -> bool:
394
+ """Return True if the tag has the given CSS class."""
395
+ classes = tag.get("class")
396
+ if classes is None:
397
+ return False
398
+ if isinstance(classes, str):
399
+ return class_name in classes.split()
400
+ return class_name in classes
401
+
402
+
403
+ def _parse_charge_row(row: Tag) -> BillCharge | None:
404
+ """Parse a single .bill-table-row into a BillCharge, or None if incomplete."""
405
+ desc_el = row.select_one(".bill-table-row-item-left")
406
+ units_el = row.select_one(".bill-table-row-item")
407
+ charge_el = row.select_one(".bill-table-row-item-right")
408
+ if not desc_el or not charge_el:
409
+ return None
410
+ return BillCharge(
411
+ description=desc_el.text.strip(),
412
+ units=units_el.text.strip() if units_el else "",
413
+ charge=_parse_currency(charge_el.text),
414
+ )
415
+
416
+
350
417
  def _parse_bill_charges(
351
418
  soup: BeautifulSoup, container_selector: str
352
419
  ) -> list[BillCharge]:
@@ -354,20 +421,51 @@ def _parse_bill_charges(
354
421
  charges: list[BillCharge] = []
355
422
  rows = soup.select(f"{container_selector} .bill-table-row-desktop .bill-table-row")
356
423
  for row in rows:
357
- desc_el = row.select_one(".bill-table-row-item-left")
358
- units_el = row.select_one(".bill-table-row-item")
359
- charge_el = row.select_one(".bill-table-row-item-right")
360
- if desc_el and charge_el:
361
- charges.append(
362
- BillCharge(
363
- description=desc_el.text.strip(),
364
- units=units_el.text.strip() if units_el else "",
365
- charge=_parse_currency(charge_el.text),
366
- )
367
- )
424
+ charge = _parse_charge_row(row)
425
+ if charge is not None:
426
+ charges.append(charge)
368
427
  return charges
369
428
 
370
429
 
430
+ def _parse_keyed_currency_rows(
431
+ container: Tag | None,
432
+ fields: dict[str, Callable[[str], bool]],
433
+ ) -> dict[str, float]:
434
+ """Parse .bill-total-table-row rows, matching labels to keys via predicates.
435
+
436
+ Returns a mapping for each predicate that matched a row; missing keys are
437
+ logged at warning level so silent zeros don't hide upstream HTML changes.
438
+ """
439
+ found: dict[str, float] = {}
440
+ if container is None:
441
+ for key in fields:
442
+ _LOGGER.warning("Missing expected bill-total field: %s", key)
443
+ return found
444
+ for row in container.select(".bill-total-table-row"):
445
+ label_el = row.select_one(".bill-total-table-row-item-left")
446
+ value_el = row.select_one(".bill-total-table-row-item-right")
447
+ if not label_el or not value_el:
448
+ continue
449
+ label = label_el.text.strip()
450
+ for key, matches in fields.items():
451
+ if key not in found and matches(label):
452
+ found[key] = _parse_currency(value_el.text)
453
+ break
454
+ for key in fields:
455
+ if key not in found:
456
+ _LOGGER.warning("Missing expected bill-total field: %s", key)
457
+ return found
458
+
459
+
460
+ def _parse_currency_header(soup: BeautifulSoup, selector: str, name: str) -> float:
461
+ """Parse a single currency value from a header selector, defaulting to 0.0."""
462
+ el = soup.select_one(selector)
463
+ if el is None:
464
+ _LOGGER.warning("Missing expected %s header", name)
465
+ return 0.0
466
+ return _parse_currency(el.text)
467
+
468
+
371
469
  def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
372
470
  """Extract bill detail from the bill detail page."""
373
471
  # Header: invoice number and date of issue
@@ -409,110 +507,81 @@ def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
409
507
  soup, "#BillItemsContainer > .bill-table-row:first-of-type"
410
508
  )
411
509
  # If the CSS child selector doesn't match, fall back to looking at
412
- # desktop rows that come before .other-charges-table-row
510
+ # desktop rows that come before .other-charges-table-row.
413
511
  if not consumption_charges:
414
512
  items_container = soup.select_one("#BillItemsContainer")
415
513
  if items_container:
416
- consumption_rows: list[Tag] = []
417
514
  for child in items_container.children:
418
515
  if not isinstance(child, Tag):
419
516
  continue
420
- if "other-charges-table-row" in child.get("class", []):
517
+ if _has_class(child, "other-charges-table-row"):
421
518
  break
422
- desktop = child.select_one(".bill-table-row-desktop .bill-table-row")
423
- if desktop:
424
- consumption_rows.append(desktop)
425
- elif "bill-table-row-desktop" in child.get("class", []):
426
- inner = child.select_one(".bill-table-row")
427
- if inner:
428
- consumption_rows.append(inner)
429
- for row in consumption_rows:
430
- desc_el = row.select_one(".bill-table-row-item-left")
431
- units_el = row.select_one(".bill-table-row-item")
432
- charge_el = row.select_one(".bill-table-row-item-right")
433
- if desc_el and charge_el:
434
- consumption_charges.append(
435
- BillCharge(
436
- description=desc_el.text.strip(),
437
- units=(units_el.text.strip() if units_el else ""),
438
- charge=_parse_currency(charge_el.text),
439
- )
440
- )
441
-
442
- # Other charges
519
+ desktop_row = child.select_one(
520
+ ".bill-table-row-desktop .bill-table-row"
521
+ )
522
+ if desktop_row is None and _has_class(child, "bill-table-row-desktop"):
523
+ desktop_row = child.select_one(".bill-table-row")
524
+ if desktop_row is not None:
525
+ charge = _parse_charge_row(desktop_row)
526
+ if charge is not None:
527
+ consumption_charges.append(charge)
528
+
529
+ # Other charges: desktop rows that follow the .other-charges-table-row header,
530
+ # stopping at the first sibling that isn't a desktop or narrow row.
443
531
  other_charges: list[BillCharge] = []
444
532
  other_header = soup.select_one(".other-charges-table-row")
445
533
  if other_header:
446
534
  sibling = other_header.next_sibling
447
535
  while sibling:
448
536
  if isinstance(sibling, Tag):
449
- if "bill-table-row-desktop" in sibling.get("class", []):
537
+ if _has_class(sibling, "bill-table-row-desktop"):
450
538
  charge_row = sibling.select_one(".bill-table-row")
451
- if charge_row:
452
- desc_el = charge_row.select_one(".bill-table-row-item-left")
453
- units_el = charge_row.select_one(".bill-table-row-item")
454
- charge_el = charge_row.select_one(".bill-table-row-item-right")
455
- if desc_el and charge_el:
456
- other_charges.append(
457
- BillCharge(
458
- description=desc_el.text.strip(),
459
- units=(units_el.text.strip() if units_el else ""),
460
- charge=_parse_currency(charge_el.text),
461
- )
462
- )
463
- elif "bill-table-row-narrow" not in sibling.get("class", []):
539
+ if charge_row is not None:
540
+ charge = _parse_charge_row(charge_row)
541
+ if charge is not None:
542
+ other_charges.append(charge)
543
+ elif not _has_class(sibling, "bill-table-row-narrow"):
464
544
  break
465
545
  sibling = sibling.next_sibling
466
546
 
467
- # Totals
468
- totals_container = soup.select_one("#BillTotalsCollapsibleContent")
469
- total_excl_vat = 0.0
470
- vat = 0.0
471
- total_rows = (
472
- totals_container.select(".bill-total-table-row") if totals_container else []
547
+ # Totals. Defaults to 0.0 stay for backwards compatibility with callers, but
548
+ # log when an expected field is missing so problems aren't completely silent.
549
+ totals = _parse_keyed_currency_rows(
550
+ soup.select_one("#BillTotalsCollapsibleContent"),
551
+ {
552
+ "total_excl_vat": lambda label: label.startswith(
553
+ "Total charges excluding VAT"
554
+ ),
555
+ "vat": lambda label: label.startswith("VAT"),
556
+ },
473
557
  )
474
- for row in total_rows:
475
- label_el = row.select_one(".bill-total-table-row-item-left")
476
- value_el = row.select_one(".bill-total-table-row-item-right")
477
- if label_el and value_el:
478
- label = label_el.text.strip()
479
- if label.startswith("Total charges excluding VAT"):
480
- total_excl_vat = _parse_currency(value_el.text)
481
- elif label.startswith("VAT"):
482
- vat = _parse_currency(value_el.text)
558
+ total_excl_vat = totals.get("total_excl_vat", 0.0)
559
+ vat = totals.get("vat", 0.0)
483
560
 
484
561
  # Bill total from the collapsible header
485
- total = 0.0
486
- total_header = soup.select_one(
487
- "#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right"
562
+ total = _parse_currency_header(
563
+ soup,
564
+ "#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right",
565
+ "bill total",
488
566
  )
489
- if total_header:
490
- total = _parse_currency(total_header.text)
491
567
 
492
568
  # Account balance
493
- balance_container = soup.select_one("#AccountBalanceCollapsibleContent")
494
- previous_balance = 0.0
495
- payments_received = 0.0
496
- balance_rows = (
497
- balance_container.select(".bill-total-table-row") if balance_container else []
569
+ balances = _parse_keyed_currency_rows(
570
+ soup.select_one("#AccountBalanceCollapsibleContent"),
571
+ {
572
+ "previous_balance": lambda label: label == "Previous account balance",
573
+ "payments_received": lambda label: label == "Payments received",
574
+ },
498
575
  )
499
- for row in balance_rows:
500
- label_el = row.select_one(".bill-total-table-row-item-left")
501
- value_el = row.select_one(".bill-total-table-row-item-right")
502
- if label_el and value_el:
503
- label = label_el.text.strip()
504
- if label == "Previous account balance":
505
- previous_balance = _parse_currency(value_el.text)
506
- elif label == "Payments received":
507
- payments_received = _parse_currency(value_el.text)
508
-
509
- balance = 0.0
510
- balance_header = soup.select_one(
576
+ previous_balance = balances.get("previous_balance", 0.0)
577
+ payments_received = balances.get("payments_received", 0.0)
578
+
579
+ balance = _parse_currency_header(
580
+ soup,
511
581
  "#AccountBalanceContainer > .collapsible-header"
512
- " .bill-total-table-row-item-right"
582
+ " .bill-total-table-row-item-right",
583
+ "account balance",
513
584
  )
514
- if balance_header:
515
- balance = _parse_currency(balance_header.text)
516
585
 
517
586
  # Download URL
518
587
  download_url = ""
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Async client library for the Switch2 energy portal
5
5
  Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
6
6
  License-Expression: Apache-2.0
@@ -38,16 +38,17 @@ import asyncio
38
38
  from switch2 import Switch2ApiClient
39
39
 
40
40
  async def main():
41
- client = Switch2ApiClient("you@example.com", "your-password")
42
- try:
41
+ async with Switch2ApiClient("you@example.com", "your-password") as client:
43
42
  data = await client.fetch_data()
44
43
  print(f"Customer: {data.customer.name}")
44
+ if data.account_balance is not None:
45
+ print(f"Balance: £{data.account_balance.balance}")
45
46
  for reading in data.readings:
46
47
  print(f" {reading.date}: {reading.amount} {reading.unit}")
47
48
  for bill in data.bills:
48
49
  print(f" {bill.date}: £{bill.amount}")
49
- finally:
50
- await client.close()
50
+ detail = await client.fetch_bill_detail(bill)
51
+ print(f" invoice {detail.invoice_number}, total £{detail.total}")
51
52
 
52
53
  asyncio.run(main())
53
54
  ```
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  switch2/__init__.py
4
4
  switch2/api.py
5
+ switch2/py.typed
5
6
  switch2.egg-info/PKG-INFO
6
7
  switch2.egg-info/SOURCES.txt
7
8
  switch2.egg-info/dependency_links.txt
@@ -1,11 +1,14 @@
1
1
  """Tests for the Switch2 API client."""
2
2
 
3
+ import asyncio
3
4
  import unittest
4
5
  from datetime import datetime
5
6
 
6
7
  from bs4 import BeautifulSoup
7
8
 
8
9
  from switch2.api import (
10
+ Switch2ApiClient,
11
+ _parse_account_balance,
9
12
  _parse_bill_detail,
10
13
  _parse_bills,
11
14
  _parse_currency,
@@ -350,3 +353,52 @@ class ParseBillDetailTests(unittest.TestCase):
350
353
  detail.download_url,
351
354
  "https://my.switch2.co.uk/Credit/Bill/Download/2289896",
352
355
  )
356
+
357
+
358
+ class ClientContextManagerTests(unittest.TestCase):
359
+ def test_aclose_without_session(self) -> None:
360
+ async def run() -> None:
361
+ async with Switch2ApiClient("e", "p") as client:
362
+ self.assertIsNone(client._session)
363
+
364
+ asyncio.run(run())
365
+
366
+
367
+ class ParseAccountBalanceTests(unittest.TestCase):
368
+ def test_balance_from_dashboard(self) -> None:
369
+ html = """
370
+ <div id="CreditPaymentBalanceDashTile">
371
+ <div class="dashboard-credit-amount-desktop font-largest">
372
+ &#163;0.00
373
+ </div>
374
+ <div class="dashboard-credit-lastUpdated font-small-light">
375
+ Last updated 27/02/2026 10:13
376
+ </div>
377
+ </div>
378
+ """
379
+ soup = BeautifulSoup(html, "html.parser")
380
+ balance = _parse_account_balance(soup)
381
+ assert balance is not None
382
+ self.assertEqual(balance.balance, 0.00)
383
+ self.assertEqual(balance.last_updated, datetime(2026, 2, 27, 10, 13))
384
+
385
+ def test_balance_with_amount_owed(self) -> None:
386
+ html = """
387
+ <div id="CreditPaymentBalanceDashTile">
388
+ <div class="dashboard-credit-amount-desktop font-largest">
389
+ &#163;172.26
390
+ </div>
391
+ <div class="dashboard-credit-lastUpdated font-small-light">
392
+ Last updated 15/03/2026 14:30
393
+ </div>
394
+ </div>
395
+ """
396
+ soup = BeautifulSoup(html, "html.parser")
397
+ balance = _parse_account_balance(soup)
398
+ assert balance is not None
399
+ self.assertEqual(balance.balance, 172.26)
400
+ self.assertEqual(balance.last_updated, datetime(2026, 3, 15, 14, 30))
401
+
402
+ def test_no_balance_element(self) -> None:
403
+ soup = BeautifulSoup("<div></div>", "html.parser")
404
+ self.assertIsNone(_parse_account_balance(soup))
File without changes