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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.2.0
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`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "switch2"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Async client library for the Switch2 energy portal"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -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
- # Strip ordinal suffixes (1st, 2nd, 3rd, 4th, etc.)
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.0
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 = METER_HISTORY
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