python-daynest 0.1.0__py3-none-any.whl
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.
- daynest/__init__.py +27 -0
- daynest/client.py +340 -0
- daynest/exceptions.py +29 -0
- daynest/models.py +76 -0
- python_daynest-0.1.0.dist-info/METADATA +7 -0
- python_daynest-0.1.0.dist-info/RECORD +7 -0
- python_daynest-0.1.0.dist-info/WHEEL +4 -0
daynest/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Daynest Python client library."""
|
|
2
|
+
|
|
3
|
+
from daynest.client import DaynestClient
|
|
4
|
+
from daynest.exceptions import (
|
|
5
|
+
DaynestAuthError,
|
|
6
|
+
DaynestCommunicationError,
|
|
7
|
+
DaynestError,
|
|
8
|
+
DaynestMalformedResponseError,
|
|
9
|
+
DaynestNotFoundError,
|
|
10
|
+
DaynestServerUnavailableError,
|
|
11
|
+
DaynestTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
from daynest.models import DaynestApiResponse, DaynestDashboard, DaynestSummary
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DaynestClient",
|
|
17
|
+
"DaynestError",
|
|
18
|
+
"DaynestAuthError",
|
|
19
|
+
"DaynestCommunicationError",
|
|
20
|
+
"DaynestTimeoutError",
|
|
21
|
+
"DaynestServerUnavailableError",
|
|
22
|
+
"DaynestMalformedResponseError",
|
|
23
|
+
"DaynestNotFoundError",
|
|
24
|
+
"DaynestApiResponse",
|
|
25
|
+
"DaynestSummary",
|
|
26
|
+
"DaynestDashboard",
|
|
27
|
+
]
|
daynest/client.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Daynest async HTTP client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import date
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from daynest.exceptions import (
|
|
14
|
+
DaynestAuthError,
|
|
15
|
+
DaynestCommunicationError,
|
|
16
|
+
DaynestMalformedResponseError,
|
|
17
|
+
DaynestNotFoundError,
|
|
18
|
+
DaynestServerUnavailableError,
|
|
19
|
+
DaynestTimeoutError,
|
|
20
|
+
)
|
|
21
|
+
from daynest.models import DaynestApiResponse, DaynestDashboard, DaynestSummary
|
|
22
|
+
|
|
23
|
+
DEFAULT_API_BASE_URL = "http://localhost:8000"
|
|
24
|
+
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
ModelT = TypeVar("ModelT")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DaynestClient:
|
|
32
|
+
"""Async HTTP client for the Daynest API.
|
|
33
|
+
|
|
34
|
+
Supports two usage patterns:
|
|
35
|
+
|
|
36
|
+
Standalone (library owns the session)::
|
|
37
|
+
|
|
38
|
+
async with DaynestClient(base_url, integration_key) as client:
|
|
39
|
+
dashboard = await client.async_get_dashboard()
|
|
40
|
+
|
|
41
|
+
HA-compatible (caller supplies a shared session)::
|
|
42
|
+
|
|
43
|
+
client = DaynestClient(base_url, integration_key, session=hass_session)
|
|
44
|
+
dashboard = await client.async_get_dashboard()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
base_url: str | None = None,
|
|
50
|
+
integration_key: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
session: aiohttp.ClientSession | None = None,
|
|
53
|
+
password: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
if base_url is not None and not base_url.strip():
|
|
56
|
+
msg = "A base URL is required to initialize DaynestClient"
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
self._base_url = (base_url or DEFAULT_API_BASE_URL).strip().rstrip("/")
|
|
59
|
+
self._integration_key = integration_key if integration_key is not None else password
|
|
60
|
+
self._session = session
|
|
61
|
+
self._owned_session = session is None
|
|
62
|
+
self._context_depth = 0
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> DaynestClient:
|
|
65
|
+
self._context_depth += 1
|
|
66
|
+
if self._owned_session and self._session is None:
|
|
67
|
+
self._session = aiohttp.ClientSession()
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, *args: object) -> None:
|
|
71
|
+
self._context_depth -= 1
|
|
72
|
+
if self._owned_session and self._context_depth == 0 and self._session is not None:
|
|
73
|
+
await self._session.close()
|
|
74
|
+
self._session = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def has_integration_key(self) -> bool:
|
|
78
|
+
"""Return whether the client has an integration key configured."""
|
|
79
|
+
return bool(self._integration_key)
|
|
80
|
+
|
|
81
|
+
async def async_get_data(self) -> dict[str, Any]:
|
|
82
|
+
"""Fetch summary data as the coordinator's primary payload."""
|
|
83
|
+
response = await self.async_get_summary()
|
|
84
|
+
return response.data.payload
|
|
85
|
+
|
|
86
|
+
async def async_get_summary(self) -> DaynestApiResponse[DaynestSummary]:
|
|
87
|
+
"""Fetch and parse the integration summary endpoint."""
|
|
88
|
+
return await self._request_model(
|
|
89
|
+
path="/api/v1/integrations/home-assistant/summary",
|
|
90
|
+
parser=DaynestSummary.from_dict,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def async_get_dashboard(self) -> DaynestApiResponse[DaynestDashboard]:
|
|
94
|
+
"""Fetch and parse the integration dashboard endpoint."""
|
|
95
|
+
return await self._request_model(
|
|
96
|
+
path="/api/v1/integrations/home-assistant/dashboard",
|
|
97
|
+
parser=DaynestDashboard.from_dict,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def async_complete_task(self, chore_instance_id: int) -> dict[str, Any]:
|
|
101
|
+
"""Complete a chore instance by ID."""
|
|
102
|
+
return await self._post_action(
|
|
103
|
+
path="/api/v1/integrations/home-assistant/actions/complete-task",
|
|
104
|
+
payload={"chore_instance_id": chore_instance_id},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def async_snooze_task(self, chore_instance_id: int, days: int = 1) -> dict[str, Any]:
|
|
108
|
+
"""Reschedule a chore instance N days into the future."""
|
|
109
|
+
return await self._post_action(
|
|
110
|
+
path="/api/v1/integrations/home-assistant/actions/snooze-task",
|
|
111
|
+
payload={"chore_instance_id": chore_instance_id, "days": days},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def async_mark_medication_taken(self, medication_dose_id: int) -> dict[str, Any]:
|
|
115
|
+
"""Mark a medication dose as taken."""
|
|
116
|
+
return await self._post_action(
|
|
117
|
+
path="/api/v1/integrations/home-assistant/actions/mark-medication-taken",
|
|
118
|
+
payload={"medication_dose_id": medication_dose_id},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def async_skip_task(self, chore_instance_id: int) -> dict[str, Any]:
|
|
122
|
+
"""Skip a chore instance."""
|
|
123
|
+
return await self._post_action(
|
|
124
|
+
path="/api/v1/integrations/home-assistant/actions/skip-task",
|
|
125
|
+
payload={"chore_instance_id": chore_instance_id},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def async_skip_medication(self, medication_dose_id: int) -> dict[str, Any]:
|
|
129
|
+
"""Skip a medication dose."""
|
|
130
|
+
return await self._post_action(
|
|
131
|
+
path="/api/v1/integrations/home-assistant/actions/skip-medication",
|
|
132
|
+
payload={"medication_dose_id": medication_dose_id},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def async_mark_planned_done(self, planned_item_id: int) -> dict[str, Any]:
|
|
136
|
+
"""Mark a planned item as done."""
|
|
137
|
+
return await self._post_action(
|
|
138
|
+
path="/api/v1/integrations/home-assistant/actions/mark-planned-done",
|
|
139
|
+
payload={"planned_item_id": planned_item_id},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def async_create_planned_item(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
title: str,
|
|
146
|
+
planned_for: str,
|
|
147
|
+
notes: str | None = None,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
"""Create a planned item."""
|
|
150
|
+
return await self._post_action(
|
|
151
|
+
path="/api/v1/integrations/home-assistant/actions/create-planned-item",
|
|
152
|
+
payload={"title": title, "planned_for": planned_for, "notes": notes},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def async_update_planned_item(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
planned_item_id: int,
|
|
159
|
+
title: str,
|
|
160
|
+
planned_for: str,
|
|
161
|
+
is_done: bool,
|
|
162
|
+
notes: str | None = None,
|
|
163
|
+
module_key: str | None = None,
|
|
164
|
+
recurrence_hint: str | None = None,
|
|
165
|
+
linked_source: str | None = None,
|
|
166
|
+
linked_ref: str | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Update a planned item."""
|
|
169
|
+
return await self._put_action(
|
|
170
|
+
path=f"/api/v1/integrations/home-assistant/actions/update-planned-item/{planned_item_id}",
|
|
171
|
+
payload={
|
|
172
|
+
"title": title,
|
|
173
|
+
"planned_for": planned_for,
|
|
174
|
+
"is_done": is_done,
|
|
175
|
+
"notes": notes,
|
|
176
|
+
"module_key": module_key,
|
|
177
|
+
"recurrence_hint": recurrence_hint,
|
|
178
|
+
"linked_source": linked_source,
|
|
179
|
+
"linked_ref": linked_ref,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def async_delete_planned_item(self, planned_item_id: int) -> dict[str, Any]:
|
|
184
|
+
"""Delete a planned item."""
|
|
185
|
+
return await self._delete_action(
|
|
186
|
+
path=f"/api/v1/integrations/home-assistant/actions/delete-planned-item/{planned_item_id}",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def async_get_calendar(self, start: date, end: date) -> list[dict[str, Any]]:
|
|
190
|
+
"""Fetch calendar events for an inclusive date range."""
|
|
191
|
+
return await self._request_list(
|
|
192
|
+
f"/api/v1/integrations/home-assistant/calendar?start={start.isoformat()}&end={end.isoformat()}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _session_or_raise(self) -> aiohttp.ClientSession:
|
|
196
|
+
if self._session is None:
|
|
197
|
+
msg = "No active session — use 'async with DaynestClient(...) as client' or supply a session"
|
|
198
|
+
raise RuntimeError(msg)
|
|
199
|
+
return self._session
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _check_response_status(response: aiohttp.ClientResponse, path: str) -> None:
|
|
203
|
+
if response.status in (401, 403):
|
|
204
|
+
msg = f"Authentication failed with status {response.status}"
|
|
205
|
+
raise DaynestAuthError(msg)
|
|
206
|
+
if response.status == 404:
|
|
207
|
+
msg = f"Resource not found at {path}"
|
|
208
|
+
raise DaynestNotFoundError(msg)
|
|
209
|
+
if 500 <= response.status < 600:
|
|
210
|
+
msg = f"Backend unavailable (status {response.status})"
|
|
211
|
+
raise DaynestServerUnavailableError(msg)
|
|
212
|
+
response.raise_for_status()
|
|
213
|
+
|
|
214
|
+
async def _request_model(
|
|
215
|
+
self,
|
|
216
|
+
path: str,
|
|
217
|
+
parser: Callable[[dict[str, Any]], ModelT],
|
|
218
|
+
) -> DaynestApiResponse[ModelT]:
|
|
219
|
+
session = self._session_or_raise()
|
|
220
|
+
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
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
async with session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) as response:
|
|
227
|
+
self._check_response_status(response, path)
|
|
228
|
+
|
|
229
|
+
contract = response.headers.get("X-Integration-Contract")
|
|
230
|
+
payload = await response.json(content_type=None)
|
|
231
|
+
if not isinstance(payload, dict):
|
|
232
|
+
msg = "Malformed response payload: expected JSON object"
|
|
233
|
+
raise DaynestMalformedResponseError(msg)
|
|
234
|
+
|
|
235
|
+
model = parser(payload)
|
|
236
|
+
return DaynestApiResponse(data=model, integration_contract=contract)
|
|
237
|
+
|
|
238
|
+
except TimeoutError as err:
|
|
239
|
+
msg = f"Request timed out for endpoint {path}"
|
|
240
|
+
raise DaynestTimeoutError(msg) from err
|
|
241
|
+
except aiohttp.ClientConnectionError as err:
|
|
242
|
+
msg = f"Server unavailable while requesting endpoint {path}: {err}"
|
|
243
|
+
raise DaynestServerUnavailableError(msg) from err
|
|
244
|
+
except aiohttp.ClientError as err:
|
|
245
|
+
msg = f"Communication error while requesting endpoint {path}: {err}"
|
|
246
|
+
raise DaynestCommunicationError(msg) from err
|
|
247
|
+
except ValueError as err:
|
|
248
|
+
msg = f"Malformed JSON response for endpoint {path}: {err}"
|
|
249
|
+
raise DaynestMalformedResponseError(msg) from err
|
|
250
|
+
|
|
251
|
+
async def _request_list(self, path: str) -> list[dict[str, Any]]:
|
|
252
|
+
session = self._session_or_raise()
|
|
253
|
+
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
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
async with session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) as response:
|
|
260
|
+
self._check_response_status(response, path)
|
|
261
|
+
|
|
262
|
+
payload = await response.json(content_type=None)
|
|
263
|
+
if not isinstance(payload, list):
|
|
264
|
+
msg = "Malformed response payload: expected JSON array"
|
|
265
|
+
raise DaynestMalformedResponseError(msg)
|
|
266
|
+
result = []
|
|
267
|
+
for i, item in enumerate(payload):
|
|
268
|
+
if isinstance(item, dict):
|
|
269
|
+
result.append(item)
|
|
270
|
+
else:
|
|
271
|
+
logger.warning("Skipping non-dict item at index %d in response for %s: %r", i, path, item)
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
except TimeoutError as err:
|
|
275
|
+
msg = f"Request timed out for endpoint {path}"
|
|
276
|
+
raise DaynestTimeoutError(msg) from err
|
|
277
|
+
except aiohttp.ClientConnectionError as err:
|
|
278
|
+
msg = f"Server unavailable while requesting endpoint {path}: {err}"
|
|
279
|
+
raise DaynestServerUnavailableError(msg) from err
|
|
280
|
+
except aiohttp.ClientError as err:
|
|
281
|
+
msg = f"Communication error while requesting endpoint {path}: {err}"
|
|
282
|
+
raise DaynestCommunicationError(msg) from err
|
|
283
|
+
except ValueError as err:
|
|
284
|
+
msg = f"Malformed JSON response for endpoint {path}: {err}"
|
|
285
|
+
raise DaynestMalformedResponseError(msg) from err
|
|
286
|
+
|
|
287
|
+
async def _post_action(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
288
|
+
return await self._send_action("post", path=path, payload=payload)
|
|
289
|
+
|
|
290
|
+
async def _put_action(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
291
|
+
return await self._send_action("put", path=path, payload=payload)
|
|
292
|
+
|
|
293
|
+
async def _delete_action(self, path: str) -> dict[str, Any]:
|
|
294
|
+
return await self._send_action("delete", path=path)
|
|
295
|
+
|
|
296
|
+
async def _send_action(
|
|
297
|
+
self,
|
|
298
|
+
method: str,
|
|
299
|
+
path: str,
|
|
300
|
+
payload: dict[str, Any] | None = None,
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
session = self._session_or_raise()
|
|
303
|
+
url = urljoin(f"{self._base_url}/", path.lstrip("/"))
|
|
304
|
+
headers = {"Accept": "application/json"}
|
|
305
|
+
if payload is not None:
|
|
306
|
+
headers["Content-Type"] = "application/json"
|
|
307
|
+
if self._integration_key:
|
|
308
|
+
headers["X-Integration-Key"] = self._integration_key
|
|
309
|
+
|
|
310
|
+
if method.lower() not in {"post", "put", "delete"}:
|
|
311
|
+
msg = f"Unsupported write method: {method}"
|
|
312
|
+
raise ValueError(msg)
|
|
313
|
+
|
|
314
|
+
request = getattr(session, method.lower())
|
|
315
|
+
request_kwargs: dict[str, Any] = {"headers": headers, "timeout": REQUEST_TIMEOUT}
|
|
316
|
+
if payload is not None:
|
|
317
|
+
request_kwargs["json"] = payload
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
async with request(url, **request_kwargs) as response:
|
|
321
|
+
self._check_response_status(response, path)
|
|
322
|
+
|
|
323
|
+
result = await response.json(content_type=None)
|
|
324
|
+
if not isinstance(result, dict):
|
|
325
|
+
msg = "Malformed response payload: expected JSON object"
|
|
326
|
+
raise DaynestMalformedResponseError(msg)
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
except TimeoutError as err:
|
|
330
|
+
msg = f"Request timed out for endpoint {path}"
|
|
331
|
+
raise DaynestTimeoutError(msg) from err
|
|
332
|
+
except aiohttp.ClientConnectionError as err:
|
|
333
|
+
msg = f"Server unavailable while requesting endpoint {path}: {err}"
|
|
334
|
+
raise DaynestServerUnavailableError(msg) from err
|
|
335
|
+
except aiohttp.ClientError as err:
|
|
336
|
+
msg = f"Communication error while requesting endpoint {path}: {err}"
|
|
337
|
+
raise DaynestCommunicationError(msg) from err
|
|
338
|
+
except ValueError as err:
|
|
339
|
+
msg = f"Malformed JSON response for endpoint {path}: {err}"
|
|
340
|
+
raise DaynestMalformedResponseError(msg) from err
|
daynest/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Daynest client exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DaynestError(Exception):
|
|
5
|
+
"""Base exception for all Daynest client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DaynestCommunicationError(DaynestError):
|
|
9
|
+
"""Generic transport-level failure."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DaynestAuthError(DaynestError):
|
|
13
|
+
"""Authentication or authorization failure reported by the backend."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DaynestTimeoutError(DaynestCommunicationError):
|
|
17
|
+
"""Request timed out before receiving a response."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DaynestServerUnavailableError(DaynestCommunicationError):
|
|
21
|
+
"""Backend is unavailable or returned an upstream/server error."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DaynestMalformedResponseError(DaynestError):
|
|
25
|
+
"""Response payload could not be parsed into the expected model."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DaynestNotFoundError(DaynestError):
|
|
29
|
+
"""Requested resource does not exist (404)."""
|
daynest/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Daynest API response models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from daynest.exceptions import DaynestMalformedResponseError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True, frozen=True)
|
|
12
|
+
class DaynestSummary:
|
|
13
|
+
"""Typed model for the ``/summary`` payload."""
|
|
14
|
+
|
|
15
|
+
payload: dict[str, Any]
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, payload: dict[str, Any]) -> DaynestSummary:
|
|
19
|
+
"""Build a typed summary model from a raw JSON payload."""
|
|
20
|
+
if not isinstance(payload, dict):
|
|
21
|
+
msg = "Malformed summary payload: expected JSON object"
|
|
22
|
+
raise DaynestMalformedResponseError(msg)
|
|
23
|
+
required_keys = {
|
|
24
|
+
"sensor_daynest_chores_due",
|
|
25
|
+
"sensor_daynest_routines_open",
|
|
26
|
+
"sensor_daynest_medication_due",
|
|
27
|
+
"sensor_daynest_planned_remaining",
|
|
28
|
+
"sensor_daynest_overdue_count",
|
|
29
|
+
"sensor_daynest_next_medication",
|
|
30
|
+
}
|
|
31
|
+
missing_keys = sorted(required_keys.difference(payload))
|
|
32
|
+
if missing_keys:
|
|
33
|
+
missing = ", ".join(missing_keys)
|
|
34
|
+
msg = f"Malformed summary payload: missing required keys ({missing})"
|
|
35
|
+
raise DaynestMalformedResponseError(msg)
|
|
36
|
+
return cls(payload=payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True, frozen=True)
|
|
40
|
+
class DaynestDashboard:
|
|
41
|
+
"""Typed model for the ``/dashboard`` payload."""
|
|
42
|
+
|
|
43
|
+
payload: dict[str, Any]
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, payload: dict[str, Any]) -> DaynestDashboard:
|
|
47
|
+
"""Build a typed dashboard model from a raw JSON payload."""
|
|
48
|
+
if not isinstance(payload, dict):
|
|
49
|
+
msg = "Malformed dashboard payload: expected JSON object"
|
|
50
|
+
raise DaynestMalformedResponseError(msg)
|
|
51
|
+
required_keys = {
|
|
52
|
+
"for_date",
|
|
53
|
+
"due_today_count",
|
|
54
|
+
"overdue_count",
|
|
55
|
+
"planned_count",
|
|
56
|
+
"medication_due_count",
|
|
57
|
+
"completion_ratio",
|
|
58
|
+
"next_medication",
|
|
59
|
+
}
|
|
60
|
+
missing_keys = sorted(required_keys.difference(payload))
|
|
61
|
+
if missing_keys:
|
|
62
|
+
missing = ", ".join(missing_keys)
|
|
63
|
+
msg = f"Malformed dashboard payload: missing required keys ({missing})"
|
|
64
|
+
raise DaynestMalformedResponseError(msg)
|
|
65
|
+
return cls(payload=payload)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ModelT = TypeVar("ModelT")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, frozen=True)
|
|
72
|
+
class DaynestApiResponse(Generic[ModelT]):
|
|
73
|
+
"""Typed response wrapper carrying contract metadata."""
|
|
74
|
+
|
|
75
|
+
data: ModelT
|
|
76
|
+
integration_contract: str | None
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
daynest/__init__.py,sha256=3hDL2aOMNo1I2-qNhQ6QmEmZ9jvhfGIFF_Xq1L_YAOY,700
|
|
2
|
+
daynest/client.py,sha256=iJRN4umgnj9hhaDUxVr0BVAUvsI-omW48LwS5r1xOOc,14026
|
|
3
|
+
daynest/exceptions.py,sha256=qIv5pK_Mh7iVTycKttQ6W_-MRxnM4hD3U1fC4MZ4ThM,799
|
|
4
|
+
daynest/models.py,sha256=zoARfGQr5RAqff-z3SscD42pNegvijyBMd46CptSnFE,2525
|
|
5
|
+
python_daynest-0.1.0.dist-info/METADATA,sha256=aUnCFvIEevw24VTTR8IGzNf8vJTa65XygSWLuR68Dxs,174
|
|
6
|
+
python_daynest-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
python_daynest-0.1.0.dist-info/RECORD,,
|