python-daynest 0.1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-daynest
3
- Version: 0.1.0
3
+ Version: 0.1.2
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.0"
3
+ version = "0.1.2"
4
4
  description = "Python client library for the Daynest API"
5
5
  requires-python = ">=3.12"
6
6
  license = { text = "MIT" }
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from collections.abc import Callable
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