leneda-client 0.5.0__tar.gz → 0.6.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.
Files changed (24) hide show
  1. {leneda_client-0.5.0/src/leneda_client.egg-info → leneda_client-0.6.0}/PKG-INFO +1 -1
  2. {leneda_client-0.5.0 → leneda_client-0.6.0}/examples/advanced_usage.py +3 -3
  3. {leneda_client-0.5.0 → leneda_client-0.6.0}/examples/basic_usage.py +7 -7
  4. {leneda_client-0.5.0 → leneda_client-0.6.0}/pyproject.toml +3 -3
  5. {leneda_client-0.5.0 → leneda_client-0.6.0}/setup.py +2 -0
  6. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda/client.py +82 -6
  7. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda/exceptions.py +6 -0
  8. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda/models.py +9 -0
  9. leneda_client-0.6.0/src/leneda/py.typed +0 -0
  10. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda/version.py +1 -1
  11. {leneda_client-0.5.0 → leneda_client-0.6.0/src/leneda_client.egg-info}/PKG-INFO +1 -1
  12. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda_client.egg-info/SOURCES.txt +1 -0
  13. {leneda_client-0.5.0 → leneda_client-0.6.0}/tests/test_client.py +142 -31
  14. {leneda_client-0.5.0 → leneda_client-0.6.0}/LICENSE +0 -0
  15. {leneda_client-0.5.0 → leneda_client-0.6.0}/MANIFEST.in +0 -0
  16. {leneda_client-0.5.0 → leneda_client-0.6.0}/README.md +0 -0
  17. {leneda_client-0.5.0 → leneda_client-0.6.0}/requirements.txt +0 -0
  18. {leneda_client-0.5.0 → leneda_client-0.6.0}/setup.cfg +0 -0
  19. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda/__init__.py +0 -0
  20. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda/obis_codes.py +0 -0
  21. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda_client.egg-info/dependency_links.txt +0 -0
  22. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda_client.egg-info/requires.txt +0 -0
  23. {leneda_client-0.5.0 → leneda_client-0.6.0}/src/leneda_client.egg-info/top_level.txt +0 -0
  24. {leneda_client-0.5.0 → leneda_client-0.6.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leneda-client
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Python client for the Leneda energy data platform
5
5
  Home-page: https://github.com/fedus/leneda-client
6
6
  Author: fedus
@@ -36,7 +36,7 @@ logging.basicConfig(
36
36
  logger = logging.getLogger("leneda_advanced_example")
37
37
 
38
38
 
39
- def parse_arguments():
39
+ def parse_arguments() -> argparse.Namespace:
40
40
  """Parse command-line arguments."""
41
41
  parser = argparse.ArgumentParser(description="Leneda API Client Advanced Usage Example")
42
42
 
@@ -94,7 +94,7 @@ def parse_arguments():
94
94
  return parser.parse_args()
95
95
 
96
96
 
97
- def get_credentials(args):
97
+ def get_credentials(args: argparse.Namespace) -> tuple[str, str]:
98
98
  """Get API credentials from arguments or environment variables."""
99
99
  api_key = args.api_key or os.environ.get("LENEDA_API_KEY")
100
100
  energy_id = args.energy_id or os.environ.get("LENEDA_ENERGY_ID")
@@ -159,7 +159,7 @@ def detect_anomalies(df: pd.DataFrame, threshold: float) -> pd.DataFrame:
159
159
  return df
160
160
 
161
161
 
162
- async def main():
162
+ async def main() -> None:
163
163
  # Parse command-line arguments
164
164
  args = parse_arguments()
165
165
 
@@ -29,7 +29,7 @@ logging.basicConfig(
29
29
  logger = logging.getLogger("leneda_example")
30
30
 
31
31
 
32
- def parse_arguments():
32
+ def parse_arguments() -> argparse.Namespace:
33
33
  """Parse command-line arguments."""
34
34
  parser = argparse.ArgumentParser(description="Leneda API Client Basic Usage Example")
35
35
 
@@ -59,7 +59,7 @@ def parse_arguments():
59
59
  return parser.parse_args()
60
60
 
61
61
 
62
- def get_credentials(args):
62
+ def get_credentials(args: argparse.Namespace) -> tuple[str, str]:
63
63
  """Get API credentials from arguments or environment variables."""
64
64
  api_key = args.api_key or os.environ.get("LENEDA_API_KEY")
65
65
  energy_id = args.energy_id or os.environ.get("LENEDA_ENERGY_ID")
@@ -79,7 +79,7 @@ def get_credentials(args):
79
79
  return api_key, energy_id
80
80
 
81
81
 
82
- async def main():
82
+ async def main() -> None:
83
83
  # Parse command-line arguments
84
84
  args = parse_arguments()
85
85
 
@@ -148,11 +148,11 @@ async def main():
148
148
  # Display all measurements
149
149
  if aggregated_data.aggregated_time_series:
150
150
  print("\nMonthly measurements:")
151
- for item in aggregated_data.aggregated_time_series:
151
+ for metering_value in aggregated_data.aggregated_time_series:
152
152
  print(
153
- f"Period: {item.started_at.strftime('%Y-%m')}, "
154
- f"Value: {item.value} {aggregated_data.unit}, "
155
- f"Calculated: {item.calculated}"
153
+ f"Period: {metering_value.started_at.strftime('%Y-%m')}, "
154
+ f"Value: {metering_value.value} {aggregated_data.unit}, "
155
+ f"Calculated: {metering_value.calculated}"
156
156
  )
157
157
 
158
158
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [tool.black]
6
6
  line-length = 100
7
- target-version = ['py38', 'py39', 'py310', 'py311']
7
+ target-version = ['py39', 'py310', 'py311']
8
8
  include = '\.pyi?$'
9
9
 
10
10
  [tool.isort]
@@ -14,12 +14,12 @@ multi_line_output = 3
14
14
  src_paths = ["src", "tests"]
15
15
 
16
16
  [tool.mypy]
17
- python_version = "3.8"
17
+ python_version = "3.9"
18
18
  warn_return_any = true
19
19
  warn_unused_configs = true
20
20
  disallow_untyped_defs = true
21
21
  disallow_incomplete_defs = true
22
- mypy_path = "src"
22
+ explicit_package_bases = true
23
23
 
24
24
  [tool.pytest.ini_options]
25
25
  testpaths = ["tests"]
@@ -35,6 +35,8 @@ setup(
35
35
  url="https://github.com/fedus/leneda-client",
36
36
  package_dir={"": "src"},
37
37
  packages=find_packages(where="src"),
38
+ include_package_data=True,
39
+ package_data={"leneda": ["py.typed"]},
38
40
  install_requires=requirements,
39
41
  classifiers=[
40
42
  "Development Status :: 4 - Beta",
@@ -8,14 +8,15 @@ energy consumption and production data for electricity and gas.
8
8
  import json
9
9
  import logging
10
10
  from datetime import datetime, timedelta
11
- from typing import Any, Dict, List, Optional, Union
11
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
12
12
 
13
13
  import aiohttp
14
- from aiohttp import ClientTimeout
14
+ from aiohttp import ClientResponseError, ClientTimeout
15
15
 
16
- from .exceptions import ForbiddenException, UnauthorizedException
16
+ from .exceptions import ForbiddenException, MeteringPointNotFoundException, UnauthorizedException
17
17
  from .models import (
18
18
  AggregatedMeteringData,
19
+ AuthenticationProbeResult,
19
20
  MeteringData,
20
21
  )
21
22
  from .obis_codes import ObisCode
@@ -115,7 +116,7 @@ class LenedaClient:
115
116
 
116
117
  # Parse the response
117
118
  if response.content:
118
- response_data = await response.json()
119
+ response_data: dict = await response.json()
119
120
  logger.debug(f"Response status: {response.status}")
120
121
  logger.debug(f"Response data: {json.dumps(response_data, indent=2)}")
121
122
  return response_data
@@ -133,6 +134,39 @@ class LenedaClient:
133
134
  logger.error(f"JSON decode error: {e}")
134
135
  raise
135
136
 
137
+ async def _make_metering_request(
138
+ self, request_callable: Callable[..., Awaitable[dict[Any, Any]]], *args: Any, **kwargs: Any
139
+ ) -> dict[Any, Any]:
140
+ """
141
+ Make a request to a metering data endpoint with 404 error handling.
142
+
143
+ This wrapper around any request callable specifically handles 404 errors for metering data
144
+ endpoints by raising MeteringPointNotFoundException.
145
+
146
+ Args:
147
+ request_callable: The callable to execute (e.g., self._make_request)
148
+ *args: Arguments to pass to the callable
149
+ **kwargs: Keyword arguments to pass to the callable
150
+
151
+ Returns:
152
+ The JSON response from the API
153
+
154
+ Raises:
155
+ MeteringPointNotFoundException: If the API returns a 404 status code
156
+ UnauthorizedException: If the API returns a 401 status code
157
+ ForbiddenException: If the API returns a 403 status code
158
+ aiohttp.ClientError: For other request errors
159
+ json.JSONDecodeError: If the response cannot be parsed as JSON
160
+ """
161
+ try:
162
+ return await request_callable(*args, **kwargs)
163
+ except aiohttp.ClientResponseError as e:
164
+ if e.status == 404:
165
+ raise MeteringPointNotFoundException(
166
+ "Metering point not found. The requested metering point may not exist or you may not have access to it."
167
+ )
168
+ raise
169
+
136
170
  async def get_metering_data(
137
171
  self,
138
172
  metering_point_code: str,
@@ -167,7 +201,9 @@ class LenedaClient:
167
201
  }
168
202
 
169
203
  # Make the request
170
- response_data = await self._make_request(method="GET", endpoint=endpoint, params=params)
204
+ response_data = await self._make_metering_request(
205
+ self._make_request, method="GET", endpoint=endpoint, params=params
206
+ )
171
207
 
172
208
  # Parse the response into a MeteringData object
173
209
  return MeteringData.from_dict(response_data)
@@ -212,7 +248,9 @@ class LenedaClient:
212
248
  }
213
249
 
214
250
  # Make the request
215
- response_data = await self._make_request(method="GET", endpoint=endpoint, params=params)
251
+ response_data = await self._make_metering_request(
252
+ self._make_request, method="GET", endpoint=endpoint, params=params
253
+ )
216
254
 
217
255
  # Parse the response into an AggregatedMeteringData object
218
256
  return AggregatedMeteringData.from_dict(response_data)
@@ -320,3 +358,41 @@ class LenedaClient:
320
358
  if await self.probe_metering_point_obis_code(metering_point_code, obis_code):
321
359
  supported_codes.append(obis_code)
322
360
  return supported_codes
361
+
362
+ async def probe_credentials(self) -> AuthenticationProbeResult:
363
+ """
364
+ Probe if credentials are valid.
365
+
366
+ NOTE: This is an experimental function, as the Leneda API does not provide a native way to verify credentials only.
367
+ Use with caution, may break or yield unexpected results.
368
+
369
+ This method attempts to verify authentication by making a request to the metering data access endpoint
370
+ with invalid parameters. If the API returns a 400 status code, it indicates that authentication is successful
371
+ but the request parameters are invalid. If it returns a 401 status code, authentication has failed.
372
+
373
+ We make a request with invalid parameters because we don't want to actually lodge a metering data access request,
374
+ we just want to verify that the credentials are valid.
375
+
376
+ Returns:
377
+ AuthenticationProbeResult: SUCCESS if authentication is valid, FAILURE if authentication failed,
378
+ or UNKNOWN if the result cannot be determined
379
+
380
+ Raises:
381
+ ForbiddenException: If the API returns a 403 status code
382
+ """
383
+ try:
384
+ await self.request_metering_data_access("", "", [], [])
385
+ except UnauthorizedException:
386
+ return AuthenticationProbeResult.FAILURE
387
+ except ClientResponseError as e:
388
+ # We expect a 400 response if authentication is successful and our request is invalid
389
+ if e.status == 400:
390
+ # Update the config entry with new token
391
+ return AuthenticationProbeResult.SUCCESS
392
+ return AuthenticationProbeResult.UNKNOWN
393
+ except ForbiddenException:
394
+ raise
395
+ except Exception:
396
+ return AuthenticationProbeResult.UNKNOWN
397
+
398
+ return AuthenticationProbeResult.UNKNOWN
@@ -19,3 +19,9 @@ class ForbiddenException(LenedaException):
19
19
  """Raised when access is forbidden (403 Forbidden), typically due to geoblocking or other access restrictions."""
20
20
 
21
21
  pass
22
+
23
+
24
+ class MeteringPointNotFoundException(LenedaException):
25
+ """Raised when a metering point is not found."""
26
+
27
+ pass
@@ -8,6 +8,7 @@ making it easier to work with the data in a type-safe manner.
8
8
  import logging
9
9
  from dataclasses import dataclass, field
10
10
  from datetime import datetime
11
+ from enum import Enum
11
12
  from typing import Any, Dict, List
12
13
 
13
14
  from dateutil import parser
@@ -237,3 +238,11 @@ class AggregatedMeteringData:
237
238
  f"AggregatedMeteringData(unit={self.unit}, "
238
239
  f"items_count={len(self.aggregated_time_series)})"
239
240
  )
241
+
242
+
243
+ class AuthenticationProbeResult(Enum):
244
+ """Result of an authentication probe."""
245
+
246
+ SUCCESS = "SUCCESS"
247
+ FAILURE = "FAILURE"
248
+ UNKNOWN = "UNKNOWN"
File without changes
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leneda-client
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Python client for the Leneda energy data platform
5
5
  Home-page: https://github.com/fedus/leneda-client
6
6
  Author: fedus
@@ -11,6 +11,7 @@ src/leneda/client.py
11
11
  src/leneda/exceptions.py
12
12
  src/leneda/models.py
13
13
  src/leneda/obis_codes.py
14
+ src/leneda/py.typed
14
15
  src/leneda/version.py
15
16
  src/leneda_client.egg-info/PKG-INFO
16
17
  src/leneda_client.egg-info/SOURCES.txt
@@ -3,25 +3,24 @@ Tests for the Leneda API client.
3
3
  """
4
4
 
5
5
  import json
6
- import os
7
- import sys
8
6
  import unittest
9
- from unittest.mock import AsyncMock, patch
7
+ from typing import Any
8
+ from unittest.mock import AsyncMock, MagicMock, patch
10
9
 
11
10
  import aiohttp
12
11
  import pytest
13
-
14
- # Add the src directory to the path
15
- sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
12
+ from aiohttp import ClientResponseError
16
13
 
17
14
  from src.leneda import LenedaClient
18
15
  from src.leneda.exceptions import (
19
16
  ForbiddenException,
17
+ MeteringPointNotFoundException,
20
18
  UnauthorizedException,
21
19
  )
22
20
  from src.leneda.models import (
23
21
  AggregatedMeteringData,
24
22
  AggregatedMeteringValue,
23
+ AuthenticationProbeResult,
25
24
  MeteringData,
26
25
  MeteringValue,
27
26
  )
@@ -33,7 +32,7 @@ class TestLenedaClient:
33
32
  """Test cases for the LenedaClient class."""
34
33
 
35
34
  @pytest.fixture(autouse=True)
36
- def setup(self):
35
+ def setup(self) -> None:
37
36
  """Set up test fixtures."""
38
37
  self.api_key = "test_api_key"
39
38
  self.energy_id = "test_energy_id"
@@ -82,7 +81,7 @@ class TestLenedaClient:
82
81
  }
83
82
 
84
83
  @patch("aiohttp.ClientSession.request")
85
- async def test_get_time_series(self, mock_request):
84
+ async def test_get_time_series(self, mock_request: Any) -> None:
86
85
  """Test getting time series data."""
87
86
  # Set up the mock response
88
87
  mock_response = AsyncMock()
@@ -131,7 +130,7 @@ class TestLenedaClient:
131
130
  }
132
131
 
133
132
  @patch("aiohttp.ClientSession.request")
134
- async def test_get_aggregated_time_series(self, mock_request):
133
+ async def test_get_aggregated_time_series(self, mock_request: Any) -> None:
135
134
  """Test getting aggregated time series data."""
136
135
  # Set up the mock response
137
136
  mock_response = AsyncMock()
@@ -183,7 +182,7 @@ class TestLenedaClient:
183
182
  }
184
183
 
185
184
  @patch("aiohttp.ClientSession.request")
186
- async def test_request_metering_data_access(self, mock_request):
185
+ async def test_request_metering_data_access(self, mock_request: Any) -> None:
187
186
  """Test requesting metering data access."""
188
187
  # Set up the mock response
189
188
  mock_response = AsyncMock()
@@ -224,14 +223,12 @@ class TestLenedaClient:
224
223
  }
225
224
 
226
225
  @patch("aiohttp.ClientSession.request")
227
- async def test_unauthorized_error(self, mock_request):
226
+ async def test_unauthorized_error(self, mock_request: Any) -> None:
228
227
  """Test handling of 401 Unauthorized errors."""
229
228
  # Set up the mock response with 401 status
230
- mock_response = AsyncMock()
231
- mock_response.status = 401
232
- mock_response.content = b"Unauthorized"
233
- mock_response.raise_for_status = lambda: None
234
- mock_request.return_value.__aenter__.return_value = mock_response
229
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
230
+ 401, "Unauthorized"
231
+ )
235
232
 
236
233
  # Call the method and check that it raises UnauthorizedException
237
234
  with pytest.raises(UnauthorizedException) as exc_info:
@@ -246,14 +243,12 @@ class TestLenedaClient:
246
243
  assert "API authentication failed" in str(exc_info.value)
247
244
 
248
245
  @patch("aiohttp.ClientSession.request")
249
- async def test_forbidden_error(self, mock_request):
246
+ async def test_forbidden_error(self, mock_request: Any) -> None:
250
247
  """Test handling of 403 Forbidden errors."""
251
248
  # Set up the mock response with 403 status
252
- mock_response = AsyncMock()
253
- mock_response.status = 403
254
- mock_response.content = b"Forbidden"
255
- mock_response.raise_for_status = lambda: None
256
- mock_request.return_value.__aenter__.return_value = mock_response
249
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
250
+ 403, "Forbidden"
251
+ )
257
252
 
258
253
  # Call the method and check that it raises ForbiddenException
259
254
  with pytest.raises(ForbiddenException) as exc_info:
@@ -268,13 +263,15 @@ class TestLenedaClient:
268
263
  assert "geoblocking" in str(exc_info.value)
269
264
 
270
265
  @patch("aiohttp.ClientSession.request")
271
- async def test_error_handling(self, mock_request):
272
- """Test error handling for other HTTP errors."""
266
+ async def test_metering_point_not_found_handling(self, mock_request: Any) -> None:
267
+ """Test error handling when a metering point is not found."""
273
268
  # Set up the mock response to raise an exception
274
- mock_request.side_effect = aiohttp.ClientError("404 Client Error")
269
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
270
+ 404, "Not found"
271
+ )
275
272
 
276
273
  # Call the method and check that it raises an exception
277
- with pytest.raises(aiohttp.ClientError):
274
+ with pytest.raises(MeteringPointNotFoundException):
278
275
  await self.client.get_metering_data(
279
276
  "LU-METERING_POINT1",
280
277
  ObisCode.ELEC_CONSUMPTION_ACTIVE,
@@ -283,7 +280,7 @@ class TestLenedaClient:
283
280
  )
284
281
 
285
282
  @patch("aiohttp.ClientSession.request")
286
- async def test_probe_metering_point_obis_code_valid(self, mock_request):
283
+ async def test_probe_metering_point_obis_code_valid(self, mock_request: Any) -> None:
287
284
  """Test probe_metering_point_obis_code with a valid metering point and OBIS code."""
288
285
  # Set up the mock response with valid data
289
286
  mock_response = AsyncMock()
@@ -304,7 +301,7 @@ class TestLenedaClient:
304
301
  mock_request.assert_called_once()
305
302
 
306
303
  @patch("aiohttp.ClientSession.request")
307
- async def test_probe_metering_point_obis_code_invalid(self, mock_request):
304
+ async def test_probe_metering_point_obis_code_invalid(self, mock_request: Any) -> None:
308
305
  """Test probe_metering_point_obis_code with an invalid metering point or unsupported OBIS code."""
309
306
  # Set up the mock response with null unit
310
307
  mock_response = AsyncMock()
@@ -325,11 +322,11 @@ class TestLenedaClient:
325
322
  mock_request.assert_called_once()
326
323
 
327
324
  @patch.object(LenedaClient, "probe_metering_point_obis_code")
328
- async def test_get_supported_obis_codes(self, mock_probe):
325
+ async def test_get_supported_obis_codes(self, mock_probe: Any) -> None:
329
326
  """Test getting supported OBIS codes for a metering point."""
330
327
 
331
328
  # Mock probe_metering_point_obis_code to return True for two codes, False otherwise
332
- def side_effect(metering_point_code, obis_code):
329
+ def side_effect(metering_point_code: str, obis_code: ObisCode) -> bool:
333
330
  return obis_code in [ObisCode.ELEC_CONSUMPTION_ACTIVE, ObisCode.ELEC_PRODUCTION_ACTIVE]
334
331
 
335
332
  mock_probe.side_effect = side_effect
@@ -347,7 +344,7 @@ class TestLenedaClient:
347
344
  assert mock_probe.call_count == len(ObisCode)
348
345
 
349
346
  @patch.object(LenedaClient, "probe_metering_point_obis_code", return_value=False)
350
- async def test_get_supported_obis_codes_none(self, mock_probe):
347
+ async def test_get_supported_obis_codes_none(self, mock_probe: Any) -> None:
351
348
  """Test getting supported OBIS codes when none are supported."""
352
349
  # Call the method
353
350
  result = await self.client.get_supported_obis_codes("INVALID-METERING-POINT")
@@ -359,6 +356,120 @@ class TestLenedaClient:
359
356
  # Check that the probe was called for each OBIS code
360
357
  assert mock_probe.call_count == len(ObisCode)
361
358
 
359
+ @patch("aiohttp.ClientSession.request")
360
+ async def test_probe_credentials_success(self, mock_request: Any) -> None:
361
+ """Test probe_credentials with valid credentials (400 response expected)."""
362
+ # Set up the mock response with 400 status (expected for valid credentials with invalid request)
363
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
364
+ 400, "Bad request"
365
+ )
366
+
367
+ # Call the method
368
+ result = await self.client.probe_credentials()
369
+
370
+ # Check the result
371
+ assert result == AuthenticationProbeResult.SUCCESS
372
+
373
+ # Check that the request was made correctly
374
+ mock_request.assert_called_once()
375
+ call_kwargs = mock_request.call_args[1]
376
+ assert call_kwargs["method"] == "POST"
377
+ assert call_kwargs["url"] == "https://api.leneda.lu/api/metering-data-access-request"
378
+ assert call_kwargs["headers"] == {
379
+ "X-API-KEY": "test_api_key",
380
+ "X-ENERGY-ID": "test_energy_id",
381
+ "Content-Type": "application/json",
382
+ }
383
+ assert call_kwargs["json"] == {
384
+ "from": "",
385
+ "fromName": "",
386
+ "meteringPointCodes": [],
387
+ "obisCodes": [],
388
+ }
389
+
390
+ @patch("aiohttp.ClientSession.request")
391
+ async def test_probe_credentials_unauthorized(self, mock_request: Any) -> None:
392
+ """Test probe_credentials with invalid credentials (401 response)."""
393
+ # Set up the mock response with 401 status
394
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
395
+ 401, "Unauthorized"
396
+ )
397
+
398
+ # Call the method
399
+ result = await self.client.probe_credentials()
400
+
401
+ # Check the result
402
+ assert result == AuthenticationProbeResult.FAILURE
403
+
404
+ # Check that the request was made correctly
405
+ mock_request.assert_called_once()
406
+
407
+ @patch("aiohttp.ClientSession.request")
408
+ async def test_probe_credentials_forbidden(self, mock_request: Any) -> None:
409
+ """Test probe_credentials with forbidden access (403 response)."""
410
+ # Set up the mock response with 403 status
411
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
412
+ 403, "Forbidden"
413
+ )
414
+
415
+ # Call the method and check that it raises ForbiddenException
416
+ with pytest.raises(ForbiddenException):
417
+ await self.client.probe_credentials()
418
+
419
+ # Check that the request was made correctly
420
+ mock_request.assert_called_once()
421
+
422
+ @patch("aiohttp.ClientSession.request")
423
+ async def test_probe_credentials_unknown_response(self, mock_request: Any) -> None:
424
+ """Test probe_credentials with unexpected response (500 response)."""
425
+ mock_request.return_value.__aenter__.return_value = build_error_mock_response(
426
+ 500, "Internal server error"
427
+ )
428
+
429
+ # Call the method
430
+ result = await self.client.probe_credentials()
431
+
432
+ # Check the result
433
+ assert result == AuthenticationProbeResult.UNKNOWN
434
+
435
+ # Check that the request was made correctly
436
+ mock_request.assert_called_once()
437
+
438
+ @patch("aiohttp.ClientSession.request")
439
+ async def test_probe_credentials_network_error(self, mock_request: Any) -> None:
440
+ """Test probe_credentials with network error."""
441
+ # Set up the mock to raise a network error
442
+ mock_request.side_effect = aiohttp.ClientError("Network error")
443
+
444
+ # Call the method
445
+ result = await self.client.probe_credentials()
446
+
447
+ # Check the result
448
+ assert result == AuthenticationProbeResult.UNKNOWN
449
+
450
+ # Check that the request was attempted
451
+ mock_request.assert_called_once()
452
+
453
+
454
+ def build_error_mock_response(status: int, message: str) -> AsyncMock:
455
+ """Build a mock response for an error case."""
456
+
457
+ def raise_for_status() -> None:
458
+ raise ClientResponseError(
459
+ status=status,
460
+ history=(),
461
+ message=message,
462
+ headers=None,
463
+ request_info=MagicMock(),
464
+ )
465
+
466
+ mock_response = AsyncMock()
467
+ mock_response.status = status
468
+ mock_response.content = message
469
+ mock_response.raise_for_status = raise_for_status
470
+
471
+ return mock_response
472
+
362
473
 
363
474
  if __name__ == "__main__":
364
475
  unittest.main()
File without changes
File without changes
File without changes
File without changes