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.
@@ -0,0 +1,140 @@
1
+ """Fluvius Energy API Python wrapper."""
2
+
3
+ from .auth import TokenProvider
4
+ from .client import Environment, FluviusEnergyClient
5
+ from .credentials import FluviusCredentials
6
+ from .exceptions import (
7
+ AuthenticationError,
8
+ ConfigurationError,
9
+ FluviusAPIError,
10
+ ForbiddenError,
11
+ NotFoundError,
12
+ ServerError,
13
+ ServiceUnavailableError,
14
+ ValidationError,
15
+ )
16
+ from .models import (
17
+ AuxiliarySubHeadpoint,
18
+ ClientSessionDataService,
19
+ CreateClientSessionRequest,
20
+ CreateClientSessionResponse,
21
+ CreateClientSessionResponseApiDataResponse,
22
+ CreateClientSessionStatus,
23
+ CreateMandateRequest,
24
+ CreateMandateResponse,
25
+ CreateMandateResponseApiDataResponse,
26
+ DataServiceType,
27
+ EnergyType,
28
+ ErrorResponse,
29
+ ErrorValidationResponse,
30
+ EstimatedVolume,
31
+ EstimatedVolumeType,
32
+ Flow,
33
+ GasConversionFactor,
34
+ GetEnergyResponse,
35
+ GetEnergyResponseApiDataResponse,
36
+ GetInstallationResponse,
37
+ GetInstallationResponseApiDataResponse,
38
+ GetMandatesResponse,
39
+ GetMandatesResponseApiDataResponse,
40
+ Headpoint,
41
+ HeadpointUnion,
42
+ Installation,
43
+ LocalProductionInstallation,
44
+ LocalProductionInstallationType,
45
+ Mandate,
46
+ MandateRenewalStatus,
47
+ MandateStatus,
48
+ MeasurementDirection,
49
+ MeasurementTimeSlice,
50
+ MeasurementValue,
51
+ MeasurementValidationState,
52
+ MeasurementValueSet,
53
+ MeterType,
54
+ MeteringOnHeadpoint,
55
+ MeteringOnHeadpointAndMeter,
56
+ MeteringOnMeter,
57
+ OfftakeSubHeadpoint,
58
+ PeriodType,
59
+ PhysicalMeter,
60
+ ProductionSubHeadpoint,
61
+ Register,
62
+ ResponseMetadata,
63
+ SubHeadpoint,
64
+ SubHeadpointUnion,
65
+ Unit,
66
+ )
67
+
68
+ __version__ = "0.1.0"
69
+
70
+ __all__ = [
71
+ # Client & Auth
72
+ "Environment",
73
+ "FluviusEnergyClient",
74
+ "FluviusCredentials",
75
+ "TokenProvider",
76
+ # Exceptions
77
+ "AuthenticationError",
78
+ "ConfigurationError",
79
+ "FluviusAPIError",
80
+ "ForbiddenError",
81
+ "NotFoundError",
82
+ "ServerError",
83
+ "ServiceUnavailableError",
84
+ "ValidationError",
85
+ # Enums
86
+ "CreateClientSessionStatus",
87
+ "DataServiceType",
88
+ "EnergyType",
89
+ "EstimatedVolumeType",
90
+ "Flow",
91
+ "GasConversionFactor",
92
+ "LocalProductionInstallationType",
93
+ "MandateRenewalStatus",
94
+ "MandateStatus",
95
+ "MeasurementValidationState",
96
+ "MeterType",
97
+ "PeriodType",
98
+ "Register",
99
+ "Unit",
100
+ # Base models
101
+ "ErrorResponse",
102
+ "ErrorValidationResponse",
103
+ "ResponseMetadata",
104
+ # Session models
105
+ "ClientSessionDataService",
106
+ "CreateClientSessionRequest",
107
+ "CreateClientSessionResponse",
108
+ "CreateClientSessionResponseApiDataResponse",
109
+ # Mandate models
110
+ "CreateMandateRequest",
111
+ "CreateMandateResponse",
112
+ "CreateMandateResponseApiDataResponse",
113
+ "GetMandatesResponse",
114
+ "GetMandatesResponseApiDataResponse",
115
+ "Mandate",
116
+ # Energy models
117
+ "AuxiliarySubHeadpoint",
118
+ "GetEnergyResponse",
119
+ "GetEnergyResponseApiDataResponse",
120
+ "Headpoint",
121
+ "HeadpointUnion",
122
+ "MeasurementDirection",
123
+ "MeasurementTimeSlice",
124
+ "MeasurementValue",
125
+ "MeasurementValueSet",
126
+ "MeteringOnHeadpoint",
127
+ "MeteringOnHeadpointAndMeter",
128
+ "MeteringOnMeter",
129
+ "OfftakeSubHeadpoint",
130
+ "PhysicalMeter",
131
+ "ProductionSubHeadpoint",
132
+ "SubHeadpoint",
133
+ "SubHeadpointUnion",
134
+ # Installation models
135
+ "EstimatedVolume",
136
+ "GetInstallationResponse",
137
+ "GetInstallationResponseApiDataResponse",
138
+ "Installation",
139
+ "LocalProductionInstallation",
140
+ ]
@@ -0,0 +1,128 @@
1
+ """Internal HTTP client wrapper for the Fluvius Energy API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import httpx
8
+
9
+ from .exceptions import (
10
+ AuthenticationError,
11
+ FluviusAPIError,
12
+ ForbiddenError,
13
+ NotFoundError,
14
+ ServerError,
15
+ ServiceUnavailableError,
16
+ ValidationError,
17
+ )
18
+ from .models.base import ErrorResponse, ErrorValidationResponse
19
+
20
+ if TYPE_CHECKING:
21
+ from .auth import TokenProvider
22
+
23
+
24
+ class HTTPClient:
25
+ """Internal HTTP client with authentication and error handling."""
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ token_provider: TokenProvider,
31
+ timeout: float = 30.0,
32
+ ) -> None:
33
+ self._base_url = base_url.rstrip("/")
34
+ self._token_provider = token_provider
35
+ self._timeout = timeout
36
+ self._client: httpx.Client | None = None
37
+
38
+ def _get_client(self) -> httpx.Client:
39
+ """Get or create the HTTP client."""
40
+ if self._client is None:
41
+ self._client = httpx.Client(
42
+ base_url=self._base_url,
43
+ timeout=self._timeout,
44
+ )
45
+ return self._client
46
+
47
+ def _get_headers(self) -> dict[str, str]:
48
+ """Get headers for API requests, including fresh auth token."""
49
+ headers = self._token_provider.get_authorization_headers()
50
+ headers["Content-Type"] = "application/json"
51
+ headers["Accept"] = "application/json"
52
+ return headers
53
+
54
+ def close(self) -> None:
55
+ """Close the HTTP client."""
56
+ if self._client is not None:
57
+ self._client.close()
58
+ self._client = None
59
+ self._token_provider.close()
60
+
61
+ def _handle_error_response(self, response: httpx.Response) -> None:
62
+ """Handle error responses from the API."""
63
+ error_response: ErrorResponse | ErrorValidationResponse | None = None
64
+
65
+ try:
66
+ error_data = response.json()
67
+ # Check for validation errors (API returns PascalCase)
68
+ if "validationErrorMessages" in error_data or "ValidationErrorMessages" in error_data:
69
+ error_response = ErrorValidationResponse.model_validate(error_data)
70
+ else:
71
+ error_response = ErrorResponse.model_validate(error_data)
72
+ except Exception:
73
+ pass
74
+
75
+ message = (
76
+ error_response.message
77
+ if error_response and error_response.message
78
+ else response.text or f"HTTP {response.status_code}"
79
+ )
80
+
81
+ match response.status_code:
82
+ case 400:
83
+ raise ValidationError(
84
+ message=message,
85
+ error_response=error_response
86
+ if isinstance(error_response, ErrorValidationResponse)
87
+ else None,
88
+ )
89
+ case 401:
90
+ raise AuthenticationError(message=message, error_response=error_response)
91
+ case 403:
92
+ raise ForbiddenError(message=message, error_response=error_response)
93
+ case 404:
94
+ raise NotFoundError(message=message, error_response=error_response)
95
+ case 500:
96
+ raise ServerError(message=message, error_response=error_response)
97
+ case 503:
98
+ raise ServiceUnavailableError(
99
+ message=message, error_response=error_response
100
+ )
101
+ case _:
102
+ raise FluviusAPIError(
103
+ message=message,
104
+ status_code=response.status_code,
105
+ error_response=error_response,
106
+ )
107
+
108
+ def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
109
+ """Make a GET request."""
110
+ client = self._get_client()
111
+ response = client.get(path, params=params, headers=self._get_headers())
112
+
113
+ if not response.is_success:
114
+ self._handle_error_response(response)
115
+
116
+ return response.json()
117
+
118
+ def post(
119
+ self, path: str, json_data: dict[str, Any] | None = None
120
+ ) -> dict[str, Any]:
121
+ """Make a POST request."""
122
+ client = self._get_client()
123
+ response = client.post(path, json=json_data, headers=self._get_headers())
124
+
125
+ if not response.is_success:
126
+ self._handle_error_response(response)
127
+
128
+ return response.json()
@@ -0,0 +1,239 @@
1
+ """OAuth2 token management for Fluvius API authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass
10
+
11
+ import httpx
12
+ import jwt
13
+
14
+ from .credentials import FluviusCredentials
15
+ from .exceptions import AuthenticationError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ AZURE_AD_TOKEN_URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
20
+ TOKEN_LIFETIME_SECONDS = 3600
21
+ REFRESH_MARGIN_SECONDS = 60
22
+
23
+
24
+ @dataclass
25
+ class AccessToken:
26
+ """Represents an OAuth2 access token."""
27
+
28
+ access_token: str
29
+ token_type: str
30
+ expires_in: int
31
+ obtained_at: float
32
+
33
+ @property
34
+ def expires_at(self) -> float:
35
+ """Unix timestamp when the token expires."""
36
+ return self.obtained_at + self.expires_in
37
+
38
+ def is_expired(self, margin_seconds: int = REFRESH_MARGIN_SECONDS) -> bool:
39
+ """Check if the token is expired or about to expire.
40
+
41
+ Args:
42
+ margin_seconds: Safety margin in seconds before actual expiration.
43
+
44
+ Returns:
45
+ True if the token should be refreshed.
46
+ """
47
+ return time.time() >= (self.expires_at - margin_seconds)
48
+
49
+
50
+ class TokenProvider:
51
+ """Manages OAuth2 token lifecycle with automatic refresh.
52
+
53
+ Supports two authentication methods:
54
+ - Certificate-based: Uses JWT bearer assertion with RS256 (production)
55
+ - Client secret: Uses simple client_secret parameter (sandbox)
56
+
57
+ Tokens are cached and automatically refreshed before expiration.
58
+
59
+ Attributes:
60
+ credentials: The Fluvius API credentials.
61
+
62
+ Example:
63
+ ```python
64
+ credentials = FluviusCredentials.from_env()
65
+ token_provider = TokenProvider(credentials)
66
+
67
+ # Get a valid access token (auto-refreshes if needed)
68
+ token = token_provider.get_access_token()
69
+
70
+ # Get authorization headers for API requests
71
+ headers = token_provider.get_authorization_headers()
72
+ ```
73
+ """
74
+
75
+ def __init__(self, credentials: FluviusCredentials) -> None:
76
+ """Initialize the token provider.
77
+
78
+ Args:
79
+ credentials: The Fluvius API credentials.
80
+ """
81
+ self.credentials = credentials
82
+ self._token: AccessToken | None = None
83
+ self._http_client: httpx.Client | None = None
84
+
85
+ def _get_http_client(self) -> httpx.Client:
86
+ """Get or create the HTTP client for token requests."""
87
+ if self._http_client is None:
88
+ self._http_client = httpx.Client(timeout=30.0)
89
+ return self._http_client
90
+
91
+ def close(self) -> None:
92
+ """Close the HTTP client."""
93
+ if self._http_client is not None:
94
+ self._http_client.close()
95
+ self._http_client = None
96
+
97
+ def get_access_token(self) -> str:
98
+ """Get a valid access token, refreshing if necessary.
99
+
100
+ Returns:
101
+ The access token string.
102
+
103
+ Raises:
104
+ AuthenticationError: If token acquisition fails.
105
+ """
106
+ if self._token is None or self._token.is_expired():
107
+ logger.debug("Token is missing or expired, acquiring new token")
108
+ self._token = self._acquire_token()
109
+ else:
110
+ logger.debug("Reusing cached token")
111
+
112
+ return self._token.access_token
113
+
114
+ def get_authorization_headers(self) -> dict[str, str]:
115
+ """Get HTTP headers for authenticated API requests.
116
+
117
+ Returns:
118
+ Dictionary containing Authorization and subscription key headers.
119
+ """
120
+ return {
121
+ "Authorization": f"Bearer {self.get_access_token()}",
122
+ "Ocp-Apim-Subscription-Key": self.credentials.subscription_key,
123
+ }
124
+
125
+ def _acquire_token(self) -> AccessToken:
126
+ """Acquire a new access token from Azure AD.
127
+
128
+ Uses certificate-based auth if credentials include certificate,
129
+ otherwise uses client secret.
130
+
131
+ Returns:
132
+ The acquired access token.
133
+
134
+ Raises:
135
+ AuthenticationError: If token acquisition fails.
136
+ """
137
+ token_url = AZURE_AD_TOKEN_URL.format(tenant_id=self.credentials.tenant_id)
138
+
139
+ # Build request data based on authentication method
140
+ if self.credentials.uses_certificate:
141
+ logger.debug("Using certificate-based authentication")
142
+ client_assertion = self._create_client_assertion()
143
+ data = {
144
+ "grant_type": "client_credentials",
145
+ "client_id": self.credentials.client_id,
146
+ "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
147
+ "client_assertion": client_assertion,
148
+ "scope": self.credentials.scope,
149
+ }
150
+ else:
151
+ logger.debug("Using client secret authentication")
152
+ data = {
153
+ "grant_type": "client_credentials",
154
+ "client_id": self.credentials.client_id,
155
+ "client_secret": self.credentials.client_secret,
156
+ "scope": self.credentials.scope,
157
+ }
158
+
159
+ client = self._get_http_client()
160
+ obtained_at = time.time()
161
+
162
+ try:
163
+ response = client.post(
164
+ token_url,
165
+ data=data,
166
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
167
+ )
168
+ response.raise_for_status()
169
+ except httpx.HTTPStatusError as e:
170
+ error_detail = ""
171
+ try:
172
+ error_body = e.response.json()
173
+ error_detail = f": {error_body.get('error_description', error_body.get('error', ''))}"
174
+ except Exception:
175
+ pass
176
+ raise AuthenticationError(
177
+ f"Failed to acquire access token{error_detail}"
178
+ ) from e
179
+ except httpx.RequestError as e:
180
+ raise AuthenticationError(
181
+ f"Failed to connect to Azure AD: {e}"
182
+ ) from e
183
+
184
+ token_data = response.json()
185
+
186
+ return AccessToken(
187
+ access_token=token_data["access_token"],
188
+ token_type=token_data.get("token_type", "Bearer"),
189
+ expires_in=token_data.get("expires_in", TOKEN_LIFETIME_SECONDS),
190
+ obtained_at=obtained_at,
191
+ )
192
+
193
+ def _create_client_assertion(self) -> str:
194
+ """Create a signed JWT client assertion for Azure AD.
195
+
196
+ Returns:
197
+ The signed JWT token.
198
+ """
199
+ now = int(time.time())
200
+ expiry = now + TOKEN_LIFETIME_SECONDS
201
+
202
+ headers = {
203
+ "alg": "RS256",
204
+ "typ": "JWT",
205
+ "x5t": self._encode_thumbprint(self.credentials.certificate_thumbprint),
206
+ }
207
+
208
+ payload = {
209
+ "aud": f"https://login.microsoftonline.com/{self.credentials.tenant_id}/v2.0",
210
+ "iss": self.credentials.client_id,
211
+ "sub": self.credentials.client_id,
212
+ "jti": str(uuid.uuid4()),
213
+ "nbf": now,
214
+ "exp": expiry,
215
+ }
216
+
217
+ return jwt.encode(
218
+ payload,
219
+ self.credentials.private_key,
220
+ algorithm="RS256",
221
+ headers=headers,
222
+ )
223
+
224
+ @staticmethod
225
+ def _encode_thumbprint(thumbprint: str) -> str:
226
+ """Encode a certificate thumbprint for the x5t JWT header.
227
+
228
+ The thumbprint is a hexadecimal string that needs to be converted
229
+ to bytes and then base64url-encoded.
230
+
231
+ Args:
232
+ thumbprint: Hexadecimal certificate thumbprint.
233
+
234
+ Returns:
235
+ Base64url-encoded thumbprint.
236
+ """
237
+ thumbprint_bytes = bytes.fromhex(thumbprint)
238
+ encoded = base64.urlsafe_b64encode(thumbprint_bytes).rstrip(b"=")
239
+ return encoded.decode("ascii")