python-daynest 0.1.11__tar.gz → 2026.11.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-2026.11.2/.python-version +1 -0
- python_daynest-2026.11.2/PKG-INFO +7 -0
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/pyproject.toml +13 -8
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/src/daynest/client.py +49 -20
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/src/daynest/models.py +9 -8
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/tests/test_client.py +91 -6
- python_daynest-2026.11.2/uv.lock +593 -0
- python_daynest-0.1.11/PKG-INFO +0 -7
- python_daynest-0.1.11/uv.lock +0 -686
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/.gitignore +0 -0
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/src/daynest/__init__.py +9 -9
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/src/daynest/exceptions.py +0 -0
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/tests/__init__.py +0 -0
- {python_daynest-0.1.11 → python_daynest-2026.11.2}/tests/test_package.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-daynest"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2026.11.2"
|
|
4
4
|
description = "Python client library for the Daynest API"
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
6
|
license = { text = "MIT" }
|
|
7
7
|
dependencies = [
|
|
8
|
-
"aiohttp>=3.
|
|
8
|
+
"aiohttp>=3.14.5",
|
|
9
9
|
]
|
|
10
10
|
|
|
11
11
|
[dependency-groups]
|
|
12
12
|
dev = [
|
|
13
|
-
"pytest==9.
|
|
14
|
-
"pytest-asyncio==1.4.
|
|
15
|
-
"ruff==0.
|
|
16
|
-
"pyright==1.1.
|
|
13
|
+
"pytest==9.2.0",
|
|
14
|
+
"pytest-asyncio==1.4.1.post1",
|
|
15
|
+
"ruff==0.17.4",
|
|
16
|
+
"pyright==1.1.414",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
[build-system]
|
|
@@ -25,13 +25,18 @@ packages = ["src/daynest"]
|
|
|
25
25
|
|
|
26
26
|
[tool.pytest.ini_options]
|
|
27
27
|
asyncio_mode = "auto"
|
|
28
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
28
29
|
testpaths = ["tests"]
|
|
29
30
|
markers = ["unit: fast, isolated unit tests"]
|
|
30
31
|
|
|
31
32
|
[tool.pyright]
|
|
32
33
|
include = ["src"]
|
|
33
|
-
pythonVersion = "3.
|
|
34
|
+
pythonVersion = "3.14"
|
|
34
35
|
typeCheckingMode = "standard"
|
|
35
36
|
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
target-version = "py314"
|
|
39
|
+
line-length = 120
|
|
40
|
+
|
|
36
41
|
[tool.ruff.lint]
|
|
37
42
|
per-file-ignores = { "__init__.py" = ["F401"] }
|
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import logging
|
|
6
|
-
import json
|
|
7
|
-
import inspect
|
|
8
5
|
import asyncio
|
|
9
6
|
import copy
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
10
|
import time
|
|
11
11
|
import weakref
|
|
12
12
|
from collections.abc import Awaitable, Callable, Mapping
|
|
13
13
|
from datetime import date, timedelta
|
|
14
|
-
from typing import Any, TypeVar
|
|
15
|
-
from urllib.parse import urlencode, urljoin
|
|
14
|
+
from typing import Any, Self, TypeVar
|
|
15
|
+
from urllib.parse import urlencode, urljoin, urlsplit
|
|
16
16
|
|
|
17
17
|
import aiohttp
|
|
18
18
|
|
|
@@ -46,6 +46,29 @@ logger = logging.getLogger(__name__)
|
|
|
46
46
|
ModelT = TypeVar("ModelT")
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def _checked_oidc_url(value: object) -> str:
|
|
50
|
+
"""Validate a plain HTTPS endpoint, allowing HTTP only on loopback."""
|
|
51
|
+
if not isinstance(value, str):
|
|
52
|
+
raise TypeError("Sign-in endpoints must be URLs")
|
|
53
|
+
if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in value):
|
|
54
|
+
raise ValueError("Sign-in endpoints must not contain whitespace or controls")
|
|
55
|
+
url = urlsplit(value)
|
|
56
|
+
# Accessing port also rejects malformed and out-of-range port numbers.
|
|
57
|
+
_ = url.port
|
|
58
|
+
if (
|
|
59
|
+
url.scheme not in {"https", "http"}
|
|
60
|
+
or (url.scheme == "http" and url.hostname not in {"localhost", "127.0.0.1", "::1"})
|
|
61
|
+
or not url.hostname
|
|
62
|
+
or url.username is not None
|
|
63
|
+
or url.password is not None
|
|
64
|
+
or "?" in value
|
|
65
|
+
or "#" in value
|
|
66
|
+
or "\\" in value
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Sign-in endpoints must be plain HTTPS URLs")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
49
72
|
class DaynestClient:
|
|
50
73
|
"""Async HTTP client for the Daynest API.
|
|
51
74
|
|
|
@@ -98,7 +121,7 @@ class DaynestClient:
|
|
|
98
121
|
self._enable_sse = enable_sse
|
|
99
122
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
100
123
|
|
|
101
|
-
async def __aenter__(self) ->
|
|
124
|
+
async def __aenter__(self) -> Self:
|
|
102
125
|
self._context_depth += 1
|
|
103
126
|
if self._owned_session and self._session is None:
|
|
104
127
|
self._session = aiohttp.ClientSession()
|
|
@@ -162,16 +185,16 @@ class DaynestClient:
|
|
|
162
185
|
body = await response.json(content_type=None)
|
|
163
186
|
if not isinstance(body, Mapping):
|
|
164
187
|
msg = "Token endpoint response was not a JSON object"
|
|
165
|
-
raise
|
|
188
|
+
raise TypeError(msg)
|
|
166
189
|
token = body.get("access_token")
|
|
167
190
|
if not isinstance(token, str) or not token:
|
|
168
191
|
msg = "Token endpoint returned no access_token"
|
|
169
192
|
raise ValueError(msg)
|
|
170
193
|
try:
|
|
171
194
|
expires_in = int(body.get("expires_in", 300))
|
|
172
|
-
except
|
|
195
|
+
except TypeError, ValueError:
|
|
173
196
|
expires_in = 300
|
|
174
|
-
except ValueError as err:
|
|
197
|
+
except (TypeError, ValueError) as err:
|
|
175
198
|
msg = f"Token endpoint returned malformed payload: {err}"
|
|
176
199
|
raise DaynestAuthError(msg) from err
|
|
177
200
|
self._cached_token = token
|
|
@@ -204,22 +227,24 @@ class DaynestClient:
|
|
|
204
227
|
|
|
205
228
|
Returns (authorization_url, token_url) or None if the backend is
|
|
206
229
|
unreachable or the response is malformed. Does not require authentication.
|
|
230
|
+
Endpoints must use HTTPS (HTTP is allowed on loopback for development)
|
|
231
|
+
and share one host and port. Discovery redirects are never followed.
|
|
207
232
|
"""
|
|
208
233
|
url = urljoin(f"{base_url.strip().rstrip('/')}/", "api/auth/oidc-config")
|
|
209
234
|
owned = session is None
|
|
210
235
|
if owned:
|
|
211
236
|
session = aiohttp.ClientSession()
|
|
212
237
|
try:
|
|
213
|
-
async with session.get(url, timeout=REQUEST_TIMEOUT) as resp:
|
|
238
|
+
async with session.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=False) as resp:
|
|
214
239
|
if resp.status == 200:
|
|
215
240
|
data = await resp.json(content_type=None)
|
|
216
241
|
if isinstance(data, dict):
|
|
217
|
-
auth_url = data.get("authorization_url")
|
|
218
|
-
token_url = data.get("token_url")
|
|
219
|
-
if
|
|
242
|
+
auth_url = _checked_oidc_url(data.get("authorization_url"))
|
|
243
|
+
token_url = _checked_oidc_url(data.get("token_url"))
|
|
244
|
+
if urlsplit(auth_url).netloc.lower() == urlsplit(token_url).netloc.lower():
|
|
220
245
|
return auth_url, token_url
|
|
221
|
-
except Exception:
|
|
222
|
-
|
|
246
|
+
except Exception:
|
|
247
|
+
logger.debug("Failed to fetch OIDC discovery config from %s", url, exc_info=True)
|
|
223
248
|
finally:
|
|
224
249
|
if owned:
|
|
225
250
|
await session.close()
|
|
@@ -443,8 +468,7 @@ class DaynestClient:
|
|
|
443
468
|
return [
|
|
444
469
|
item
|
|
445
470
|
for item in payload
|
|
446
|
-
if item.get("module_key") == "shopping_list"
|
|
447
|
-
and str(item.get("linked_ref", "")) == str(shopping_list_id)
|
|
471
|
+
if item.get("module_key") == "shopping_list" and str(item.get("linked_ref", "")) == str(shopping_list_id)
|
|
448
472
|
]
|
|
449
473
|
|
|
450
474
|
async def async_create_shopping_item(
|
|
@@ -523,7 +547,9 @@ class DaynestClient:
|
|
|
523
547
|
|
|
524
548
|
async def async_list_routine_templates(self) -> list[RoutineTemplate]:
|
|
525
549
|
"""List routine templates."""
|
|
526
|
-
payload = await self._cached_call(
|
|
550
|
+
payload = await self._cached_call(
|
|
551
|
+
"async_list_routine_templates", lambda: self._request_list("/api/templates/routines")
|
|
552
|
+
)
|
|
527
553
|
return [RoutineTemplate.from_dict(item) for item in payload]
|
|
528
554
|
|
|
529
555
|
async def async_create_routine_template(
|
|
@@ -651,7 +677,9 @@ class DaynestClient:
|
|
|
651
677
|
|
|
652
678
|
async def async_export_calendar_ics(self) -> bytes:
|
|
653
679
|
"""Export calendar iCalendar bytes."""
|
|
654
|
-
return await self._cached_call(
|
|
680
|
+
return await self._cached_call(
|
|
681
|
+
"async_export_calendar_ics", lambda: self._request_bytes("/api/calendar/export.ics")
|
|
682
|
+
)
|
|
655
683
|
|
|
656
684
|
async def async_listen(
|
|
657
685
|
self,
|
|
@@ -697,7 +725,7 @@ class DaynestClient:
|
|
|
697
725
|
payload = {}
|
|
698
726
|
try:
|
|
699
727
|
await callback(event_name, payload)
|
|
700
|
-
except Exception:
|
|
728
|
+
except Exception:
|
|
701
729
|
logger.exception(
|
|
702
730
|
"SSE callback failed for event %s with payload %r",
|
|
703
731
|
event_name,
|
|
@@ -732,6 +760,7 @@ class DaynestClient:
|
|
|
732
760
|
callback: Callable[[dict[str, Any]], Awaitable[None]],
|
|
733
761
|
) -> Callable[[], None]:
|
|
734
762
|
"""Subscribe to today SSE updates and return an unsubscribe callable."""
|
|
763
|
+
|
|
735
764
|
async def _today_callback(event_name: str, payload: dict[str, Any]) -> None:
|
|
736
765
|
if event_name == "today_updated":
|
|
737
766
|
await callback(payload)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from datetime import date, datetime, time
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
8
8
|
from uuid import UUID
|
|
9
9
|
|
|
10
10
|
from daynest.exceptions import DaynestMalformedResponseError
|
|
@@ -67,11 +67,8 @@ class DaynestDashboard:
|
|
|
67
67
|
return cls(payload=payload)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
ModelT = TypeVar("ModelT")
|
|
71
|
-
|
|
72
|
-
|
|
73
70
|
@dataclass(slots=True, frozen=True)
|
|
74
|
-
class DaynestApiResponse
|
|
71
|
+
class DaynestApiResponse[ModelT]:
|
|
75
72
|
"""Typed response wrapper carrying contract metadata."""
|
|
76
73
|
|
|
77
74
|
data: ModelT
|
|
@@ -343,8 +340,12 @@ class CalendarEvent:
|
|
|
343
340
|
item_id=int(_require(payload, "item_id", context="calendar event")),
|
|
344
341
|
title=str(_require(payload, "title", context="calendar event")),
|
|
345
342
|
status=str(payload.get("status", "")),
|
|
346
|
-
scheduled_at=_parse_datetime(scheduled_at_raw, field="scheduled_at")
|
|
347
|
-
|
|
343
|
+
scheduled_at=_parse_datetime(scheduled_at_raw, field="scheduled_at")
|
|
344
|
+
if scheduled_at_raw is not None
|
|
345
|
+
else None,
|
|
346
|
+
scheduled_date=_parse_date(scheduled_date_raw, field="scheduled_date")
|
|
347
|
+
if scheduled_date_raw is not None
|
|
348
|
+
else None,
|
|
348
349
|
detail=payload.get("detail") if isinstance(payload.get("detail"), str) else None,
|
|
349
350
|
module_key=payload.get("module_key") if isinstance(payload.get("module_key"), str) else None,
|
|
350
351
|
recurrence_hint=payload.get("recurrence_hint") if isinstance(payload.get("recurrence_hint"), str) else None,
|
|
@@ -396,7 +397,7 @@ class CalendarDay:
|
|
|
396
397
|
raise DaynestMalformedResponseError(msg)
|
|
397
398
|
return cls(
|
|
398
399
|
date=_parse_date(_require(payload, "date", context="calendar month day"), field="date"),
|
|
399
|
-
items=
|
|
400
|
+
items=(),
|
|
400
401
|
total=int(payload.get("total", 0)),
|
|
401
402
|
routines=int(payload.get("routines", 0)),
|
|
402
403
|
chores=int(payload.get("chores", 0)),
|
|
@@ -16,7 +16,17 @@ from daynest.exceptions import (
|
|
|
16
16
|
DaynestServerUnavailableError,
|
|
17
17
|
DaynestTimeoutError,
|
|
18
18
|
)
|
|
19
|
-
from daynest.models import
|
|
19
|
+
from daynest.models import (
|
|
20
|
+
CalendarDay,
|
|
21
|
+
CalendarEvent,
|
|
22
|
+
ChoreTemplate,
|
|
23
|
+
DaynestDashboard,
|
|
24
|
+
DaynestSummary,
|
|
25
|
+
MealPlan,
|
|
26
|
+
MealSlot,
|
|
27
|
+
PlannedItem,
|
|
28
|
+
RoutineTemplate,
|
|
29
|
+
)
|
|
20
30
|
|
|
21
31
|
VALID_SUMMARY_PAYLOAD = {
|
|
22
32
|
"sensor_daynest_chores_due": 2,
|
|
@@ -551,7 +561,9 @@ class TestDaynestClientWriteMethods:
|
|
|
551
561
|
session.post = MagicMock(return_value=response)
|
|
552
562
|
client = DaynestClient(base_url="https://api.example", integration_key="key", session=session)
|
|
553
563
|
|
|
554
|
-
result = await client.async_create_planned_item(
|
|
564
|
+
result = await client.async_create_planned_item(
|
|
565
|
+
title="Plan dinner", planned_for="2026-01-15", notes="With rice"
|
|
566
|
+
)
|
|
555
567
|
|
|
556
568
|
call_kwargs = session.post.call_args[1]
|
|
557
569
|
assert call_kwargs["json"] == {
|
|
@@ -777,11 +789,20 @@ class TestDaynestClientTypedMethods:
|
|
|
777
789
|
async def test_calendar_day_month_and_export_methods(self) -> None:
|
|
778
790
|
month_response = _make_mock_response(
|
|
779
791
|
200,
|
|
780
|
-
{
|
|
792
|
+
{
|
|
793
|
+
"year": 2026,
|
|
794
|
+
"month": 1,
|
|
795
|
+
"days": [
|
|
796
|
+
{"date": "2026-01-01", "total": 1, "routines": 0, "chores": 1, "medications": 0, "planned": 0}
|
|
797
|
+
],
|
|
798
|
+
},
|
|
781
799
|
)
|
|
782
800
|
day_response = _make_mock_response(
|
|
783
801
|
200,
|
|
784
|
-
{
|
|
802
|
+
{
|
|
803
|
+
"date": "2026-01-01",
|
|
804
|
+
"items": [{"item_type": "chore", "item_id": 1, "title": "Clean", "status": "pending"}],
|
|
805
|
+
},
|
|
785
806
|
)
|
|
786
807
|
ics_response = _make_mock_response(200, {})
|
|
787
808
|
ics_response.read = AsyncMock(return_value=b"BEGIN:VCALENDAR")
|
|
@@ -857,8 +878,9 @@ class TestDaynestClientCacheAndSSE:
|
|
|
857
878
|
def __aiter__(self):
|
|
858
879
|
async def _gen():
|
|
859
880
|
yield b"event: ping\n"
|
|
860
|
-
yield b
|
|
881
|
+
yield b'data: {"alive": true}\n'
|
|
861
882
|
yield b"\n"
|
|
883
|
+
|
|
862
884
|
return _gen()
|
|
863
885
|
|
|
864
886
|
response = _make_mock_response(200, {})
|
|
@@ -886,10 +908,11 @@ class TestDaynestClientCacheAndSSE:
|
|
|
886
908
|
async def _gen():
|
|
887
909
|
for line in self._lines:
|
|
888
910
|
yield line
|
|
911
|
+
|
|
889
912
|
return _gen()
|
|
890
913
|
|
|
891
914
|
response = _make_mock_response(200, {})
|
|
892
|
-
response.content = _Stream([b"event: today_updated\n", b
|
|
915
|
+
response.content = _Stream([b"event: today_updated\n", b'data: {"ping": true}\n', b"\n"])
|
|
893
916
|
session = MagicMock(spec=aiohttp.ClientSession)
|
|
894
917
|
session.get = MagicMock(return_value=response)
|
|
895
918
|
callback_called = asyncio.Event()
|
|
@@ -1102,3 +1125,65 @@ class TestDaynestClientShoppingLists:
|
|
|
1102
1125
|
await client.async_delete_shopping_item(1)
|
|
1103
1126
|
|
|
1104
1127
|
assert session.delete.call_args.args[0] == "https://api.daynest.example/api/planned-items/1"
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
class TestOidcDiscovery:
|
|
1131
|
+
@pytest.mark.parametrize("origin", ["https://auth.example", "http://localhost:8080", "http://[::1]:8080"])
|
|
1132
|
+
async def test_valid_endpoints(self, origin: str) -> None:
|
|
1133
|
+
auth, token = f"{origin}/auth", f"{origin}/token"
|
|
1134
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
1135
|
+
session.get.return_value = _make_mock_response(200, {"authorization_url": auth, "token_url": token})
|
|
1136
|
+
assert await DaynestClient.async_fetch_oidc_config("https://api.example/", session=session) == (auth, token)
|
|
1137
|
+
session.get.assert_called_once_with(
|
|
1138
|
+
"https://api.example/api/auth/oidc-config", timeout=aiohttp.ClientTimeout(total=10), allow_redirects=False
|
|
1139
|
+
)
|
|
1140
|
+
session.close.assert_not_called()
|
|
1141
|
+
|
|
1142
|
+
@pytest.mark.parametrize("field", ["authorization_url", "token_url"])
|
|
1143
|
+
@pytest.mark.parametrize(
|
|
1144
|
+
"value",
|
|
1145
|
+
[
|
|
1146
|
+
None,
|
|
1147
|
+
42,
|
|
1148
|
+
"",
|
|
1149
|
+
"/auth",
|
|
1150
|
+
"//auth.example/auth",
|
|
1151
|
+
"javascript:alert(1)",
|
|
1152
|
+
"http://auth.example/auth",
|
|
1153
|
+
"https://evil.example/auth",
|
|
1154
|
+
"https://user:pw@auth.example/auth",
|
|
1155
|
+
"https://@auth.example/auth",
|
|
1156
|
+
"https://auth.example/auth?next=evil",
|
|
1157
|
+
"https://auth.example/auth#fragment",
|
|
1158
|
+
"https://auth.example/auth?",
|
|
1159
|
+
"https://auth.example/auth#",
|
|
1160
|
+
"https://auth.example:444/auth",
|
|
1161
|
+
"https://auth.example:bad/auth",
|
|
1162
|
+
"https://auth.example:99999/auth",
|
|
1163
|
+
"https://[invalid/auth",
|
|
1164
|
+
" https://auth.example/auth",
|
|
1165
|
+
"https://auth.example/au\nth",
|
|
1166
|
+
"https://auth.example/\\evil",
|
|
1167
|
+
"http://127.1/auth",
|
|
1168
|
+
"http://2130706433/auth",
|
|
1169
|
+
],
|
|
1170
|
+
)
|
|
1171
|
+
async def test_rejects_unsafe_endpoints(self, field: str, value: object) -> None:
|
|
1172
|
+
payload = {"authorization_url": "https://auth.example/auth", "token_url": "https://auth.example/token"}
|
|
1173
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
1174
|
+
session.get.return_value = _make_mock_response(200, {**payload, field: value})
|
|
1175
|
+
assert await DaynestClient.async_fetch_oidc_config("https://api.example", session=session) is None
|
|
1176
|
+
|
|
1177
|
+
@pytest.mark.parametrize("status, payload", [(302, {}), (404, {}), (200, []), (200, {})])
|
|
1178
|
+
async def test_unusable_response(self, status: int, payload: object) -> None:
|
|
1179
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
1180
|
+
session.get.return_value = _make_mock_response(status, payload)
|
|
1181
|
+
assert await DaynestClient.async_fetch_oidc_config("https://api.example", session=session) is None
|
|
1182
|
+
assert session.get.call_args.kwargs["allow_redirects"] is False
|
|
1183
|
+
|
|
1184
|
+
async def test_owned_session_closed_on_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1185
|
+
session = MagicMock(spec=aiohttp.ClientSession)
|
|
1186
|
+
session.get.side_effect = aiohttp.ClientConnectionError()
|
|
1187
|
+
monkeypatch.setattr(aiohttp, "ClientSession", lambda: session)
|
|
1188
|
+
assert await DaynestClient.async_fetch_oidc_config("https://api.example") is None
|
|
1189
|
+
session.close.assert_awaited_once()
|