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 +136 -58
- leneda/exceptions.py +6 -0
- leneda/models.py +9 -0
- leneda/py.typed +0 -0
- leneda/version.py +1 -1
- {leneda_client-0.4.0.dist-info → leneda_client-0.6.0.dist-info}/METADATA +6 -2
- leneda_client-0.6.0.dist-info/RECORD +12 -0
- {leneda_client-0.4.0.dist-info → leneda_client-0.6.0.dist-info}/WHEEL +1 -1
- leneda_client-0.4.0.dist-info/RECORD +0 -11
- {leneda_client-0.4.0.dist-info → leneda_client-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {leneda_client-0.4.0.dist-info → leneda_client-0.6.0.dist-info}/top_level.txt +0 -0
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
|
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__(
|
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
|
-
|
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
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
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
|
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.
|
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.
|
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(
|
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
|
-
|
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
|
-
|
354
|
+
aiohttp.ClientError: For other request errors
|
315
355
|
"""
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
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,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: leneda-client
|
3
|
-
Version: 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:
|
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,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,,
|
File without changes
|
File without changes
|