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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-daynest
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Python client library for the Daynest API
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-daynest"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "Python client library for the Daynest API"
5
5
  requires-python = ">=3.12"
6
6
  license = { text = "MIT" }
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from collections.abc import Callable
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