leneda-client 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
leneda/client.py CHANGED
@@ -8,13 +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
- import requests
13
+ import aiohttp
14
+ from aiohttp import ClientResponseError, ClientTimeout
14
15
 
15
- from .exceptions import ForbiddenException, UnauthorizedException
16
+ from .exceptions import ForbiddenException, MeteringPointNotFoundException, UnauthorizedException
16
17
  from .models import (
17
18
  AggregatedMeteringData,
19
+ AuthenticationProbeResult,
18
20
  MeteringData,
19
21
  )
20
22
  from .obis_codes import ObisCode
@@ -27,8 +29,15 @@ class LenedaClient:
27
29
  """Client for the Leneda API."""
28
30
 
29
31
  BASE_URL = "https://api.leneda.lu/api"
32
+ DEFAULT_TIMEOUT = ClientTimeout(total=30) # 30 seconds total timeout
30
33
 
31
- def __init__(self, api_key: str, energy_id: str, debug: bool = False):
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ energy_id: str,
38
+ debug: bool = False,
39
+ timeout: Optional[ClientTimeout] = None,
40
+ ):
32
41
  """
33
42
  Initialize the Leneda API client.
34
43
 
@@ -36,9 +45,11 @@ class LenedaClient:
36
45
  api_key: Your Leneda API key
37
46
  energy_id: Your Energy ID
38
47
  debug: Enable debug logging
48
+ timeout: Optional timeout settings for requests
39
49
  """
40
50
  self.api_key = api_key
41
51
  self.energy_id = energy_id
52
+ self.timeout = timeout or self.DEFAULT_TIMEOUT
42
53
 
43
54
  # Set up headers for API requests
44
55
  self.headers = {
@@ -53,7 +64,7 @@ class LenedaClient:
53
64
  logger.setLevel(logging.DEBUG)
54
65
  logger.debug("Debug logging enabled for Leneda client")
55
66
 
56
- def _make_request(
67
+ async def _make_request(
57
68
  self,
58
69
  method: str,
59
70
  endpoint: str,
@@ -75,7 +86,7 @@ class LenedaClient:
75
86
  Raises:
76
87
  UnauthorizedException: If the API returns a 401 status code
77
88
  ForbiddenException: If the API returns a 403 status code
78
- requests.exceptions.RequestException: For other request errors
89
+ aiohttp.ClientError: For other request errors
79
90
  json.JSONDecodeError: If the response cannot be parsed as JSON
80
91
  """
81
92
  url = f"{self.BASE_URL}/{endpoint}"
@@ -88,52 +99,75 @@ class LenedaClient:
88
99
  logger.debug(f"Request data: {json.dumps(json_data, indent=2)}")
89
100
 
90
101
  try:
91
- # Make the request
92
- response = requests.request(
93
- method=method, url=url, headers=self.headers, params=params, json=json_data
94
- )
95
-
96
- # Check for HTTP errors
97
- if response.status_code == 401:
98
- raise UnauthorizedException(
99
- "API authentication failed. Please check your API key and energy ID."
100
- )
101
- if response.status_code == 403:
102
- raise ForbiddenException(
103
- "Access forbidden. This may be due to Leneda's geoblocking or other access restrictions."
104
- )
105
- response.raise_for_status()
106
-
107
- # Parse the response
108
- if response.content:
109
- response_data = response.json()
110
- logger.debug(f"Response status: {response.status_code}")
111
- logger.debug(f"Response data: {json.dumps(response_data, indent=2)}")
112
- return response_data
113
- else:
114
- logger.debug(f"Response status: {response.status_code} (no content)")
115
- return {}
116
-
117
- except requests.exceptions.HTTPError as e:
102
+ async with aiohttp.ClientSession(timeout=self.timeout) as session:
103
+ async with session.request(
104
+ method=method, url=url, headers=self.headers, params=params, json=json_data
105
+ ) as response:
106
+ # Check for HTTP errors
107
+ if response.status == 401:
108
+ raise UnauthorizedException(
109
+ "API authentication failed. Please check your API key and energy ID."
110
+ )
111
+ if response.status == 403:
112
+ raise ForbiddenException(
113
+ "Access forbidden. This may be due to Leneda's geoblocking or other access restrictions."
114
+ )
115
+ response.raise_for_status()
116
+
117
+ # Parse the response
118
+ if response.content:
119
+ response_data: dict = await response.json()
120
+ logger.debug(f"Response status: {response.status}")
121
+ logger.debug(f"Response data: {json.dumps(response_data, indent=2)}")
122
+ return response_data
123
+ else:
124
+ logger.debug(f"Response status: {response.status} (no content)")
125
+ return {}
126
+
127
+ except aiohttp.ClientError as e:
118
128
  # Handle HTTP errors
119
129
  logger.error(f"HTTP error: {e}")
120
- if hasattr(e, "response") and e.response is not None:
121
- logger.error(f"Response status: {e.response.status_code}")
122
- logger.error(f"Response body: {e.response.text}")
123
- raise
124
-
125
- except requests.exceptions.RequestException as e:
126
- # Handle other request errors
127
- logger.error(f"Request error: {e}")
128
130
  raise
129
131
 
130
132
  except json.JSONDecodeError as e:
131
133
  # Handle JSON parsing errors
132
134
  logger.error(f"JSON decode error: {e}")
133
- logger.error(f"Response text: {response.text}")
134
135
  raise
135
136
 
136
- def get_metering_data(
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
+
170
+ async def get_metering_data(
137
171
  self,
138
172
  metering_point_code: str,
139
173
  obis_code: ObisCode,
@@ -167,12 +201,14 @@ class LenedaClient:
167
201
  }
168
202
 
169
203
  # Make the request
170
- response_data = 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)
174
210
 
175
- def get_aggregated_metering_data(
211
+ async def get_aggregated_metering_data(
176
212
  self,
177
213
  metering_point_code: str,
178
214
  obis_code: ObisCode,
@@ -212,12 +248,14 @@ class LenedaClient:
212
248
  }
213
249
 
214
250
  # Make the request
215
- response_data = 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)
219
257
 
220
- def request_metering_data_access(
258
+ async def request_metering_data_access(
221
259
  self,
222
260
  from_energy_id: str,
223
261
  from_name: str,
@@ -246,11 +284,13 @@ class LenedaClient:
246
284
  }
247
285
 
248
286
  # Make the request
249
- response_data = self._make_request(method="POST", endpoint=endpoint, json_data=data)
287
+ response_data = await self._make_request(method="POST", endpoint=endpoint, json_data=data)
250
288
 
251
289
  return response_data
252
290
 
253
- def probe_metering_point_obis_code(self, metering_point_code: str, obis_code: ObisCode) -> bool:
291
+ async def probe_metering_point_obis_code(
292
+ self, metering_point_code: str, obis_code: ObisCode
293
+ ) -> bool:
254
294
  """
255
295
  Probe if a metering point provides data for a specific OBIS code.
256
296
 
@@ -272,14 +312,14 @@ class LenedaClient:
272
312
  Raises:
273
313
  UnauthorizedException: If the API returns a 401 status code
274
314
  ForbiddenException: If the API returns a 403 status code
275
- requests.exceptions.RequestException: For other request errors
315
+ aiohttp.ClientError: For other request errors
276
316
  """
277
317
  # Use arbitrary time window
278
318
  end_date = datetime.now()
279
319
  start_date = end_date - timedelta(weeks=4)
280
320
 
281
321
  # Try to get aggregated data for the specified OBIS code
282
- result = self.get_aggregated_metering_data(
322
+ result = await self.get_aggregated_metering_data(
283
323
  metering_point_code=metering_point_code,
284
324
  obis_code=obis_code,
285
325
  start_date=start_date,
@@ -291,7 +331,7 @@ class LenedaClient:
291
331
  # Return True if we got data (unit is not None), False otherwise
292
332
  return result.unit is not None
293
333
 
294
- def get_supported_obis_codes(self, metering_point_code: str) -> List[ObisCode]:
334
+ async def get_supported_obis_codes(self, metering_point_code: str) -> List[ObisCode]:
295
335
  """
296
336
  Get all OBIS codes that are supported by a given metering point.
297
337
 
@@ -311,10 +351,48 @@ class LenedaClient:
311
351
  Raises:
312
352
  UnauthorizedException: If the API returns a 401 status code
313
353
  ForbiddenException: If the API returns a 403 status code
314
- requests.exceptions.RequestException: For other request errors
354
+ aiohttp.ClientError: For other request errors
315
355
  """
316
- return [
317
- obis_code
318
- for obis_code in ObisCode
319
- if self.probe_metering_point_obis_code(metering_point_code, obis_code)
320
- ]
356
+ supported_codes = []
357
+ for obis_code in ObisCode:
358
+ if await self.probe_metering_point_obis_code(metering_point_code, obis_code):
359
+ supported_codes.append(obis_code)
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
leneda/exceptions.py CHANGED
@@ -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
leneda/models.py CHANGED
@@ -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"
leneda/py.typed ADDED
File without changes
leneda/version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.6.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leneda-client
3
- Version: 0.4.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
@@ -22,8 +22,9 @@ Classifier: Topic :: Utilities
22
22
  Requires-Python: >=3.8
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: requests>=2.25.0
25
+ Requires-Dist: aiohttp>=3.9.0
26
26
  Requires-Dist: python-dateutil>=2.8.2
27
+ Requires-Dist: pytest-asyncio>=0.23.0
27
28
  Dynamic: author
28
29
  Dynamic: author-email
29
30
  Dynamic: classifier
@@ -46,6 +47,9 @@ Dynamic: summary
46
47
 
47
48
  A Python client for interacting with the Leneda energy data platform API.
48
49
 
50
+ PLEASE NOTE: As long as the library is in a version below 1.0.0, breaking changes
51
+ may also be introduced between minor version bumps.
52
+
49
53
  ## Overview
50
54
 
51
55
  This client provides a simple interface to the Leneda API, which allows users to:
@@ -0,0 +1,12 @@
1
+ leneda/__init__.py,sha256=-BHIXBfTTbX6EKDHr2Bq7rPzsPNYrrkgs9AJqWJR_1Q,732
2
+ leneda/client.py,sha256=v41E1jFqe5MLMAoSOCtSYm8ZYWJwXCo-rBmKwMpDjB4,15875
3
+ leneda/exceptions.py,sha256=eyQpEYdLAU_BkXbZeldS6XgXeQJEIKp4dBeyzwlKOfA,580
4
+ leneda/models.py,sha256=Dwn99_h9D7bKnBZY_3WJ5bwA6XyBliEYK6A9kBhKtmk,8111
5
+ leneda/obis_codes.py,sha256=VfsJQN1U80eZ5g1bIteDCLkkmBQ0AIkkm_zNAeM1Dog,7507
6
+ leneda/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ leneda/version.py,sha256=5C-KzeFwke_CL13dukzT7o-_j-Sv7xz_3L5EqVn3hI0,50
8
+ leneda_client-0.6.0.dist-info/licenses/LICENSE,sha256=nAhDs625lK6v8oLqjWCABKBKlwxVoRDFXQvoZPfOtKQ,1062
9
+ leneda_client-0.6.0.dist-info/METADATA,sha256=kVCSco-qeUMd6V0mr6RF02C4Ivb87VAW8hFNsSKfSyQ,3515
10
+ leneda_client-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ leneda_client-0.6.0.dist-info/top_level.txt,sha256=PANScm25ep7WLjKiph0fhJPb8s_sa_uLHemnBpQBaJ8,7
12
+ leneda_client-0.6.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.3.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,11 +0,0 @@
1
- leneda/__init__.py,sha256=-BHIXBfTTbX6EKDHr2Bq7rPzsPNYrrkgs9AJqWJR_1Q,732
2
- leneda/client.py,sha256=RcANz6UweQ13fJHKwWP1V5dWnqSwcG4Ot9ws1grhZqQ,12250
3
- leneda/exceptions.py,sha256=q00gjI5VwXAMF2I1gXfQidZMzbCF6UOSo4i1Wnb-inU,460
4
- leneda/models.py,sha256=jdU2cIZZDExUSiSfz9zaYjJepr0m3v_x5b1fyOaEI8Q,7930
5
- leneda/obis_codes.py,sha256=VfsJQN1U80eZ5g1bIteDCLkkmBQ0AIkkm_zNAeM1Dog,7507
6
- leneda/version.py,sha256=e6aGg4sxTYiyshAbW98gDoekO-J6qYKpZNzhVZhaYUk,50
7
- leneda_client-0.4.0.dist-info/licenses/LICENSE,sha256=nAhDs625lK6v8oLqjWCABKBKlwxVoRDFXQvoZPfOtKQ,1062
8
- leneda_client-0.4.0.dist-info/METADATA,sha256=TGPWdTziOm6yt9HLE8GKtbDxUaU-N9GHcNH9vsAbdzw,3344
9
- leneda_client-0.4.0.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
10
- leneda_client-0.4.0.dist-info/top_level.txt,sha256=PANScm25ep7WLjKiph0fhJPb8s_sa_uLHemnBpQBaJ8,7
11
- leneda_client-0.4.0.dist-info/RECORD,,