python-daynest 0.1.1__tar.gz → 0.1.3__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.
- {python_daynest-0.1.1 → python_daynest-0.1.3}/PKG-INFO +1 -1
- {python_daynest-0.1.1 → python_daynest-0.1.3}/pyproject.toml +1 -1
- {python_daynest-0.1.1 → python_daynest-0.1.3}/src/daynest/client.py +86 -10
- {python_daynest-0.1.1 → python_daynest-0.1.3}/tests/test_client.py +73 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/.gitignore +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/src/daynest/__init__.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/src/daynest/exceptions.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/src/daynest/models.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/tests/__init__.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/tests/test_package.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.3}/uv.lock +0 -0
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
9
|
from datetime import date
|
|
8
10
|
from typing import Any, TypeVar
|
|
9
11
|
from urllib.parse import urljoin
|
|
@@ -22,6 +24,7 @@ from daynest.models import DaynestApiResponse, DaynestDashboard, DaynestSummary
|
|
|
22
24
|
|
|
23
25
|
DEFAULT_API_BASE_URL = "http://localhost:8000"
|
|
24
26
|
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
|
27
|
+
TOKEN_EXPIRY_BUFFER = 30 # seconds before expiry to refresh proactively
|
|
25
28
|
|
|
26
29
|
logger = logging.getLogger(__name__)
|
|
27
30
|
|
|
@@ -51,12 +54,22 @@ class DaynestClient:
|
|
|
51
54
|
*,
|
|
52
55
|
session: aiohttp.ClientSession | None = None,
|
|
53
56
|
password: str | None = None,
|
|
57
|
+
client_id: str | None = None,
|
|
58
|
+
client_secret: str | None = None,
|
|
59
|
+
token_url: str | None = None,
|
|
60
|
+
access_token_getter: Callable[[], str | None | Awaitable[str | None]] | None = None,
|
|
54
61
|
) -> None:
|
|
55
62
|
if base_url is not None and not base_url.strip():
|
|
56
63
|
msg = "A base URL is required to initialize DaynestClient"
|
|
57
64
|
raise ValueError(msg)
|
|
58
65
|
self._base_url = (base_url or DEFAULT_API_BASE_URL).strip().rstrip("/")
|
|
59
66
|
self._integration_key = integration_key if integration_key is not None else password
|
|
67
|
+
self._client_id = client_id
|
|
68
|
+
self._client_secret = client_secret
|
|
69
|
+
self._token_url = token_url
|
|
70
|
+
self._access_token_getter = access_token_getter
|
|
71
|
+
self._cached_token: str | None = None
|
|
72
|
+
self._token_expires_at: float = 0.0
|
|
60
73
|
self._session = session
|
|
61
74
|
self._owned_session = session is None
|
|
62
75
|
self._context_depth = 0
|
|
@@ -78,6 +91,75 @@ class DaynestClient:
|
|
|
78
91
|
"""Return whether the client has an integration key configured."""
|
|
79
92
|
return bool(self._integration_key)
|
|
80
93
|
|
|
94
|
+
@property
|
|
95
|
+
def has_oauth_credentials(self) -> bool:
|
|
96
|
+
"""Return whether the client has OAuth client credentials configured."""
|
|
97
|
+
return bool(self._client_id and self._client_secret and self._token_url)
|
|
98
|
+
|
|
99
|
+
async def _get_external_access_token(self) -> str | None:
|
|
100
|
+
"""Return an externally managed OAuth access token, if configured."""
|
|
101
|
+
if self._access_token_getter is None:
|
|
102
|
+
return None
|
|
103
|
+
token = self._access_token_getter()
|
|
104
|
+
if inspect.isawaitable(token):
|
|
105
|
+
token = await token
|
|
106
|
+
if not token:
|
|
107
|
+
return None
|
|
108
|
+
return token
|
|
109
|
+
|
|
110
|
+
async def _fetch_oauth_token(self) -> str:
|
|
111
|
+
"""Exchange client credentials for an access token."""
|
|
112
|
+
session = self._session_or_raise()
|
|
113
|
+
data = {
|
|
114
|
+
"grant_type": "client_credentials",
|
|
115
|
+
"client_id": self._client_id,
|
|
116
|
+
"client_secret": self._client_secret,
|
|
117
|
+
}
|
|
118
|
+
try:
|
|
119
|
+
async with session.post(
|
|
120
|
+
self._token_url, # type: ignore[arg-type]
|
|
121
|
+
data=data,
|
|
122
|
+
timeout=REQUEST_TIMEOUT,
|
|
123
|
+
) as response:
|
|
124
|
+
if response.status in (401, 403):
|
|
125
|
+
msg = "OAuth client credentials rejected by token endpoint"
|
|
126
|
+
raise DaynestAuthError(msg)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
try:
|
|
129
|
+
body = await response.json(content_type=None)
|
|
130
|
+
if not isinstance(body, Mapping):
|
|
131
|
+
msg = "Token endpoint response was not a JSON object"
|
|
132
|
+
raise ValueError(msg)
|
|
133
|
+
token = body.get("access_token")
|
|
134
|
+
if not isinstance(token, str) or not token:
|
|
135
|
+
msg = "Token endpoint returned no access_token"
|
|
136
|
+
raise ValueError(msg)
|
|
137
|
+
try:
|
|
138
|
+
expires_in = int(body.get("expires_in", 300))
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
expires_in = 300
|
|
141
|
+
except ValueError as err:
|
|
142
|
+
msg = f"Token endpoint returned malformed payload: {err}"
|
|
143
|
+
raise DaynestAuthError(msg) from err
|
|
144
|
+
self._cached_token = token
|
|
145
|
+
self._token_expires_at = time.monotonic() + expires_in - TOKEN_EXPIRY_BUFFER
|
|
146
|
+
return token
|
|
147
|
+
except aiohttp.ClientError as err:
|
|
148
|
+
msg = f"Failed to reach token endpoint: {err}"
|
|
149
|
+
raise DaynestCommunicationError(msg) from err
|
|
150
|
+
|
|
151
|
+
async def _get_auth_headers(self) -> dict[str, str]:
|
|
152
|
+
"""Return auth headers for an API request."""
|
|
153
|
+
if external_token := await self._get_external_access_token():
|
|
154
|
+
return {"Authorization": f"Bearer {external_token}"}
|
|
155
|
+
if self.has_oauth_credentials:
|
|
156
|
+
if not self._cached_token or time.monotonic() >= self._token_expires_at:
|
|
157
|
+
await self._fetch_oauth_token()
|
|
158
|
+
return {"Authorization": f"Bearer {self._cached_token}"}
|
|
159
|
+
if self._integration_key:
|
|
160
|
+
return {"X-Integration-Key": self._integration_key}
|
|
161
|
+
return {}
|
|
162
|
+
|
|
81
163
|
async def async_get_data(self) -> dict[str, Any]:
|
|
82
164
|
"""Fetch summary data as the coordinator's primary payload."""
|
|
83
165
|
response = await self.async_get_summary()
|
|
@@ -218,9 +300,7 @@ class DaynestClient:
|
|
|
218
300
|
) -> DaynestApiResponse[ModelT]:
|
|
219
301
|
session = self._session_or_raise()
|
|
220
302
|
url = urljoin(f"{self._base_url}/", path.lstrip("/"))
|
|
221
|
-
headers = {"Accept": "application/json"}
|
|
222
|
-
if self._integration_key:
|
|
223
|
-
headers["X-Integration-Key"] = self._integration_key
|
|
303
|
+
headers = {"Accept": "application/json", **await self._get_auth_headers()}
|
|
224
304
|
|
|
225
305
|
try:
|
|
226
306
|
async with session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) as response:
|
|
@@ -251,9 +331,7 @@ class DaynestClient:
|
|
|
251
331
|
async def _request_list(self, path: str) -> list[dict[str, Any]]:
|
|
252
332
|
session = self._session_or_raise()
|
|
253
333
|
url = urljoin(f"{self._base_url}/", path.lstrip("/"))
|
|
254
|
-
headers = {"Accept": "application/json"}
|
|
255
|
-
if self._integration_key:
|
|
256
|
-
headers["X-Integration-Key"] = self._integration_key
|
|
334
|
+
headers = {"Accept": "application/json", **await self._get_auth_headers()}
|
|
257
335
|
|
|
258
336
|
try:
|
|
259
337
|
async with session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) as response:
|
|
@@ -301,11 +379,9 @@ class DaynestClient:
|
|
|
301
379
|
) -> dict[str, Any]:
|
|
302
380
|
session = self._session_or_raise()
|
|
303
381
|
url = urljoin(f"{self._base_url}/", path.lstrip("/"))
|
|
304
|
-
headers = {"Accept": "application/json"}
|
|
382
|
+
headers = {"Accept": "application/json", **await self._get_auth_headers()}
|
|
305
383
|
if payload is not None:
|
|
306
384
|
headers["Content-Type"] = "application/json"
|
|
307
|
-
if self._integration_key:
|
|
308
|
-
headers["X-Integration-Key"] = self._integration_key
|
|
309
385
|
|
|
310
386
|
if method.lower() not in {"post", "put", "delete"}:
|
|
311
387
|
msg = f"Unsupported write method: {method}"
|
|
@@ -255,6 +255,79 @@ class TestDaynestClientRequests:
|
|
|
255
255
|
call_kwargs = session.get.call_args[1]
|
|
256
256
|
assert "X-Integration-Key" not in call_kwargs["headers"]
|
|
257
257
|
|
|
258
|
+
async def test_oauth_token_response_builds_bearer_header(self) -> None:
|
|
259
|
+
response = _make_mock_response(200, {"access_token": "access-token", "expires_in": "300"})
|
|
260
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
261
|
+
session.post = MagicMock(return_value=response)
|
|
262
|
+
client = DaynestClient(
|
|
263
|
+
base_url="https://api.example",
|
|
264
|
+
client_id="integration-client",
|
|
265
|
+
client_secret="client-secret",
|
|
266
|
+
token_url="https://auth.example/token",
|
|
267
|
+
session=session,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
assert await client._get_auth_headers() == {"Authorization": "Bearer access-token"}
|
|
271
|
+
|
|
272
|
+
async def test_oauth_token_non_object_response_raises_auth_error(self) -> None:
|
|
273
|
+
response = _make_mock_response(200, ["not", "an", "object"])
|
|
274
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
275
|
+
session.post = MagicMock(return_value=response)
|
|
276
|
+
client = DaynestClient(
|
|
277
|
+
base_url="https://api.example",
|
|
278
|
+
client_id="integration-client",
|
|
279
|
+
client_secret="client-secret",
|
|
280
|
+
token_url="https://auth.example/token",
|
|
281
|
+
session=session,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
with pytest.raises(DaynestAuthError, match="malformed payload"):
|
|
285
|
+
await client._get_auth_headers()
|
|
286
|
+
|
|
287
|
+
async def test_oauth_token_invalid_expires_in_falls_back_to_default(self) -> None:
|
|
288
|
+
response = _make_mock_response(200, {"access_token": "access-token", "expires_in": "soon"})
|
|
289
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
290
|
+
session.post = MagicMock(return_value=response)
|
|
291
|
+
client = DaynestClient(
|
|
292
|
+
base_url="https://api.example",
|
|
293
|
+
client_id="integration-client",
|
|
294
|
+
client_secret="client-secret",
|
|
295
|
+
token_url="https://auth.example/token",
|
|
296
|
+
session=session,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
assert await client._get_auth_headers() == {"Authorization": "Bearer access-token"}
|
|
300
|
+
|
|
301
|
+
async def test_external_access_token_getter_is_used_before_other_auth_modes(self) -> None:
|
|
302
|
+
response = _make_mock_response(200, {"access_token": "ignored-token", "expires_in": "300"})
|
|
303
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
304
|
+
session.post = MagicMock(return_value=response)
|
|
305
|
+
client = DaynestClient(
|
|
306
|
+
base_url="https://api.example",
|
|
307
|
+
integration_key="legacy-key",
|
|
308
|
+
client_id="integration-client",
|
|
309
|
+
client_secret="client-secret",
|
|
310
|
+
token_url="https://auth.example/token",
|
|
311
|
+
access_token_getter=lambda: "oidc-access-token",
|
|
312
|
+
session=session,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
assert await client._get_auth_headers() == {"Authorization": "Bearer oidc-access-token"}
|
|
316
|
+
session.post.assert_not_called()
|
|
317
|
+
|
|
318
|
+
async def test_external_access_token_getter_supports_async_callback(self) -> None:
|
|
319
|
+
async def _token() -> str:
|
|
320
|
+
return "async-token"
|
|
321
|
+
|
|
322
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
323
|
+
client = DaynestClient(
|
|
324
|
+
base_url="https://api.example",
|
|
325
|
+
access_token_getter=_token,
|
|
326
|
+
session=session,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
assert await client._get_auth_headers() == {"Authorization": "Bearer async-token"}
|
|
330
|
+
|
|
258
331
|
async def test_client_error_raises_communication_error(self) -> None:
|
|
259
332
|
session = MagicMock(spec=aiohttp.ClientSession)
|
|
260
333
|
mock_ctx = MagicMock()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|