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.
- {switch2-0.1.2 → switch2-0.1.3}/PKG-INFO +6 -5
- {switch2-0.1.2 → switch2-0.1.3}/README.md +5 -4
- {switch2-0.1.2 → switch2-0.1.3}/pyproject.toml +4 -1
- {switch2-0.1.2 → switch2-0.1.3}/switch2/api.py +131 -90
- switch2-0.1.3/switch2/py.typed +0 -0
- {switch2-0.1.2 → switch2-0.1.3}/switch2.egg-info/PKG-INFO +6 -5
- {switch2-0.1.2 → switch2-0.1.3}/switch2.egg-info/SOURCES.txt +1 -0
- {switch2-0.1.2 → switch2-0.1.3}/tests/test_api.py +11 -0
- {switch2-0.1.2 → switch2-0.1.3}/setup.cfg +0 -0
- {switch2-0.1.2 → switch2-0.1.3}/switch2/__init__.py +0 -0
- {switch2-0.1.2 → switch2-0.1.3}/switch2.egg-info/dependency_links.txt +0 -0
- {switch2-0.1.2 → switch2-0.1.3}/switch2.egg-info/requires.txt +0 -0
- {switch2-0.1.2 → switch2-0.1.3}/switch2.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switch2
|
|
3
|
-
Version: 0.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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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"
|
|
517
|
+
if _has_class(child, "other-charges-table-row"):
|
|
449
518
|
break
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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"
|
|
537
|
+
if _has_class(sibling, "bill-table-row-desktop"):
|
|
478
538
|
charge_row = sibling.select_one(".bill-table-row")
|
|
479
|
-
if charge_row:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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 =
|
|
514
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
```
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|