hydopt-client 0.2.0__tar.gz → 0.4.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.
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/PKG-INFO +1 -1
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/docker-compose.yml +1 -1
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/azure.py +28 -4
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/client.py +39 -1
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/models.py +13 -0
- hydopt_client-0.4.0/tests/test_azure.py +105 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/tests/test_client_unit.py +30 -0
- hydopt_client-0.2.0/tests/test_azure.py +0 -20
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/.github/workflows/release.yml +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/.github/workflows/test.yml +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/.gitignore +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/Makefile +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/README.md +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/examples/streamlit_app.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/pyproject.toml +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/__init__.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/__init__.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/base.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/iap.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/service_account.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/desktop_app_secret.json +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/tests/test_api.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/tests/test_auth.py +0 -0
- {hydopt_client-0.2.0 → hydopt_client-0.4.0}/uv.lock +0 -0
|
@@ -2,7 +2,7 @@ name: hydopt-api
|
|
|
2
2
|
|
|
3
3
|
services:
|
|
4
4
|
api:
|
|
5
|
-
image: us-central1-docker.pkg.dev/hydopt/hydopt-backend/hydopt-api:
|
|
5
|
+
image: us-central1-docker.pkg.dev/hydopt/hydopt-backend/hydopt-api:20260922T111447-86bcc45
|
|
6
6
|
ports:
|
|
7
7
|
- "${PORT:-8083}:8083"
|
|
8
8
|
environment:
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
1
5
|
import msal
|
|
2
6
|
|
|
3
7
|
hydopt_client_id = "2902dddd-fab8-4843-baae-dbde348275bd"
|
|
@@ -10,6 +14,20 @@ class IdTokenError(RuntimeError):
|
|
|
10
14
|
super().__init__("Failed to get access token")
|
|
11
15
|
|
|
12
16
|
|
|
17
|
+
def _id_token_expired(token: str, now: float | None = None) -> bool:
|
|
18
|
+
"""True when the JWT ``exp`` is in the past, or the token cannot be read."""
|
|
19
|
+
try:
|
|
20
|
+
payload_b64 = token.split(".")[1]
|
|
21
|
+
padding = "=" * (-len(payload_b64) % 4)
|
|
22
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
|
23
|
+
exp = payload.get("exp")
|
|
24
|
+
except (IndexError, ValueError, json.JSONDecodeError):
|
|
25
|
+
return True
|
|
26
|
+
if exp is None:
|
|
27
|
+
return False
|
|
28
|
+
return float(exp) <= (now if now is not None else time.time())
|
|
29
|
+
|
|
30
|
+
|
|
13
31
|
class AzureTokenProvider:
|
|
14
32
|
def __init__(
|
|
15
33
|
self,
|
|
@@ -25,17 +43,23 @@ class AzureTokenProvider:
|
|
|
25
43
|
self.client_id,
|
|
26
44
|
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
|
|
27
45
|
)
|
|
46
|
+
self._id_token: str | None = None
|
|
28
47
|
|
|
29
48
|
def get_token(self) -> str:
|
|
49
|
+
if self._id_token and not _id_token_expired(self._id_token):
|
|
50
|
+
return self._id_token
|
|
51
|
+
|
|
30
52
|
accounts = self._client.get_accounts()
|
|
31
53
|
result = None
|
|
32
54
|
if accounts:
|
|
33
55
|
result = self._client.acquire_token_silent(self.scopes, account=accounts[0])
|
|
34
56
|
|
|
35
|
-
|
|
57
|
+
# A silent cache hit carries the access token and omits the ID token the API needs.
|
|
58
|
+
if not result or "id_token" not in result:
|
|
36
59
|
result = self._client.acquire_token_interactive(self.scopes)
|
|
37
60
|
|
|
38
|
-
|
|
61
|
+
token = result.get("id_token") if result else None
|
|
62
|
+
if not token:
|
|
39
63
|
raise IdTokenError()
|
|
40
|
-
|
|
41
|
-
return
|
|
64
|
+
self._id_token = str(token)
|
|
65
|
+
return self._id_token
|
|
@@ -17,6 +17,7 @@ from hydopt_client.models import (
|
|
|
17
17
|
GraphLayers,
|
|
18
18
|
HydoptRecord,
|
|
19
19
|
KeyNumbers,
|
|
20
|
+
Link,
|
|
20
21
|
ObjectStatReport,
|
|
21
22
|
Order,
|
|
22
23
|
parse_records,
|
|
@@ -95,7 +96,7 @@ class Category(StrEnum):
|
|
|
95
96
|
balance_energy_rate = "Balance energy rate"
|
|
96
97
|
balance_energy = "Balance energy"
|
|
97
98
|
balance_power = "Balance power"
|
|
98
|
-
|
|
99
|
+
day_ahead_price = "Day ahead price"
|
|
99
100
|
discharge = "Discharge"
|
|
100
101
|
forward_curve = "Forward curve"
|
|
101
102
|
generator_water_value = "Generator water value"
|
|
@@ -109,6 +110,38 @@ class Category(StrEnum):
|
|
|
109
110
|
wind = "Wind@100m"
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
class ValueType(StrEnum):
|
|
114
|
+
actual = "Actual"
|
|
115
|
+
actual_region = "Actual region"
|
|
116
|
+
daily = "Daily"
|
|
117
|
+
estimate = "Estimate"
|
|
118
|
+
forecast = "Forecast"
|
|
119
|
+
forecast_buffer = "Forecast buffer"
|
|
120
|
+
forecast_region = "Forecast region"
|
|
121
|
+
history = "History"
|
|
122
|
+
history_avg = "History avg"
|
|
123
|
+
history_buffer = "History buffer"
|
|
124
|
+
history_max = "History max"
|
|
125
|
+
history_min = "History min"
|
|
126
|
+
history_region = "History region"
|
|
127
|
+
monthly = "Monthly"
|
|
128
|
+
normal = "Normal"
|
|
129
|
+
quarterly = "Quarterly"
|
|
130
|
+
smooth = "Smooth"
|
|
131
|
+
weekly = "Weekly"
|
|
132
|
+
yearly = "Yearly"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DataSource(StrEnum):
|
|
136
|
+
ecmwf_era5 = "ECMWF-ERA5"
|
|
137
|
+
entsoe = "ENTSOE"
|
|
138
|
+
euronext = "Euronext"
|
|
139
|
+
hydopt = "HydOpt"
|
|
140
|
+
nve = "NVE"
|
|
141
|
+
smhi = "SMHI"
|
|
142
|
+
vattenreglering = "Vattenreglering"
|
|
143
|
+
|
|
144
|
+
|
|
112
145
|
class DifferenceTypes(StrEnum):
|
|
113
146
|
anomaly_forecast = "Anomaly Forecast"
|
|
114
147
|
anomaly_forecast_region = "Anomaly Forecast Region"
|
|
@@ -264,6 +297,11 @@ class Client:
|
|
|
264
297
|
response.raise_for_status()
|
|
265
298
|
return response.json()["availableDates"]
|
|
266
299
|
|
|
300
|
+
def links(self) -> list[Link]:
|
|
301
|
+
response = self._request("GET", "/v1/links")
|
|
302
|
+
response.raise_for_status()
|
|
303
|
+
return [Link.model_validate(item) for item in response.json().get("links") or []]
|
|
304
|
+
|
|
267
305
|
def catchments(self, asof: str) -> list[CatchmentRecord]:
|
|
268
306
|
response = self._request("GET", f"/v1/catchments/{asof}")
|
|
269
307
|
response.raise_for_status()
|
|
@@ -45,6 +45,19 @@ def parse_records(items: list[dict]) -> list[HydoptRecord]:
|
|
|
45
45
|
return [HydoptRecord.model_validate(item) for item in items]
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
class Link(BaseModel):
|
|
49
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
up: str
|
|
53
|
+
down: str
|
|
54
|
+
edge_type: str = ""
|
|
55
|
+
lat_up: float = 0.0
|
|
56
|
+
lon_up: float = 0.0
|
|
57
|
+
lat_down: float = 0.0
|
|
58
|
+
lon_down: float = 0.0
|
|
59
|
+
|
|
60
|
+
|
|
48
61
|
class CatchmentRecord(BaseModel):
|
|
49
62
|
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
50
63
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from hydopt_client.auth.azure import AzureTokenProvider, IdTokenError
|
|
9
|
+
from hydopt_client.auth.base import TokenProvider
|
|
10
|
+
from hydopt_client.client import Client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _jwt(exp: float | None) -> str:
|
|
14
|
+
payload = {} if exp is None else {"exp": exp}
|
|
15
|
+
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
|
16
|
+
return f"e30.{body}.sig"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FakeApp:
|
|
20
|
+
def __init__(self, id_token: str) -> None:
|
|
21
|
+
self.id_token = id_token
|
|
22
|
+
self.accounts: list[dict[str, str]] = []
|
|
23
|
+
self.silent_calls = 0
|
|
24
|
+
self.interactive_calls = 0
|
|
25
|
+
self.interactive_result: dict[str, str] | None = None
|
|
26
|
+
|
|
27
|
+
def get_accounts(self) -> list[dict[str, str]]:
|
|
28
|
+
return self.accounts
|
|
29
|
+
|
|
30
|
+
def acquire_token_silent(
|
|
31
|
+
self, scopes: list[str], account: dict[str, str]
|
|
32
|
+
) -> dict[str, str]:
|
|
33
|
+
self.silent_calls += 1
|
|
34
|
+
return {"access_token": "access"}
|
|
35
|
+
|
|
36
|
+
def acquire_token_interactive(self, scopes: list[str]) -> dict[str, str]:
|
|
37
|
+
self.interactive_calls += 1
|
|
38
|
+
if self.interactive_result is not None:
|
|
39
|
+
return self.interactive_result
|
|
40
|
+
self.accounts = [{"username": "a"}]
|
|
41
|
+
return {"id_token": self.id_token}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _provider(
|
|
45
|
+
monkeypatch: pytest.MonkeyPatch, id_token: str
|
|
46
|
+
) -> tuple[AzureTokenProvider, _FakeApp]:
|
|
47
|
+
app = _FakeApp(id_token)
|
|
48
|
+
monkeypatch.setattr(
|
|
49
|
+
"hydopt_client.auth.azure.msal.PublicClientApplication",
|
|
50
|
+
lambda *args, **kwargs: app,
|
|
51
|
+
)
|
|
52
|
+
return AzureTokenProvider(), app
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_satisfy_interface():
|
|
56
|
+
assert isinstance(AzureTokenProvider(), TokenProvider)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_reuses_id_token_from_interactive_login(
|
|
60
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
61
|
+
) -> None:
|
|
62
|
+
provider, app = _provider(monkeypatch, _jwt(time.time() + 3600))
|
|
63
|
+
|
|
64
|
+
assert provider.get_token() == app.id_token
|
|
65
|
+
assert provider.get_token() == app.id_token
|
|
66
|
+
assert app.interactive_calls == 1
|
|
67
|
+
assert app.silent_calls == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_expired_id_token_signs_in_again(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
71
|
+
provider, app = _provider(monkeypatch, _jwt(time.time() - 10))
|
|
72
|
+
|
|
73
|
+
assert provider.get_token() == app.id_token
|
|
74
|
+
app.id_token = _jwt(time.time() + 3600)
|
|
75
|
+
assert provider.get_token() == app.id_token
|
|
76
|
+
assert app.interactive_calls == 2
|
|
77
|
+
assert app.silent_calls == 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_silent_result_without_id_token_is_a_miss(
|
|
81
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
82
|
+
) -> None:
|
|
83
|
+
provider, app = _provider(monkeypatch, _jwt(time.time() + 3600))
|
|
84
|
+
app.accounts = [{"username": "a"}]
|
|
85
|
+
|
|
86
|
+
assert provider.get_token() == app.id_token
|
|
87
|
+
assert app.silent_calls == 1
|
|
88
|
+
assert app.interactive_calls == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_missing_id_token_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
92
|
+
provider, app = _provider(monkeypatch, _jwt(time.time() + 3600))
|
|
93
|
+
app.interactive_result = {"error": "invalid_grant"}
|
|
94
|
+
|
|
95
|
+
with pytest.raises(IdTokenError):
|
|
96
|
+
provider.get_token()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_can_get_data_via_azure():
|
|
100
|
+
if os.getenv("CI"):
|
|
101
|
+
pytest.skip("Test can not run in CI/CD")
|
|
102
|
+
|
|
103
|
+
client = Client(token_provider=AzureTokenProvider())
|
|
104
|
+
partitions = client.list_partitions()
|
|
105
|
+
assert partitions
|
|
@@ -129,6 +129,36 @@ def test_hierarchy_layers_parses_graph_layers() -> None:
|
|
|
129
129
|
assert layers.point_ids == ["p1"]
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def test_links_parses_watercourse_edges() -> None:
|
|
133
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
134
|
+
assert request.url.path == "/v1/links"
|
|
135
|
+
return httpx.Response(
|
|
136
|
+
200,
|
|
137
|
+
json={
|
|
138
|
+
"links": [
|
|
139
|
+
{
|
|
140
|
+
"id": "a//b",
|
|
141
|
+
"up": "A::R::Bergsdalen",
|
|
142
|
+
"down": "B::G::Bergsdalen",
|
|
143
|
+
"edgeType": "Release",
|
|
144
|
+
"latUp": 60.5,
|
|
145
|
+
"lonUp": 6.1,
|
|
146
|
+
"latDown": 60.4,
|
|
147
|
+
"lonDown": 6.0,
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
client, _ = make_client(handler)
|
|
154
|
+
links = client.links()
|
|
155
|
+
assert len(links) == 1
|
|
156
|
+
assert links[0].up == "A::R::Bergsdalen"
|
|
157
|
+
assert links[0].down == "B::G::Bergsdalen"
|
|
158
|
+
assert links[0].edge_type == "Release"
|
|
159
|
+
assert links[0].lat_up == 60.5
|
|
160
|
+
|
|
161
|
+
|
|
132
162
|
def test_hierarchy_layers_sends_selected_nodes() -> None:
|
|
133
163
|
captured = {}
|
|
134
164
|
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from hydopt_client.auth.azure import AzureTokenProvider
|
|
6
|
-
from hydopt_client.auth.base import TokenProvider
|
|
7
|
-
from hydopt_client.client import Client
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_satisfy_interface():
|
|
11
|
-
assert isinstance(AzureTokenProvider(), TokenProvider)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_can_get_data_via_azure():
|
|
15
|
-
if os.getenv("CI"):
|
|
16
|
-
pytest.skip("Test can not run in CI/CD")
|
|
17
|
-
|
|
18
|
-
client = Client(token_provider=AzureTokenProvider())
|
|
19
|
-
partitions = client.list_partitions()
|
|
20
|
-
assert partitions
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|