switch2 0.1.2__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.2
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.2"
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
@@ -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."""
@@ -118,7 +122,7 @@ class Switch2ApiClient:
118
122
 
119
123
  async def _ensure_session(self) -> aiohttp.ClientSession:
120
124
  if self._session is None or self._session.closed:
121
- self._session = aiohttp.ClientSession()
125
+ self._session = aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT)
122
126
  return self._session
123
127
 
124
128
  async def close(self) -> None:
@@ -126,6 +130,17 @@ class Switch2ApiClient:
126
130
  if self._session and not self._session.closed:
127
131
  await self._session.close()
128
132
 
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
+
129
144
  async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
130
145
  """Log in to the Switch2 portal and return customer info and dashboard soup."""
131
146
  session = await self._ensure_session()
@@ -375,6 +390,30 @@ def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None:
375
390
  return AccountBalance(balance=balance, last_updated=last_updated)
376
391
 
377
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
+
378
417
  def _parse_bill_charges(
379
418
  soup: BeautifulSoup, container_selector: str
380
419
  ) -> list[BillCharge]:
@@ -382,20 +421,51 @@ def _parse_bill_charges(
382
421
  charges: list[BillCharge] = []
383
422
  rows = soup.select(f"{container_selector} .bill-table-row-desktop .bill-table-row")
384
423
  for row in rows:
385
- desc_el = row.select_one(".bill-table-row-item-left")
386
- units_el = row.select_one(".bill-table-row-item")
387
- charge_el = row.select_one(".bill-table-row-item-right")
388
- if desc_el and charge_el:
389
- charges.append(
390
- BillCharge(
391
- description=desc_el.text.strip(),
392
- units=units_el.text.strip() if units_el else "",
393
- charge=_parse_currency(charge_el.text),
394
- )
395
- )
424
+ charge = _parse_charge_row(row)
425
+ if charge is not None:
426
+ charges.append(charge)
396
427
  return charges
397
428
 
398
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
+
399
469
  def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
400
470
  """Extract bill detail from the bill detail page."""
401
471
  # Header: invoice number and date of issue
@@ -437,110 +507,81 @@ def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
437
507
  soup, "#BillItemsContainer > .bill-table-row:first-of-type"
438
508
  )
439
509
  # If the CSS child selector doesn't match, fall back to looking at
440
- # desktop rows that come before .other-charges-table-row
510
+ # desktop rows that come before .other-charges-table-row.
441
511
  if not consumption_charges:
442
512
  items_container = soup.select_one("#BillItemsContainer")
443
513
  if items_container:
444
- consumption_rows: list[Tag] = []
445
514
  for child in items_container.children:
446
515
  if not isinstance(child, Tag):
447
516
  continue
448
- if "other-charges-table-row" in child.get("class", []):
517
+ if _has_class(child, "other-charges-table-row"):
449
518
  break
450
- desktop = child.select_one(".bill-table-row-desktop .bill-table-row")
451
- if desktop:
452
- consumption_rows.append(desktop)
453
- elif "bill-table-row-desktop" in child.get("class", []):
454
- inner = child.select_one(".bill-table-row")
455
- if inner:
456
- consumption_rows.append(inner)
457
- for row in consumption_rows:
458
- desc_el = row.select_one(".bill-table-row-item-left")
459
- units_el = row.select_one(".bill-table-row-item")
460
- charge_el = row.select_one(".bill-table-row-item-right")
461
- if desc_el and charge_el:
462
- consumption_charges.append(
463
- BillCharge(
464
- description=desc_el.text.strip(),
465
- units=(units_el.text.strip() if units_el else ""),
466
- charge=_parse_currency(charge_el.text),
467
- )
468
- )
469
-
470
- # 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.
471
531
  other_charges: list[BillCharge] = []
472
532
  other_header = soup.select_one(".other-charges-table-row")
473
533
  if other_header:
474
534
  sibling = other_header.next_sibling
475
535
  while sibling:
476
536
  if isinstance(sibling, Tag):
477
- if "bill-table-row-desktop" in sibling.get("class", []):
537
+ if _has_class(sibling, "bill-table-row-desktop"):
478
538
  charge_row = sibling.select_one(".bill-table-row")
479
- if charge_row:
480
- desc_el = charge_row.select_one(".bill-table-row-item-left")
481
- units_el = charge_row.select_one(".bill-table-row-item")
482
- charge_el = charge_row.select_one(".bill-table-row-item-right")
483
- if desc_el and charge_el:
484
- other_charges.append(
485
- BillCharge(
486
- description=desc_el.text.strip(),
487
- units=(units_el.text.strip() if units_el else ""),
488
- charge=_parse_currency(charge_el.text),
489
- )
490
- )
491
- 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"):
492
544
  break
493
545
  sibling = sibling.next_sibling
494
546
 
495
- # Totals
496
- totals_container = soup.select_one("#BillTotalsCollapsibleContent")
497
- total_excl_vat = 0.0
498
- vat = 0.0
499
- total_rows = (
500
- 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
+ },
501
557
  )
502
- for row in total_rows:
503
- label_el = row.select_one(".bill-total-table-row-item-left")
504
- value_el = row.select_one(".bill-total-table-row-item-right")
505
- if label_el and value_el:
506
- label = label_el.text.strip()
507
- if label.startswith("Total charges excluding VAT"):
508
- total_excl_vat = _parse_currency(value_el.text)
509
- elif label.startswith("VAT"):
510
- vat = _parse_currency(value_el.text)
558
+ total_excl_vat = totals.get("total_excl_vat", 0.0)
559
+ vat = totals.get("vat", 0.0)
511
560
 
512
561
  # Bill total from the collapsible header
513
- total = 0.0
514
- total_header = soup.select_one(
515
- "#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",
516
566
  )
517
- if total_header:
518
- total = _parse_currency(total_header.text)
519
567
 
520
568
  # Account balance
521
- balance_container = soup.select_one("#AccountBalanceCollapsibleContent")
522
- previous_balance = 0.0
523
- payments_received = 0.0
524
- balance_rows = (
525
- 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
+ },
526
575
  )
527
- for row in balance_rows:
528
- label_el = row.select_one(".bill-total-table-row-item-left")
529
- value_el = row.select_one(".bill-total-table-row-item-right")
530
- if label_el and value_el:
531
- label = label_el.text.strip()
532
- if label == "Previous account balance":
533
- previous_balance = _parse_currency(value_el.text)
534
- elif label == "Payments received":
535
- payments_received = _parse_currency(value_el.text)
536
-
537
- balance = 0.0
538
- 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,
539
581
  "#AccountBalanceContainer > .collapsible-header"
540
- " .bill-total-table-row-item-right"
582
+ " .bill-total-table-row-item-right",
583
+ "account balance",
541
584
  )
542
- if balance_header:
543
- balance = _parse_currency(balance_header.text)
544
585
 
545
586
  # Download URL
546
587
  download_url = ""
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.2
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,13 @@
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,
9
11
  _parse_account_balance,
10
12
  _parse_bill_detail,
11
13
  _parse_bills,
@@ -353,6 +355,15 @@ class ParseBillDetailTests(unittest.TestCase):
353
355
  )
354
356
 
355
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
+
356
367
  class ParseAccountBalanceTests(unittest.TestCase):
357
368
  def test_balance_from_dashboard(self) -> None:
358
369
  html = """
File without changes
File without changes