switch2 0.2.0__tar.gz → 0.2.1__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.2.0 → switch2-0.2.1}/PKG-INFO +6 -2
- {switch2-0.2.0 → switch2-0.2.1}/README.md +5 -1
- {switch2-0.2.0 → switch2-0.2.1}/pyproject.toml +1 -1
- {switch2-0.2.0 → switch2-0.2.1}/switch2/__init__.py +2 -0
- {switch2-0.2.0 → switch2-0.2.1}/switch2/api.py +14 -3
- {switch2-0.2.0 → switch2-0.2.1}/switch2.egg-info/PKG-INFO +6 -2
- {switch2-0.2.0 → switch2-0.2.1}/tests/test_api.py +24 -2
- {switch2-0.2.0 → switch2-0.2.1}/setup.cfg +0 -0
- {switch2-0.2.0 → switch2-0.2.1}/switch2/__main__.py +0 -0
- {switch2-0.2.0 → switch2-0.2.1}/switch2/py.typed +0 -0
- {switch2-0.2.0 → switch2-0.2.1}/switch2.egg-info/SOURCES.txt +0 -0
- {switch2-0.2.0 → switch2-0.2.1}/switch2.egg-info/dependency_links.txt +0 -0
- {switch2-0.2.0 → switch2-0.2.1}/switch2.egg-info/requires.txt +0 -0
- {switch2-0.2.0 → switch2-0.2.1}/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.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -37,6 +37,7 @@ pip install switch2
|
|
|
37
37
|
import asyncio
|
|
38
38
|
from switch2 import Switch2ApiClient
|
|
39
39
|
|
|
40
|
+
|
|
40
41
|
async def main():
|
|
41
42
|
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
42
43
|
data = await client.fetch_data()
|
|
@@ -50,12 +51,15 @@ async def main():
|
|
|
50
51
|
detail = await client.fetch_bill_detail(bill)
|
|
51
52
|
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
52
53
|
|
|
54
|
+
|
|
53
55
|
asyncio.run(main())
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
57
59
|
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
58
|
-
look the way the parser expects, which usually means the portal changed
|
|
60
|
+
look the way the parser expects, which usually means the portal changed;
|
|
61
|
+
`Switch2UnavailableError` means the portal failed to load data it normally
|
|
62
|
+
has, which tends to pass if you try again later.
|
|
59
63
|
|
|
60
64
|
`data.readings` holds the readings of the register the portal selects by
|
|
61
65
|
default; `data.register_readings` maps each register in `data.registers`
|
|
@@ -14,6 +14,7 @@ pip install switch2
|
|
|
14
14
|
import asyncio
|
|
15
15
|
from switch2 import Switch2ApiClient
|
|
16
16
|
|
|
17
|
+
|
|
17
18
|
async def main():
|
|
18
19
|
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
19
20
|
data = await client.fetch_data()
|
|
@@ -27,12 +28,15 @@ async def main():
|
|
|
27
28
|
detail = await client.fetch_bill_detail(bill)
|
|
28
29
|
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
29
30
|
|
|
31
|
+
|
|
30
32
|
asyncio.run(main())
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
34
36
|
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
35
|
-
look the way the parser expects, which usually means the portal changed
|
|
37
|
+
look the way the parser expects, which usually means the portal changed;
|
|
38
|
+
`Switch2UnavailableError` means the portal failed to load data it normally
|
|
39
|
+
has, which tends to pass if you try again later.
|
|
36
40
|
|
|
37
41
|
`data.readings` holds the readings of the register the portal selects by
|
|
38
42
|
default; `data.register_readings` maps each register in `data.registers`
|
|
@@ -13,6 +13,7 @@ from .api import (
|
|
|
13
13
|
Switch2Data,
|
|
14
14
|
Switch2Error,
|
|
15
15
|
Switch2ParseError,
|
|
16
|
+
Switch2UnavailableError,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
@@ -28,4 +29,5 @@ __all__ = [
|
|
|
28
29
|
"Switch2Data",
|
|
29
30
|
"Switch2Error",
|
|
30
31
|
"Switch2ParseError",
|
|
32
|
+
"Switch2UnavailableError",
|
|
31
33
|
]
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import re
|
|
6
7
|
from collections.abc import Callable
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from datetime import datetime
|
|
@@ -37,6 +38,14 @@ class Switch2ParseError(Switch2Error):
|
|
|
37
38
|
"""Raised when a portal page does not have the expected structure."""
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
class Switch2UnavailableError(Switch2Error):
|
|
42
|
+
"""Raised when the portal fails to load data it normally has.
|
|
43
|
+
|
|
44
|
+
This happens from time to time, e.g. it lists no meters for an account
|
|
45
|
+
that has them; trying again later usually works.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
@dataclass
|
|
41
50
|
class CustomerInfo:
|
|
42
51
|
"""Customer information from the Switch2 portal."""
|
|
@@ -245,6 +254,10 @@ class Switch2ApiClient:
|
|
|
245
254
|
reg_id = _get_attr(option, "value")
|
|
246
255
|
if reg_id:
|
|
247
256
|
registers[reg_id] = option.text.strip()
|
|
257
|
+
if "-1" in registers:
|
|
258
|
+
raise Switch2UnavailableError(
|
|
259
|
+
f"Portal could not load any meters: {registers['-1']!r}"
|
|
260
|
+
)
|
|
248
261
|
|
|
249
262
|
selected = soup.select_one("#RegisterId option[selected]")
|
|
250
263
|
selected_id = _get_attr(selected, "value") if selected else ""
|
|
@@ -294,9 +307,7 @@ def _form_token(soup: BeautifulSoup) -> dict[str, str]:
|
|
|
294
307
|
|
|
295
308
|
def _parse_date(text: str) -> datetime:
|
|
296
309
|
"""Parse a date string like '27th February 2026'."""
|
|
297
|
-
|
|
298
|
-
for suffix in ("st ", "nd ", "rd ", "th "):
|
|
299
|
-
text = text.replace(suffix, " ")
|
|
310
|
+
text = re.sub(r"^(\d+)(?:st|nd|rd|th)\b", r"\1", text.strip())
|
|
300
311
|
for fmt in ("%d %B %Y", "%d %b %Y"):
|
|
301
312
|
try:
|
|
302
313
|
return datetime.strptime(text, fmt)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switch2
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -37,6 +37,7 @@ pip install switch2
|
|
|
37
37
|
import asyncio
|
|
38
38
|
from switch2 import Switch2ApiClient
|
|
39
39
|
|
|
40
|
+
|
|
40
41
|
async def main():
|
|
41
42
|
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
42
43
|
data = await client.fetch_data()
|
|
@@ -50,12 +51,15 @@ async def main():
|
|
|
50
51
|
detail = await client.fetch_bill_detail(bill)
|
|
51
52
|
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
52
53
|
|
|
54
|
+
|
|
53
55
|
asyncio.run(main())
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
57
59
|
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
58
|
-
look the way the parser expects, which usually means the portal changed
|
|
60
|
+
look the way the parser expects, which usually means the portal changed;
|
|
61
|
+
`Switch2UnavailableError` means the portal failed to load data it normally
|
|
62
|
+
has, which tends to pass if you try again later.
|
|
59
63
|
|
|
60
64
|
`data.readings` holds the readings of the register the portal selects by
|
|
61
65
|
default; `data.register_readings` maps each register in `data.registers`
|
|
@@ -12,6 +12,7 @@ from switch2.api import (
|
|
|
12
12
|
Switch2ApiClient,
|
|
13
13
|
Switch2AuthError,
|
|
14
14
|
Switch2ParseError,
|
|
15
|
+
Switch2UnavailableError,
|
|
15
16
|
_parse_account_balance,
|
|
16
17
|
_parse_bill_detail,
|
|
17
18
|
_parse_bills,
|
|
@@ -35,6 +36,10 @@ class ParseDateTests(unittest.TestCase):
|
|
|
35
36
|
def test_ordinal_rd(self) -> None:
|
|
36
37
|
self.assertEqual(_parse_date("3rd April 2025"), datetime(2025, 4, 3))
|
|
37
38
|
|
|
39
|
+
def test_month_containing_suffix(self) -> None:
|
|
40
|
+
self.assertEqual(_parse_date("27th August 2026"), datetime(2026, 8, 27))
|
|
41
|
+
self.assertEqual(_parse_date("1st August 2026"), datetime(2026, 8, 1))
|
|
42
|
+
|
|
38
43
|
def test_abbreviated_month(self) -> None:
|
|
39
44
|
self.assertEqual(_parse_date("15th Jan 2025"), datetime(2025, 1, 15))
|
|
40
45
|
|
|
@@ -465,8 +470,9 @@ def _reading_row(date: str, amount: str) -> str:
|
|
|
465
470
|
class FakePortal:
|
|
466
471
|
"""Serves canned pages in place of Switch2ApiClient._fetch."""
|
|
467
472
|
|
|
468
|
-
def __init__(self, login_response: str) -> None:
|
|
473
|
+
def __init__(self, login_response: str, meter_history: str = METER_HISTORY) -> None:
|
|
469
474
|
self.login_response = login_response
|
|
475
|
+
self.meter_history = meter_history
|
|
470
476
|
self.posts: list[tuple[str, dict[str, str]]] = []
|
|
471
477
|
|
|
472
478
|
async def __call__(
|
|
@@ -478,7 +484,7 @@ class FakePortal:
|
|
|
478
484
|
html = LOGIN_FORM if data is None else self.login_response
|
|
479
485
|
elif url.endswith("/MeterReadings/History"):
|
|
480
486
|
if data is None:
|
|
481
|
-
html =
|
|
487
|
+
html = self.meter_history
|
|
482
488
|
elif data["RegisterId"] == "R1":
|
|
483
489
|
html = _reading_row("26th October 2026", "9166 kWh")
|
|
484
490
|
else:
|
|
@@ -548,3 +554,19 @@ class FetchDataTests(unittest.TestCase):
|
|
|
548
554
|
{"R1": [9166.0], "R2": [512.0]},
|
|
549
555
|
)
|
|
550
556
|
self.assertEqual([x.amount for x in data.readings], [9166.0])
|
|
557
|
+
|
|
558
|
+
def test_no_meters_available(self) -> None:
|
|
559
|
+
portal = FakePortal(
|
|
560
|
+
DASHBOARD,
|
|
561
|
+
meter_history="""
|
|
562
|
+
<form>
|
|
563
|
+
<input name="__RequestVerificationToken" value="tok2">
|
|
564
|
+
<select id="RegisterId">
|
|
565
|
+
<option value="-1" selected>No meters available</option>
|
|
566
|
+
</select>
|
|
567
|
+
</form>
|
|
568
|
+
""",
|
|
569
|
+
)
|
|
570
|
+
with self.assertRaises(Switch2UnavailableError):
|
|
571
|
+
asyncio.run(_client(portal).fetch_data())
|
|
572
|
+
self.assertEqual(portal.posts[1:], [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|