fluvius-energy-api 0.1.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.
- fluvius_energy_api/__init__.py +140 -0
- fluvius_energy_api/_http.py +128 -0
- fluvius_energy_api/auth.py +239 -0
- fluvius_energy_api/client.py +334 -0
- fluvius_energy_api/credentials.py +215 -0
- fluvius_energy_api/exceptions.py +104 -0
- fluvius_energy_api/models/__init__.py +123 -0
- fluvius_energy_api/models/base.py +36 -0
- fluvius_energy_api/models/energy.py +201 -0
- fluvius_energy_api/models/enums.py +137 -0
- fluvius_energy_api/models/installation.py +68 -0
- fluvius_energy_api/models/mandate.py +73 -0
- fluvius_energy_api/models/session.py +46 -0
- fluvius_energy_api/py.typed +0 -0
- fluvius_energy_api-0.1.0.dist-info/METADATA +311 -0
- fluvius_energy_api-0.1.0.dist-info/RECORD +18 -0
- fluvius_energy_api-0.1.0.dist-info/WHEEL +4 -0
- fluvius_energy_api-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Main API client for the Fluvius Energy API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Self
|
|
9
|
+
|
|
10
|
+
from ._http import HTTPClient
|
|
11
|
+
from .auth import TokenProvider
|
|
12
|
+
from .credentials import FluviusCredentials
|
|
13
|
+
from .models.energy import GetEnergyResponseApiDataResponse
|
|
14
|
+
from .models.enums import (
|
|
15
|
+
DataServiceType,
|
|
16
|
+
EnergyType,
|
|
17
|
+
MandateRenewalStatus,
|
|
18
|
+
MandateStatus,
|
|
19
|
+
PeriodType,
|
|
20
|
+
)
|
|
21
|
+
from .models.mandate import (
|
|
22
|
+
CreateMandateRequest,
|
|
23
|
+
CreateMandateResponseApiDataResponse,
|
|
24
|
+
GetMandatesResponseApiDataResponse,
|
|
25
|
+
)
|
|
26
|
+
from .models.session import (
|
|
27
|
+
ClientSessionDataService,
|
|
28
|
+
CreateClientSessionRequest,
|
|
29
|
+
CreateClientSessionResponseApiDataResponse,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Environment(str, Enum):
|
|
34
|
+
"""Environment configuration for the Fluvius API."""
|
|
35
|
+
|
|
36
|
+
PRODUCTION = "https://apihub.fluvius.be/esco-live/v3"
|
|
37
|
+
SANDBOX = "https://apihub.fluvius.be/esco-sbx/v3"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FluviusEnergyClient:
|
|
41
|
+
"""Client for interacting with the Fluvius Energy API.
|
|
42
|
+
|
|
43
|
+
Authentication is handled automatically using OAuth2 with Azure AD.
|
|
44
|
+
Credentials can be loaded from environment variables or passed explicitly.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
credentials: OAuth2 credentials for authentication. If not provided,
|
|
48
|
+
credentials are loaded from environment variables.
|
|
49
|
+
environment: The API environment to use. Defaults to SANDBOX.
|
|
50
|
+
timeout: Request timeout in seconds. Defaults to 30.0.
|
|
51
|
+
|
|
52
|
+
Environment Variables:
|
|
53
|
+
FLUVIUS_SUBSCRIPTION_KEY: Azure API Management subscription key.
|
|
54
|
+
FLUVIUS_CLIENT_ID: Azure AD application (client) ID.
|
|
55
|
+
FLUVIUS_TENANT_ID: Azure AD tenant ID.
|
|
56
|
+
FLUVIUS_SCOPE: OAuth2 scope for the Fluvius API.
|
|
57
|
+
FLUVIUS_CERTIFICATE_THUMBPRINT: Hexadecimal certificate thumbprint.
|
|
58
|
+
FLUVIUS_PRIVATE_KEY: RSA private key (PEM format or base64-encoded).
|
|
59
|
+
FLUVIUS_PRIVATE_KEY_PATH: Path to private key file (alternative).
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
```python
|
|
63
|
+
from fluvius_energy_api import FluviusEnergyClient
|
|
64
|
+
|
|
65
|
+
# Using environment variables (recommended)
|
|
66
|
+
with FluviusEnergyClient() as client:
|
|
67
|
+
mandates = client.get_mandates()
|
|
68
|
+
|
|
69
|
+
# Explicit credentials
|
|
70
|
+
from fluvius_energy_api import FluviusCredentials, Environment
|
|
71
|
+
|
|
72
|
+
credentials = FluviusCredentials(
|
|
73
|
+
subscription_key="...",
|
|
74
|
+
client_id="...",
|
|
75
|
+
tenant_id="...",
|
|
76
|
+
scope="...",
|
|
77
|
+
certificate_thumbprint="...",
|
|
78
|
+
private_key="-----BEGIN RSA PRIVATE KEY-----...",
|
|
79
|
+
)
|
|
80
|
+
with FluviusEnergyClient(credentials=credentials, environment=Environment.PRODUCTION) as client:
|
|
81
|
+
mandates = client.get_mandates()
|
|
82
|
+
```
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
credentials: FluviusCredentials | None = None,
|
|
88
|
+
environment: Environment = Environment.SANDBOX,
|
|
89
|
+
timeout: float = 30.0,
|
|
90
|
+
) -> None:
|
|
91
|
+
if credentials is None:
|
|
92
|
+
credentials = FluviusCredentials.from_env()
|
|
93
|
+
|
|
94
|
+
self._token_provider = TokenProvider(credentials)
|
|
95
|
+
self._http = HTTPClient(
|
|
96
|
+
base_url=environment.value,
|
|
97
|
+
token_provider=self._token_provider,
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
"""Close the client and release resources."""
|
|
103
|
+
self._http.close()
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> Self:
|
|
106
|
+
"""Enter the context manager."""
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(
|
|
110
|
+
self,
|
|
111
|
+
exc_type: type[BaseException] | None,
|
|
112
|
+
exc_val: BaseException | None,
|
|
113
|
+
exc_tb: TracebackType | None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Exit the context manager."""
|
|
116
|
+
self.close()
|
|
117
|
+
|
|
118
|
+
def create_client_session(
|
|
119
|
+
self,
|
|
120
|
+
data_access_contract_number: str,
|
|
121
|
+
reference_number: str,
|
|
122
|
+
flow: str,
|
|
123
|
+
data_services: list[ClientSessionDataService] | None = None,
|
|
124
|
+
number_of_eans: int | None = None,
|
|
125
|
+
return_url_success: str | None = None,
|
|
126
|
+
return_url_failed: str | None = None,
|
|
127
|
+
sso: bool | None = None,
|
|
128
|
+
enterprise_number: str | None = None,
|
|
129
|
+
) -> CreateClientSessionResponseApiDataResponse:
|
|
130
|
+
"""Create a new client session.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
data_access_contract_number: The data access contract number.
|
|
134
|
+
reference_number: The reference number.
|
|
135
|
+
flow: The flow type (B2C or B2B).
|
|
136
|
+
data_services: Optional list of data services.
|
|
137
|
+
number_of_eans: Optional number of EANs.
|
|
138
|
+
return_url_success: URL to redirect to on success.
|
|
139
|
+
return_url_failed: URL to redirect to on failure.
|
|
140
|
+
sso: Whether to use Single Sign On.
|
|
141
|
+
enterprise_number: The enterprise number.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
CreateClientSessionResponseApiDataResponse: The API response.
|
|
145
|
+
"""
|
|
146
|
+
request = CreateClientSessionRequest(
|
|
147
|
+
data_access_contract_number=data_access_contract_number,
|
|
148
|
+
reference_number=reference_number,
|
|
149
|
+
flow=flow,
|
|
150
|
+
data_services=data_services,
|
|
151
|
+
number_of_eans=number_of_eans,
|
|
152
|
+
return_url_success=return_url_success,
|
|
153
|
+
return_url_failed=return_url_failed,
|
|
154
|
+
sso=sso,
|
|
155
|
+
enterprise_number=enterprise_number,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
response_data = self._http.post(
|
|
159
|
+
"/api/shortUrlIdentifier",
|
|
160
|
+
json_data=request.model_dump(by_alias=True, exclude_none=True),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return CreateClientSessionResponseApiDataResponse.model_validate(response_data)
|
|
164
|
+
|
|
165
|
+
def get_mandates(
|
|
166
|
+
self,
|
|
167
|
+
reference_number: str | None = None,
|
|
168
|
+
ean: str | None = None,
|
|
169
|
+
data_service_types: list[DataServiceType] | None = None,
|
|
170
|
+
energy_type: EnergyType | None = None,
|
|
171
|
+
status: MandateStatus | None = None,
|
|
172
|
+
mandate_expiration_date: datetime | None = None,
|
|
173
|
+
renewal_status: MandateRenewalStatus | None = None,
|
|
174
|
+
last_updated_from: datetime | None = None,
|
|
175
|
+
last_updated_to: datetime | None = None,
|
|
176
|
+
) -> GetMandatesResponseApiDataResponse:
|
|
177
|
+
"""Get mandates that match the specified filters.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
reference_number: Custom reference number.
|
|
181
|
+
ean: GSRN EAN-code that identifies the installation.
|
|
182
|
+
data_service_types: Types of the data service.
|
|
183
|
+
energy_type: The discipline (E for electricity, G for gas).
|
|
184
|
+
status: Status of the mandate.
|
|
185
|
+
mandate_expiration_date: Date and time of the mandate expiration.
|
|
186
|
+
renewal_status: Renewal status of the mandate.
|
|
187
|
+
last_updated_from: Start date and time of the last updated filter.
|
|
188
|
+
last_updated_to: End date and time of the last updated filter.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
GetMandatesResponseApiDataResponse: The API response containing mandates.
|
|
192
|
+
"""
|
|
193
|
+
params: dict[str, str] = {}
|
|
194
|
+
|
|
195
|
+
if reference_number is not None:
|
|
196
|
+
params["referenceNumber"] = reference_number
|
|
197
|
+
if ean is not None:
|
|
198
|
+
params["ean"] = ean
|
|
199
|
+
if data_service_types is not None:
|
|
200
|
+
params["dataServiceTypes"] = ",".join(
|
|
201
|
+
dst.value if isinstance(dst, DataServiceType) else dst
|
|
202
|
+
for dst in data_service_types
|
|
203
|
+
)
|
|
204
|
+
if energy_type is not None:
|
|
205
|
+
params["energyType"] = (
|
|
206
|
+
energy_type.value
|
|
207
|
+
if isinstance(energy_type, EnergyType)
|
|
208
|
+
else energy_type
|
|
209
|
+
)
|
|
210
|
+
if status is not None:
|
|
211
|
+
params["status"] = (
|
|
212
|
+
status.value if isinstance(status, MandateStatus) else status
|
|
213
|
+
)
|
|
214
|
+
if mandate_expiration_date is not None:
|
|
215
|
+
params["mandateExpirationDate"] = mandate_expiration_date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
216
|
+
if renewal_status is not None:
|
|
217
|
+
params["renewalStatus"] = (
|
|
218
|
+
renewal_status.value
|
|
219
|
+
if isinstance(renewal_status, MandateRenewalStatus)
|
|
220
|
+
else renewal_status
|
|
221
|
+
)
|
|
222
|
+
if last_updated_from is not None:
|
|
223
|
+
params["lastUpdatedFrom"] = last_updated_from.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
224
|
+
if last_updated_to is not None:
|
|
225
|
+
params["lastUpdatedTo"] = last_updated_to.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
226
|
+
|
|
227
|
+
response_data = self._http.get("/api/mandate", params=params or None)
|
|
228
|
+
|
|
229
|
+
return GetMandatesResponseApiDataResponse.model_validate(response_data)
|
|
230
|
+
|
|
231
|
+
def get_energy(
|
|
232
|
+
self,
|
|
233
|
+
ean: str,
|
|
234
|
+
period_type: PeriodType,
|
|
235
|
+
reference_number: str | None = None,
|
|
236
|
+
granularity: str | None = None,
|
|
237
|
+
complex_energy_types: str | None = None,
|
|
238
|
+
from_date: datetime | None = None,
|
|
239
|
+
to_date: datetime | None = None,
|
|
240
|
+
) -> GetEnergyResponseApiDataResponse:
|
|
241
|
+
"""Get energy measurements that match the specified filters.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
ean: GSRN EAN-code that identifies the installation (required).
|
|
245
|
+
period_type: Type of period for the query (required).
|
|
246
|
+
reference_number: Custom reference number.
|
|
247
|
+
granularity: Granularity for energy measurements (e.g., "hourly_quarterhourly,daily").
|
|
248
|
+
complex_energy_types: Types of complex energy (e.g., "active,reactive").
|
|
249
|
+
from_date: Start date and time of the query.
|
|
250
|
+
to_date: End date and time of the query.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
GetEnergyResponseApiDataResponse: The API response containing energy data.
|
|
254
|
+
"""
|
|
255
|
+
params: dict[str, str] = {
|
|
256
|
+
"ean": ean,
|
|
257
|
+
"periodType": (
|
|
258
|
+
period_type.value
|
|
259
|
+
if isinstance(period_type, PeriodType)
|
|
260
|
+
else period_type
|
|
261
|
+
),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if reference_number is not None:
|
|
265
|
+
params["referenceNumber"] = reference_number
|
|
266
|
+
if granularity is not None:
|
|
267
|
+
params["granularity"] = granularity
|
|
268
|
+
if complex_energy_types is not None:
|
|
269
|
+
params["complexEnergyTypes"] = complex_energy_types
|
|
270
|
+
if from_date is not None:
|
|
271
|
+
params["from"] = from_date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
272
|
+
if to_date is not None:
|
|
273
|
+
params["to"] = to_date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
274
|
+
|
|
275
|
+
response_data = self._http.get("/api/mandate/energy", params=params)
|
|
276
|
+
|
|
277
|
+
return GetEnergyResponseApiDataResponse.model_validate(response_data)
|
|
278
|
+
|
|
279
|
+
def create_mock_mandate(
|
|
280
|
+
self,
|
|
281
|
+
reference_number: str,
|
|
282
|
+
ean: str,
|
|
283
|
+
data_service_type: DataServiceType,
|
|
284
|
+
data_period_from: str | None = None,
|
|
285
|
+
data_period_to: str | None = None,
|
|
286
|
+
status: MandateStatus | None = None,
|
|
287
|
+
mandate_expiration_date: str | None = None,
|
|
288
|
+
renewal_status: MandateRenewalStatus | None = None,
|
|
289
|
+
) -> CreateMandateResponseApiDataResponse:
|
|
290
|
+
"""Create a mock mandate in the sandbox environment.
|
|
291
|
+
|
|
292
|
+
This endpoint is only available in the sandbox environment.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
reference_number: Custom reference number for the mandate.
|
|
296
|
+
ean: GSRN EAN-code that identifies the headpoint.
|
|
297
|
+
data_service_type: The data service type for the mandate.
|
|
298
|
+
data_period_from: Start date/time of the data period (ISO format).
|
|
299
|
+
data_period_to: End date/time of the data period (ISO format).
|
|
300
|
+
status: Status of the mandate to create.
|
|
301
|
+
mandate_expiration_date: Expiration date for the mandate (ISO format).
|
|
302
|
+
renewal_status: Renewal status of the mandate.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
CreateMandateResponseApiDataResponse: The API response.
|
|
306
|
+
|
|
307
|
+
Example:
|
|
308
|
+
```python
|
|
309
|
+
response = client.create_mock_mandate(
|
|
310
|
+
reference_number="test-mandate-001",
|
|
311
|
+
ean="541448860000000016",
|
|
312
|
+
data_service_type=DataServiceType.VH_DAG,
|
|
313
|
+
data_period_from="2024-01-01T00:00:00Z",
|
|
314
|
+
status=MandateStatus.APPROVED,
|
|
315
|
+
)
|
|
316
|
+
```
|
|
317
|
+
"""
|
|
318
|
+
request = CreateMandateRequest(
|
|
319
|
+
reference_number=reference_number,
|
|
320
|
+
ean=ean,
|
|
321
|
+
data_service_type=data_service_type,
|
|
322
|
+
data_period_from=data_period_from,
|
|
323
|
+
data_period_to=data_period_to,
|
|
324
|
+
status=status,
|
|
325
|
+
mandate_expiration_date=mandate_expiration_date,
|
|
326
|
+
renewal_status=renewal_status,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
response_data = self._http.post(
|
|
330
|
+
"/api/mandate/mock",
|
|
331
|
+
json_data=request.model_dump(by_alias=True, exclude_none=True),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return CreateMandateResponseApiDataResponse.model_validate(response_data)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Credentials configuration for Fluvius API authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class FluviusCredentials:
|
|
15
|
+
"""OAuth2 credentials for Fluvius API authentication.
|
|
16
|
+
|
|
17
|
+
Supports two authentication methods:
|
|
18
|
+
1. Certificate-based (production): Uses certificate_thumbprint + private_key
|
|
19
|
+
2. Client secret-based (sandbox): Uses client_secret
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
subscription_key: Azure API Management subscription key.
|
|
23
|
+
client_id: Azure AD application (client) ID.
|
|
24
|
+
tenant_id: Azure AD tenant ID.
|
|
25
|
+
scope: OAuth2 scope for the Fluvius API.
|
|
26
|
+
certificate_thumbprint: Hexadecimal thumbprint of the certificate (for certificate auth).
|
|
27
|
+
private_key: RSA private key in PEM format (for certificate auth).
|
|
28
|
+
client_secret: Client secret (for secret-based auth, typically sandbox).
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
```python
|
|
32
|
+
# Production with certificate
|
|
33
|
+
prod_creds = FluviusCredentials(
|
|
34
|
+
subscription_key="...",
|
|
35
|
+
client_id="...",
|
|
36
|
+
tenant_id="...",
|
|
37
|
+
scope="...",
|
|
38
|
+
certificate_thumbprint="...",
|
|
39
|
+
private_key="-----BEGIN RSA PRIVATE KEY-----...",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Sandbox with client secret
|
|
43
|
+
sandbox_creds = FluviusCredentials(
|
|
44
|
+
subscription_key="...",
|
|
45
|
+
client_id="...",
|
|
46
|
+
tenant_id="...",
|
|
47
|
+
scope="...",
|
|
48
|
+
client_secret="...",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
subscription_key: str
|
|
54
|
+
client_id: str
|
|
55
|
+
tenant_id: str
|
|
56
|
+
scope: str
|
|
57
|
+
data_access_contract_number: str
|
|
58
|
+
# Certificate-based auth (production)
|
|
59
|
+
certificate_thumbprint: str | None = None
|
|
60
|
+
private_key: str | None = None
|
|
61
|
+
# Client secret auth (sandbox)
|
|
62
|
+
client_secret: str | None = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
"""Validate that all required fields are non-empty."""
|
|
66
|
+
# Always required
|
|
67
|
+
for field_name in ["subscription_key", "client_id", "tenant_id", "scope", "data_access_contract_number"]:
|
|
68
|
+
value = getattr(self, field_name)
|
|
69
|
+
if not value or not value.strip():
|
|
70
|
+
raise ConfigurationError(
|
|
71
|
+
f"Missing required credential: {field_name}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Must have either certificate-based or secret-based auth
|
|
75
|
+
has_certificate = (
|
|
76
|
+
self.certificate_thumbprint
|
|
77
|
+
and self.certificate_thumbprint.strip()
|
|
78
|
+
and self.private_key
|
|
79
|
+
and self.private_key.strip()
|
|
80
|
+
)
|
|
81
|
+
has_secret = self.client_secret and self.client_secret.strip()
|
|
82
|
+
|
|
83
|
+
if not has_certificate and not has_secret:
|
|
84
|
+
raise ConfigurationError(
|
|
85
|
+
"Missing authentication credentials: provide either "
|
|
86
|
+
"(certificate_thumbprint + private_key) or client_secret"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def uses_certificate(self) -> bool:
|
|
91
|
+
"""Return True if using certificate-based authentication."""
|
|
92
|
+
return bool(
|
|
93
|
+
self.certificate_thumbprint
|
|
94
|
+
and self.certificate_thumbprint.strip()
|
|
95
|
+
and self.private_key
|
|
96
|
+
and self.private_key.strip()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_env(cls, prefix: str = "FLUVIUS") -> FluviusCredentials:
|
|
101
|
+
"""Load credentials from environment variables.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
prefix: Environment variable prefix. Defaults to "FLUVIUS".
|
|
105
|
+
Use "FLUVIUS_SANDBOX" for sandbox-specific credentials.
|
|
106
|
+
Common values (subscription_key, client_id, tenant_id, scope)
|
|
107
|
+
will fall back to FLUVIUS_* if not found with the prefix.
|
|
108
|
+
|
|
109
|
+
Environment Variables:
|
|
110
|
+
{PREFIX}_SUBSCRIPTION_KEY: Azure API Management subscription key.
|
|
111
|
+
{PREFIX}_CLIENT_ID: Azure AD application (client) ID.
|
|
112
|
+
{PREFIX}_TENANT_ID: Azure AD tenant ID.
|
|
113
|
+
{PREFIX}_SCOPE: OAuth2 scope for the Fluvius API.
|
|
114
|
+
|
|
115
|
+
For certificate auth (production):
|
|
116
|
+
{PREFIX}_CERTIFICATE_THUMBPRINT: Hexadecimal certificate thumbprint.
|
|
117
|
+
{PREFIX}_PRIVATE_KEY: RSA private key (PEM format or base64-encoded).
|
|
118
|
+
{PREFIX}_PRIVATE_KEY_PATH: Path to private key file (alternative).
|
|
119
|
+
|
|
120
|
+
For client secret auth (sandbox):
|
|
121
|
+
{PREFIX}_CLIENT_SECRET: Client secret.
|
|
122
|
+
|
|
123
|
+
Always required:
|
|
124
|
+
{PREFIX}_DATA_ACCESS_CONTRACT_NUMBER: Data access contract number.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
```python
|
|
128
|
+
# Production (certificate auth)
|
|
129
|
+
prod_creds = FluviusCredentials.from_env()
|
|
130
|
+
|
|
131
|
+
# Sandbox (client secret auth, falls back to FLUVIUS_* for common values)
|
|
132
|
+
sandbox_creds = FluviusCredentials.from_env(prefix="FLUVIUS_SANDBOX")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
FluviusCredentials: The loaded credentials.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ConfigurationError: If required environment variables are missing.
|
|
140
|
+
"""
|
|
141
|
+
private_key = cls._load_private_key(prefix)
|
|
142
|
+
|
|
143
|
+
# Helper to get env var with fallback to default prefix
|
|
144
|
+
def get_env(name: str, fallback_prefix: str = "FLUVIUS") -> str:
|
|
145
|
+
value = os.environ.get(f"{prefix}_{name}", "")
|
|
146
|
+
if not value and prefix != fallback_prefix:
|
|
147
|
+
value = os.environ.get(f"{fallback_prefix}_{name}", "")
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
return cls(
|
|
151
|
+
subscription_key=get_env("SUBSCRIPTION_KEY"),
|
|
152
|
+
client_id=get_env("CLIENT_ID"),
|
|
153
|
+
tenant_id=get_env("TENANT_ID"),
|
|
154
|
+
scope=get_env("SCOPE"),
|
|
155
|
+
certificate_thumbprint=get_env("CERTIFICATE_THUMBPRINT") or None,
|
|
156
|
+
private_key=private_key or None,
|
|
157
|
+
client_secret=get_env("CLIENT_SECRET") or None,
|
|
158
|
+
data_access_contract_number=get_env("DATA_ACCESS_CONTRACT_NUMBER"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _load_private_key(prefix: str = "FLUVIUS") -> str:
|
|
163
|
+
"""Load the private key from environment variable or file.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
prefix: Environment variable prefix.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The private key in PEM format, or empty string if not found.
|
|
170
|
+
"""
|
|
171
|
+
private_key = os.environ.get(f"{prefix}_PRIVATE_KEY")
|
|
172
|
+
private_key_path = os.environ.get(f"{prefix}_PRIVATE_KEY_PATH")
|
|
173
|
+
|
|
174
|
+
# Fall back to default prefix if using custom prefix
|
|
175
|
+
if not private_key and not private_key_path and prefix != "FLUVIUS":
|
|
176
|
+
private_key = os.environ.get("FLUVIUS_PRIVATE_KEY")
|
|
177
|
+
private_key_path = os.environ.get("FLUVIUS_PRIVATE_KEY_PATH")
|
|
178
|
+
|
|
179
|
+
if private_key:
|
|
180
|
+
return _decode_private_key(private_key)
|
|
181
|
+
|
|
182
|
+
if private_key_path:
|
|
183
|
+
path = Path(private_key_path)
|
|
184
|
+
if not path.exists():
|
|
185
|
+
raise ConfigurationError(
|
|
186
|
+
f"Private key file not found: {private_key_path}"
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
return path.read_text()
|
|
190
|
+
except OSError as e:
|
|
191
|
+
raise ConfigurationError(
|
|
192
|
+
f"Failed to read private key file: {e}"
|
|
193
|
+
) from e
|
|
194
|
+
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _decode_private_key(key: str) -> str:
|
|
199
|
+
"""Decode a private key that may be base64-encoded.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
key: The private key, either in PEM format or base64-encoded.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The private key in PEM format.
|
|
206
|
+
"""
|
|
207
|
+
if key.startswith("-----BEGIN"):
|
|
208
|
+
return key
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
cleaned = key.replace(" ", "").replace("\n", "").replace("\r", "").replace("\t", "")
|
|
212
|
+
decoded = base64.b64decode(cleaned, validate=True).decode("utf-8")
|
|
213
|
+
return decoded
|
|
214
|
+
except (ValueError, UnicodeDecodeError):
|
|
215
|
+
return key
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Custom exceptions for the Fluvius Energy API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models.base import ErrorResponse, ErrorValidationResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FluviusAPIError(Exception):
|
|
12
|
+
"""Base exception for Fluvius API errors."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.error_response = error_response
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
if self.status_code:
|
|
27
|
+
return f"[{self.status_code}] {self.message}"
|
|
28
|
+
return self.message
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigurationError(FluviusAPIError):
|
|
32
|
+
"""Raised when credentials or configuration is missing or invalid."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(FluviusAPIError):
|
|
39
|
+
"""Raised when authentication fails (401 Unauthorized)."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
message: str = "Authentication failed. Check your bearer token.",
|
|
44
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message, status_code=401, error_response=error_response)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ForbiddenError(FluviusAPIError):
|
|
50
|
+
"""Raised when access is forbidden (403 Forbidden)."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str = "Access forbidden. You don't have permission to access this resource.",
|
|
55
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(message, status_code=403, error_response=error_response)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NotFoundError(FluviusAPIError):
|
|
61
|
+
"""Raised when a resource is not found (404 Not Found)."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
message: str = "Resource not found.",
|
|
66
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(message, status_code=404, error_response=error_response)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationError(FluviusAPIError):
|
|
72
|
+
"""Raised when request validation fails (400 Bad Request)."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
message: str = "Request validation failed.",
|
|
77
|
+
error_response: ErrorValidationResponse | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(message, status_code=400, error_response=error_response)
|
|
80
|
+
self.validation_errors = (
|
|
81
|
+
error_response.validation_error_messages if error_response else None
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ServerError(FluviusAPIError):
|
|
86
|
+
"""Raised when a server error occurs (500 Internal Server Error)."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
message: str = "Internal server error.",
|
|
91
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
super().__init__(message, status_code=500, error_response=error_response)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ServiceUnavailableError(FluviusAPIError):
|
|
97
|
+
"""Raised when the service is unavailable (503 Service Unavailable)."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
message: str = "Service unavailable. Please try again later.",
|
|
102
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
super().__init__(message, status_code=503, error_response=error_response)
|