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.
Files changed (24) hide show
  1. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/PKG-INFO +1 -1
  2. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/docker-compose.yml +1 -1
  3. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/azure.py +28 -4
  4. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/client.py +39 -1
  5. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/models.py +13 -0
  6. hydopt_client-0.4.0/tests/test_azure.py +105 -0
  7. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/tests/test_client_unit.py +30 -0
  8. hydopt_client-0.2.0/tests/test_azure.py +0 -20
  9. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/.github/workflows/release.yml +0 -0
  10. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/.github/workflows/test.yml +0 -0
  11. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/.gitignore +0 -0
  12. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/Makefile +0 -0
  13. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/README.md +0 -0
  14. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/examples/streamlit_app.py +0 -0
  15. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/pyproject.toml +0 -0
  16. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/__init__.py +0 -0
  17. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/__init__.py +0 -0
  18. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/base.py +0 -0
  19. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/iap.py +0 -0
  20. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/auth/service_account.py +0 -0
  21. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/src/hydopt_client/desktop_app_secret.json +0 -0
  22. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/tests/test_api.py +0 -0
  23. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/tests/test_auth.py +0 -0
  24. {hydopt_client-0.2.0 → hydopt_client-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hydopt-client
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Python client for the HydOpt hydropower optimization API
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: google-auth>=2.56.3
@@ -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:20260910T125042-abf20a2
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
- if not result:
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
- if "id_token" not in result:
61
+ token = result.get("id_token") if result else None
62
+ if not token:
39
63
  raise IdTokenError()
40
-
41
- return result["id_token"]
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
- day = "Day"
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