python-daynest 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +1 -1
- {python_daynest-0.1.1 → python_daynest-0.1.2}/pyproject.toml +1 -1
- {python_daynest-0.1.1 → python_daynest-0.1.2}/src/daynest/client.py +70 -10
- {python_daynest-0.1.1 → python_daynest-0.1.2}/tests/test_client.py +43 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/.gitignore +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/src/daynest/__init__.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/src/daynest/exceptions.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/src/daynest/models.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/tests/__init__.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/tests/test_package.py +0 -0
- {python_daynest-0.1.1 → python_daynest-0.1.2}/uv.lock +0 -0
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
7
8
|
from datetime import date
|
|
8
9
|
from typing import Any, TypeVar
|
|
9
10
|
from urllib.parse import urljoin
|
|
@@ -22,6 +23,7 @@ from daynest.models import DaynestApiResponse, DaynestDashboard, DaynestSummary
|
|
|
22
23
|
|
|
23
24
|
DEFAULT_API_BASE_URL = "http://localhost:8000"
|
|
24
25
|
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
|
26
|
+
TOKEN_EXPIRY_BUFFER = 30 # seconds before expiry to refresh proactively
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
27
29
|
|
|
@@ -51,12 +53,20 @@ class DaynestClient:
|
|
|
51
53
|
*,
|
|
52
54
|
session: aiohttp.ClientSession | None = None,
|
|
53
55
|
password: str | None = None,
|
|
56
|
+
client_id: str | None = None,
|
|
57
|
+
client_secret: str | None = None,
|
|
58
|
+
token_url: str | None = None,
|
|
54
59
|
) -> None:
|
|
55
60
|
if base_url is not None and not base_url.strip():
|
|
56
61
|
msg = "A base URL is required to initialize DaynestClient"
|
|
57
62
|
raise ValueError(msg)
|
|
58
63
|
self._base_url = (base_url or DEFAULT_API_BASE_URL).strip().rstrip("/")
|
|
59
64
|
self._integration_key = integration_key if integration_key is not None else password
|
|
65
|
+
self._client_id = client_id
|
|
66
|
+
self._client_secret = client_secret
|
|
67
|
+
self._token_url = token_url
|
|
68
|
+
self._cached_token: str | None = None
|
|
69
|
+
self._token_expires_at: float = 0.0
|
|
60
70
|
self._session = session
|
|
61
71
|
self._owned_session = session is None
|
|
62
72
|
self._context_depth = 0
|
|
@@ -78,6 +88,62 @@ class DaynestClient:
|
|
|
78
88
|
"""Return whether the client has an integration key configured."""
|
|
79
89
|
return bool(self._integration_key)
|
|
80
90
|
|
|
91
|
+
@property
|
|
92
|
+
def has_oauth_credentials(self) -> bool:
|
|
93
|
+
"""Return whether the client has OAuth client credentials configured."""
|
|
94
|
+
return bool(self._client_id and self._client_secret and self._token_url)
|
|
95
|
+
|
|
96
|
+
async def _fetch_oauth_token(self) -> str:
|
|
97
|
+
"""Exchange client credentials for an access token."""
|
|
98
|
+
session = self._session_or_raise()
|
|
99
|
+
data = {
|
|
100
|
+
"grant_type": "client_credentials",
|
|
101
|
+
"client_id": self._client_id,
|
|
102
|
+
"client_secret": self._client_secret,
|
|
103
|
+
}
|
|
104
|
+
try:
|
|
105
|
+
async with session.post(
|
|
106
|
+
self._token_url, # type: ignore[arg-type]
|
|
107
|
+
data=data,
|
|
108
|
+
timeout=REQUEST_TIMEOUT,
|
|
109
|
+
) as response:
|
|
110
|
+
if response.status in (401, 403):
|
|
111
|
+
msg = "OAuth client credentials rejected by token endpoint"
|
|
112
|
+
raise DaynestAuthError(msg)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
try:
|
|
115
|
+
body = await response.json(content_type=None)
|
|
116
|
+
if not isinstance(body, Mapping):
|
|
117
|
+
msg = "Token endpoint response was not a JSON object"
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
token = body.get("access_token")
|
|
120
|
+
if not isinstance(token, str) or not token:
|
|
121
|
+
msg = "Token endpoint returned no access_token"
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
try:
|
|
124
|
+
expires_in = int(body.get("expires_in", 300))
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
expires_in = 300
|
|
127
|
+
except ValueError as err:
|
|
128
|
+
msg = f"Token endpoint returned malformed payload: {err}"
|
|
129
|
+
raise DaynestAuthError(msg) from err
|
|
130
|
+
self._cached_token = token
|
|
131
|
+
self._token_expires_at = time.monotonic() + expires_in - TOKEN_EXPIRY_BUFFER
|
|
132
|
+
return token
|
|
133
|
+
except aiohttp.ClientError as err:
|
|
134
|
+
msg = f"Failed to reach token endpoint: {err}"
|
|
135
|
+
raise DaynestCommunicationError(msg) from err
|
|
136
|
+
|
|
137
|
+
async def _get_auth_headers(self) -> dict[str, str]:
|
|
138
|
+
"""Return auth headers for an API request."""
|
|
139
|
+
if self.has_oauth_credentials:
|
|
140
|
+
if not self._cached_token or time.monotonic() >= self._token_expires_at:
|
|
141
|
+
await self._fetch_oauth_token()
|
|
142
|
+
return {"Authorization": f"Bearer {self._cached_token}"}
|
|
143
|
+
if self._integration_key:
|
|
144
|
+
return {"X-Integration-Key": self._integration_key}
|
|
145
|
+
return {}
|
|
146
|
+
|
|
81
147
|
async def async_get_data(self) -> dict[str, Any]:
|
|
82
148
|
"""Fetch summary data as the coordinator's primary payload."""
|
|
83
149
|
response = await self.async_get_summary()
|
|
@@ -218,9 +284,7 @@ class DaynestClient:
|
|
|
218
284
|
) -> DaynestApiResponse[ModelT]:
|
|
219
285
|
session = self._session_or_raise()
|
|
220
286
|
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
|
|
287
|
+
headers = {"Accept": "application/json", **await self._get_auth_headers()}
|
|
224
288
|
|
|
225
289
|
try:
|
|
226
290
|
async with session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) as response:
|
|
@@ -251,9 +315,7 @@ class DaynestClient:
|
|
|
251
315
|
async def _request_list(self, path: str) -> list[dict[str, Any]]:
|
|
252
316
|
session = self._session_or_raise()
|
|
253
317
|
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
|
|
318
|
+
headers = {"Accept": "application/json", **await self._get_auth_headers()}
|
|
257
319
|
|
|
258
320
|
try:
|
|
259
321
|
async with session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) as response:
|
|
@@ -301,11 +363,9 @@ class DaynestClient:
|
|
|
301
363
|
) -> dict[str, Any]:
|
|
302
364
|
session = self._session_or_raise()
|
|
303
365
|
url = urljoin(f"{self._base_url}/", path.lstrip("/"))
|
|
304
|
-
headers = {"Accept": "application/json"}
|
|
366
|
+
headers = {"Accept": "application/json", **await self._get_auth_headers()}
|
|
305
367
|
if payload is not None:
|
|
306
368
|
headers["Content-Type"] = "application/json"
|
|
307
|
-
if self._integration_key:
|
|
308
|
-
headers["X-Integration-Key"] = self._integration_key
|
|
309
369
|
|
|
310
370
|
if method.lower() not in {"post", "put", "delete"}:
|
|
311
371
|
msg = f"Unsupported write method: {method}"
|
|
@@ -255,6 +255,49 @@ 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
|
+
|
|
258
301
|
async def test_client_error_raises_communication_error(self) -> None:
|
|
259
302
|
session = MagicMock(spec=aiohttp.ClientSession)
|
|
260
303
|
mock_ctx = MagicMock()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|