switch2 0.1.0__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.0/PKG-INFO ADDED
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: switch2
3
+ Version: 0.1.0
4
+ Summary: Async client library for the Switch2 energy portal
5
+ Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/jelmer/python-switch2
8
+ Project-URL: Repository, https://github.com/jelmer/python-switch2
9
+ Project-URL: Issues, https://github.com/jelmer/python-switch2/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Home Automation
18
+ Classifier: Framework :: AsyncIO
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: aiohttp>=3.8
22
+ Requires-Dist: beautifulsoup4>=4.12
23
+
24
+ # python-switch2
25
+
26
+ Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
27
+
28
+ ## Installation
29
+
30
+ ```sh
31
+ pip install switch2
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ import asyncio
38
+ from switch2 import Switch2ApiClient
39
+
40
+ async def main():
41
+ client = Switch2ApiClient("you@example.com", "your-password")
42
+ try:
43
+ data = await client.fetch_data()
44
+ print(f"Customer: {data.customer.name}")
45
+ for reading in data.readings:
46
+ print(f" {reading.date}: {reading.amount} {reading.unit}")
47
+ for bill in data.bills:
48
+ print(f" {bill.date}: £{bill.amount}")
49
+ finally:
50
+ await client.close()
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## License
56
+
57
+ Apache-2.0
@@ -0,0 +1,34 @@
1
+ # python-switch2
2
+
3
+ Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pip install switch2
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import asyncio
15
+ from switch2 import Switch2ApiClient
16
+
17
+ async def main():
18
+ client = Switch2ApiClient("you@example.com", "your-password")
19
+ try:
20
+ data = await client.fetch_data()
21
+ print(f"Customer: {data.customer.name}")
22
+ for reading in data.readings:
23
+ print(f" {reading.date}: {reading.amount} {reading.unit}")
24
+ for bill in data.bills:
25
+ print(f" {bill.date}: £{bill.amount}")
26
+ finally:
27
+ await client.close()
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ ## License
33
+
34
+ Apache-2.0
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "switch2"
7
+ version = "0.1.0"
8
+ description = "Async client library for the Switch2 energy portal"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Jelmer Vernooij", email = "jelmer@jelmer.uk" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Home Automation",
24
+ "Framework :: AsyncIO",
25
+ ]
26
+ dependencies = [
27
+ "aiohttp>=3.8",
28
+ "beautifulsoup4>=4.12",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/jelmer/python-switch2"
33
+ Repository = "https://github.com/jelmer/python-switch2"
34
+ Issues = "https://github.com/jelmer/python-switch2/issues"
35
+
36
+ [tool.mypy]
37
+ packages = ["switch2"]
38
+ strict = true
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "W"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ """Standalone client library for the Switch2 energy portal."""
2
+
3
+ from .api import (
4
+ Bill,
5
+ CustomerInfo,
6
+ MeterReading,
7
+ Switch2ApiClient,
8
+ Switch2AuthError,
9
+ Switch2ConnectionError,
10
+ Switch2Data,
11
+ )
12
+
13
+ __all__ = [
14
+ "Bill",
15
+ "CustomerInfo",
16
+ "MeterReading",
17
+ "Switch2ApiClient",
18
+ "Switch2AuthError",
19
+ "Switch2ConnectionError",
20
+ "Switch2Data",
21
+ ]
@@ -0,0 +1,289 @@
1
+ """API client for the Switch2 energy portal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ import aiohttp
10
+ from bs4 import BeautifulSoup, Tag
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ BASE_URL = "https://my.switch2.co.uk"
15
+ LOGIN_URL = f"{BASE_URL}/Login"
16
+ METER_HISTORY_URL = f"{BASE_URL}/MeterReadings/History"
17
+ BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
18
+
19
+
20
+ class Switch2AuthError(Exception):
21
+ """Raised when authentication fails."""
22
+
23
+
24
+ class Switch2ConnectionError(Exception):
25
+ """Raised when a connection error occurs."""
26
+
27
+
28
+ @dataclass
29
+ class CustomerInfo:
30
+ """Customer information from the Switch2 portal."""
31
+
32
+ name: str
33
+ account_number: str
34
+ address: str
35
+
36
+
37
+ @dataclass
38
+ class MeterReading:
39
+ """A single meter reading."""
40
+
41
+ date: datetime
42
+ amount: float
43
+ unit: str
44
+ reading_type: str
45
+
46
+
47
+ @dataclass
48
+ class Bill:
49
+ """A single bill."""
50
+
51
+ date: datetime
52
+ amount: float
53
+ detail_url: str
54
+
55
+
56
+ @dataclass
57
+ class Switch2Data:
58
+ """All data fetched from the Switch2 portal."""
59
+
60
+ customer: CustomerInfo
61
+ readings: list[MeterReading]
62
+ registers: dict[str, str] # register_id -> register_name
63
+ bills: list[Bill]
64
+
65
+
66
+ def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
67
+ """Get a string attribute from a BeautifulSoup tag, handling list values."""
68
+ value = tag.get(attr, default)
69
+ if isinstance(value, list):
70
+ return value[0] if value else default
71
+ return value
72
+
73
+
74
+ class Switch2ApiClient:
75
+ """Client to interact with the Switch2 web portal."""
76
+
77
+ def __init__(self, email: str, password: str) -> None:
78
+ self._email = email
79
+ self._password = password
80
+ self._session: aiohttp.ClientSession | None = None
81
+
82
+ async def _ensure_session(self) -> aiohttp.ClientSession:
83
+ if self._session is None or self._session.closed:
84
+ self._session = aiohttp.ClientSession()
85
+ return self._session
86
+
87
+ async def close(self) -> None:
88
+ """Close the underlying HTTP session."""
89
+ if self._session and not self._session.closed:
90
+ await self._session.close()
91
+
92
+ async def authenticate(self) -> CustomerInfo:
93
+ """Log in to the Switch2 portal and return customer info."""
94
+ session = await self._ensure_session()
95
+
96
+ try:
97
+ # Step 1: GET the login page to get any CSRF tokens
98
+ async with session.get(LOGIN_URL) as resp:
99
+ if resp.status != 200:
100
+ raise Switch2ConnectionError(
101
+ f"Failed to load login page: HTTP {resp.status}"
102
+ )
103
+ html = await resp.text()
104
+
105
+ soup = BeautifulSoup(html, "html.parser")
106
+ token_input = soup.select_one('input[name="__RequestVerificationToken"]')
107
+ form_data: dict[str, str] = {}
108
+ if token_input:
109
+ form_data["__RequestVerificationToken"] = _get_attr(
110
+ token_input, "value"
111
+ )
112
+
113
+ # Step 2: POST login credentials
114
+ form_data["UserName"] = self._email
115
+ form_data["Password"] = self._password
116
+
117
+ async with session.post(
118
+ LOGIN_URL, data=form_data, allow_redirects=True
119
+ ) as resp:
120
+ if resp.status != 200:
121
+ raise Switch2ConnectionError(
122
+ f"Login request failed: HTTP {resp.status}"
123
+ )
124
+ html = await resp.text()
125
+
126
+ soup = BeautifulSoup(html, "html.parser")
127
+ customer = _parse_customer_info(soup)
128
+ if not customer.name:
129
+ raise Switch2AuthError("Login failed: no customer info returned")
130
+
131
+ return customer
132
+
133
+ except aiohttp.ClientError as err:
134
+ raise Switch2ConnectionError(
135
+ f"Connection error during login: {err}"
136
+ ) from err
137
+
138
+ async def fetch_data(self) -> Switch2Data:
139
+ """Authenticate and fetch all meter data."""
140
+ customer = await self.authenticate()
141
+ session = await self._ensure_session()
142
+
143
+ try:
144
+ # GET meter readings history page
145
+ async with session.get(METER_HISTORY_URL) as resp:
146
+ if resp.status != 200:
147
+ raise Switch2ConnectionError(
148
+ f"Failed to load meter history: HTTP {resp.status}"
149
+ )
150
+ html = await resp.text()
151
+
152
+ soup = BeautifulSoup(html, "html.parser")
153
+
154
+ # Parse available registers
155
+ registers: dict[str, str] = {}
156
+ register_select = soup.select_one("#RegisterId")
157
+ if register_select:
158
+ for option in register_select.select("option"):
159
+ reg_id = option.get("value", "")
160
+ if reg_id:
161
+ registers[str(reg_id)] = option.text.strip()
162
+
163
+ # Try to get all readings by POSTing with a large page size
164
+ token_input = soup.select_one('input[name="__RequestVerificationToken"]')
165
+ selected_register = soup.select_one("#RegisterId option[selected]")
166
+
167
+ if token_input and selected_register:
168
+ form_data = {
169
+ "__RequestVerificationToken": _get_attr(token_input, "value"),
170
+ "Page": "1",
171
+ "TotalPages": "1",
172
+ "RegisterId": _get_attr(selected_register, "value"),
173
+ "PageSize": "1000000",
174
+ }
175
+
176
+ async with session.post(
177
+ METER_HISTORY_URL, data=form_data, allow_redirects=True
178
+ ) as resp:
179
+ if resp.status == 200:
180
+ html = await resp.text()
181
+ soup = BeautifulSoup(html, "html.parser")
182
+
183
+ readings = _parse_readings(soup)
184
+
185
+ # Fetch bill history
186
+ async with session.get(BILL_HISTORY_URL) as resp:
187
+ if resp.status != 200:
188
+ raise Switch2ConnectionError(
189
+ f"Failed to load bill history: HTTP {resp.status}"
190
+ )
191
+ html = await resp.text()
192
+
193
+ bills = _parse_bills(BeautifulSoup(html, "html.parser"))
194
+
195
+ return Switch2Data(
196
+ customer=customer,
197
+ readings=readings,
198
+ registers=registers,
199
+ bills=bills,
200
+ )
201
+
202
+ except aiohttp.ClientError as err:
203
+ raise Switch2ConnectionError(
204
+ f"Connection error fetching data: {err}"
205
+ ) from err
206
+
207
+
208
+ def _parse_date(text: str) -> datetime:
209
+ """Parse a date string like '27th February 2026'."""
210
+ # Strip ordinal suffixes (1st, 2nd, 3rd, 4th, etc.)
211
+ for suffix in ("st ", "nd ", "rd ", "th "):
212
+ text = text.replace(suffix, " ")
213
+ for fmt in ("%d %B %Y", "%d %b %Y"):
214
+ try:
215
+ return datetime.strptime(text, fmt)
216
+ except ValueError:
217
+ continue
218
+ raise ValueError(f"Cannot parse date: {text!r}")
219
+
220
+
221
+ def _parse_customer_info(soup: BeautifulSoup) -> CustomerInfo:
222
+ """Extract customer info from the dashboard page."""
223
+ name_el = soup.select_one(".customer-info-name")
224
+ acn_el = soup.select_one(".customer-info-account-number")
225
+ addr_el = soup.select_one(".customer-info-address")
226
+
227
+ return CustomerInfo(
228
+ name=name_el.text.strip() if name_el else "",
229
+ account_number=acn_el.text.strip() if acn_el else "",
230
+ address=addr_el.text.strip() if addr_el else "",
231
+ )
232
+
233
+
234
+ def _parse_readings(soup: BeautifulSoup) -> list[MeterReading]:
235
+ """Extract meter readings from the history page."""
236
+ readings = []
237
+ rows = soup.select(".meter-reading-history-table-data-row.desktop-layout")
238
+ for row in rows:
239
+ date_el = row.select_one(".meter-reading-history-table-data-date-row-item")
240
+ amount_el = row.select_one(".meter-reading-history-table-data-amount-row-item")
241
+ type_el = row.select_one(".meter-reading-history-table-data-type-row-item")
242
+ if date_el and amount_el:
243
+ try:
244
+ date = _parse_date(date_el.text.strip())
245
+ # Parse amount and unit (e.g. "8551 kWh" -> 8551, "kWh")
246
+ amount_parts = amount_el.text.strip().split()
247
+ amount = float(amount_parts[0])
248
+ unit = amount_parts[1] if len(amount_parts) > 1 else ""
249
+ reading_type = type_el.text.strip() if type_el else ""
250
+ readings.append(
251
+ MeterReading(
252
+ date=date,
253
+ amount=amount,
254
+ unit=unit,
255
+ reading_type=reading_type,
256
+ )
257
+ )
258
+ except (ValueError, TypeError) as err:
259
+ _LOGGER.debug("Failed to parse meter reading row: %s", err)
260
+
261
+ return readings
262
+
263
+
264
+ def _parse_bills(soup: BeautifulSoup) -> list[Bill]:
265
+ """Extract bills from the bill history page."""
266
+ bills = []
267
+ rows = soup.select(".bill-history-table-data-row")
268
+ for row in rows:
269
+ date_el = row.select_one(".bill-history-table-data-row-text-item")
270
+ amount_el = row.select_one(
271
+ ".bill-history-table-data-row-item-right"
272
+ ".bill-history-table-data-row-text-item"
273
+ )
274
+ link_el = row.select_one("a.bill-history-view-bill-button")
275
+ if date_el and amount_el:
276
+ try:
277
+ date = _parse_date(date_el.text.strip())
278
+ # Parse amount (e.g. "£172.26" -> 172.26)
279
+ amount_text = amount_el.text.strip().lstrip("£").replace(",", "")
280
+ amount = float(amount_text)
281
+ detail_url = ""
282
+ if link_el:
283
+ href = _get_attr(link_el, "href")
284
+ detail_url = f"{BASE_URL}{href}" if href else ""
285
+ bills.append(Bill(date=date, amount=amount, detail_url=detail_url))
286
+ except (ValueError, TypeError) as err:
287
+ _LOGGER.debug("Failed to parse bill row: %s", err)
288
+
289
+ return bills
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: switch2
3
+ Version: 0.1.0
4
+ Summary: Async client library for the Switch2 energy portal
5
+ Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/jelmer/python-switch2
8
+ Project-URL: Repository, https://github.com/jelmer/python-switch2
9
+ Project-URL: Issues, https://github.com/jelmer/python-switch2/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Home Automation
18
+ Classifier: Framework :: AsyncIO
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: aiohttp>=3.8
22
+ Requires-Dist: beautifulsoup4>=4.12
23
+
24
+ # python-switch2
25
+
26
+ Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
27
+
28
+ ## Installation
29
+
30
+ ```sh
31
+ pip install switch2
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ import asyncio
38
+ from switch2 import Switch2ApiClient
39
+
40
+ async def main():
41
+ client = Switch2ApiClient("you@example.com", "your-password")
42
+ try:
43
+ data = await client.fetch_data()
44
+ print(f"Customer: {data.customer.name}")
45
+ for reading in data.readings:
46
+ print(f" {reading.date}: {reading.amount} {reading.unit}")
47
+ for bill in data.bills:
48
+ print(f" {bill.date}: £{bill.amount}")
49
+ finally:
50
+ await client.close()
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## License
56
+
57
+ Apache-2.0
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ switch2/__init__.py
4
+ switch2/api.py
5
+ switch2.egg-info/PKG-INFO
6
+ switch2.egg-info/SOURCES.txt
7
+ switch2.egg-info/dependency_links.txt
8
+ switch2.egg-info/requires.txt
9
+ switch2.egg-info/top_level.txt
10
+ tests/test_api.py
@@ -0,0 +1,2 @@
1
+ aiohttp>=3.8
2
+ beautifulsoup4>=4.12
@@ -0,0 +1 @@
1
+ switch2
@@ -0,0 +1,148 @@
1
+ """Tests for the Switch2 API client."""
2
+
3
+ import unittest
4
+ from datetime import datetime
5
+
6
+ from bs4 import BeautifulSoup
7
+
8
+ from switch2.api import (
9
+ _parse_bills,
10
+ _parse_customer_info,
11
+ _parse_date,
12
+ _parse_readings,
13
+ )
14
+
15
+
16
+ class ParseDateTests(unittest.TestCase):
17
+ def test_ordinal_th(self) -> None:
18
+ self.assertEqual(_parse_date("27th February 2026"), datetime(2026, 2, 27))
19
+
20
+ def test_ordinal_st(self) -> None:
21
+ self.assertEqual(_parse_date("1st January 2025"), datetime(2025, 1, 1))
22
+
23
+ def test_ordinal_nd(self) -> None:
24
+ self.assertEqual(_parse_date("2nd March 2025"), datetime(2025, 3, 2))
25
+
26
+ def test_ordinal_rd(self) -> None:
27
+ self.assertEqual(_parse_date("3rd April 2025"), datetime(2025, 4, 3))
28
+
29
+ def test_abbreviated_month(self) -> None:
30
+ self.assertEqual(_parse_date("15th Jan 2025"), datetime(2025, 1, 15))
31
+
32
+ def test_invalid_date(self) -> None:
33
+ with self.assertRaises(ValueError):
34
+ _parse_date("not a date")
35
+
36
+
37
+ class ParseCustomerInfoTests(unittest.TestCase):
38
+ def test_full_info(self) -> None:
39
+ html = """
40
+ <div>
41
+ <span class="customer-info-name">John Doe</span>
42
+ <span class="customer-info-account-number">ACC-123</span>
43
+ <span class="customer-info-address">42 Test Lane</span>
44
+ </div>
45
+ """
46
+ soup = BeautifulSoup(html, "html.parser")
47
+ info = _parse_customer_info(soup)
48
+ self.assertEqual(info.name, "John Doe")
49
+ self.assertEqual(info.account_number, "ACC-123")
50
+ self.assertEqual(info.address, "42 Test Lane")
51
+
52
+ def test_missing_elements(self) -> None:
53
+ soup = BeautifulSoup("<div></div>", "html.parser")
54
+ info = _parse_customer_info(soup)
55
+ self.assertEqual(info.name, "")
56
+ self.assertEqual(info.account_number, "")
57
+ self.assertEqual(info.address, "")
58
+
59
+
60
+ class ParseReadingsTests(unittest.TestCase):
61
+ def test_single_reading(self) -> None:
62
+ html = """
63
+ <div class="meter-reading-history-table-data-row desktop-layout">
64
+ <div class="meter-reading-history-table-data-date-row-item">
65
+ 27th February 2026
66
+ </div>
67
+ <div class="meter-reading-history-table-data-amount-row-item">
68
+ 8551 kWh
69
+ </div>
70
+ <div class="meter-reading-history-table-data-type-row-item">
71
+ Actual
72
+ </div>
73
+ </div>
74
+ """
75
+ soup = BeautifulSoup(html, "html.parser")
76
+ readings = _parse_readings(soup)
77
+ self.assertEqual(len(readings), 1)
78
+ self.assertEqual(readings[0].date, datetime(2026, 2, 27))
79
+ self.assertEqual(readings[0].amount, 8551.0)
80
+ self.assertEqual(readings[0].unit, "kWh")
81
+ self.assertEqual(readings[0].reading_type, "Actual")
82
+
83
+ def test_no_readings(self) -> None:
84
+ soup = BeautifulSoup("<div></div>", "html.parser")
85
+ self.assertEqual(_parse_readings(soup), [])
86
+
87
+ def test_amount_without_unit(self) -> None:
88
+ html = """
89
+ <div class="meter-reading-history-table-data-row desktop-layout">
90
+ <div class="meter-reading-history-table-data-date-row-item">
91
+ 1st January 2025
92
+ </div>
93
+ <div class="meter-reading-history-table-data-amount-row-item">
94
+ 1234
95
+ </div>
96
+ </div>
97
+ """
98
+ soup = BeautifulSoup(html, "html.parser")
99
+ readings = _parse_readings(soup)
100
+ self.assertEqual(len(readings), 1)
101
+ self.assertEqual(readings[0].unit, "")
102
+ self.assertEqual(readings[0].reading_type, "")
103
+
104
+
105
+ class ParseBillsTests(unittest.TestCase):
106
+ def test_single_bill(self) -> None:
107
+ bill_amount_cls = (
108
+ "bill-history-table-data-row-item-right"
109
+ " bill-history-table-data-row-text-item"
110
+ )
111
+ html = f"""
112
+ <div class="bill-history-table-data-row">
113
+ <div class="bill-history-table-data-row-text-item">
114
+ 15th March 2025
115
+ </div>
116
+ <div class="{bill_amount_cls}">£172.26</div>
117
+ <a class="bill-history-view-bill-button"
118
+ href="/Credit/Bill/42">View</a>
119
+ </div>
120
+ """
121
+ soup = BeautifulSoup(html, "html.parser")
122
+ bills = _parse_bills(soup)
123
+ self.assertEqual(len(bills), 1)
124
+ self.assertEqual(bills[0].date, datetime(2025, 3, 15))
125
+ self.assertEqual(bills[0].amount, 172.26)
126
+ self.assertEqual(bills[0].detail_url, "https://my.switch2.co.uk/Credit/Bill/42")
127
+
128
+ def test_no_bills(self) -> None:
129
+ soup = BeautifulSoup("<div></div>", "html.parser")
130
+ self.assertEqual(_parse_bills(soup), [])
131
+
132
+ def test_bill_without_link(self) -> None:
133
+ bill_amount_cls = (
134
+ "bill-history-table-data-row-item-right"
135
+ " bill-history-table-data-row-text-item"
136
+ )
137
+ html = f"""
138
+ <div class="bill-history-table-data-row">
139
+ <div class="bill-history-table-data-row-text-item">
140
+ 1st January 2025
141
+ </div>
142
+ <div class="{bill_amount_cls}">£50.00</div>
143
+ </div>
144
+ """
145
+ soup = BeautifulSoup(html, "html.parser")
146
+ bills = _parse_bills(soup)
147
+ self.assertEqual(len(bills), 1)
148
+ self.assertEqual(bills[0].detail_url, "")