python-daynest 0.1.10__tar.gz → 0.1.12__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.
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-daynest
3
+ Version: 0.1.12
4
+ Summary: Python client library for the Daynest API
5
+ License: MIT
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: aiohttp>=3.14.5
@@ -1,19 +1,19 @@
1
1
  [project]
2
2
  name = "python-daynest"
3
- version = "0.1.10"
3
+ version = "0.1.12"
4
4
  description = "Python client library for the Daynest API"
5
- requires-python = ">=3.12"
5
+ requires-python = ">=3.14"
6
6
  license = { text = "MIT" }
7
7
  dependencies = [
8
- "aiohttp>=3.9",
8
+ "aiohttp>=3.14.5",
9
9
  ]
10
10
 
11
11
  [dependency-groups]
12
12
  dev = [
13
- "pytest==9.1.1",
14
- "pytest-asyncio==1.4.0",
15
- "ruff==0.15.22",
16
- "pyright==1.1.411",
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.12"
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) -> DaynestClient:
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 ValueError(msg)
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 (TypeError, ValueError):
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 isinstance(auth_url, str) and isinstance(token_url, str):
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: # noqa: BLE001
222
- pass
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("async_list_routine_templates", lambda: self._request_list("/api/templates/routines"))
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("async_export_calendar_ics", lambda: self._request_bytes("/api/calendar/export.ics"))
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: # noqa: BLE001
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, Generic, TypeVar
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(Generic[ModelT]):
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") if scheduled_at_raw is not None else None,
347
- scheduled_date=_parse_date(scheduled_date_raw, field="scheduled_date") if scheduled_date_raw is not None else None,
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=tuple(),
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 CalendarDay, CalendarEvent, ChoreTemplate, DaynestDashboard, DaynestSummary, MealPlan, MealSlot, PlannedItem, RoutineTemplate
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(title="Plan dinner", planned_for="2026-01-15", notes="With rice")
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
- {"year": 2026, "month": 1, "days": [{"date": "2026-01-01", "total": 1, "routines": 0, "chores": 1, "medications": 0, "planned": 0}]},
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
- {"date": "2026-01-01", "items": [{"item_type": "chore", "item_id": 1, "title": "Clean", "status": "pending"}]},
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"data: {\"alive\": true}\n"
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"data: {\"ping\": true}\n", b"\n"])
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()