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 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
+ Metadata-Version: 2.4
2
+ Name: python-daynest
3
+ Version: 0.1.0
4
+ Summary: Python client library for the Daynest API
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: aiohttp>=3.9
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any