python-polsen 0.1.1__py3-none-any.whl → 0.2.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.
python_polsen/__init__.py CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
5
7
  from .exceptions import (
6
8
  APIError,
7
9
  AuthenticationError,
@@ -11,10 +13,13 @@ from .exceptions import (
11
13
  ValidationError,
12
14
  )
13
15
  from .polsen import PolsenClient
14
- from .types import FeelingType, OverviewMetric, TimelineMetric, TimeseriesType
16
+ from .types import FeelingType, OverviewMetric, TimeseriesType
15
17
 
16
- __version__ = "0.1.0"
17
- """Version of the installed ``python-polsen`` distribution."""
18
+ #: Version of the installed ``python-polsen`` distribution.
19
+ try:
20
+ __version__ = version("python-polsen")
21
+ except PackageNotFoundError:
22
+ __version__ = "0.0.0"
18
23
 
19
24
  __all__ = [
20
25
  "APIError",
@@ -25,7 +30,6 @@ __all__ = [
25
30
  "PolsenClient",
26
31
  "PolsenError",
27
32
  "RateLimitError",
28
- "TimelineMetric",
29
33
  "TimeseriesType",
30
34
  "ValidationError",
31
35
  "__version__",
@@ -42,27 +42,27 @@ def keys() -> str:
42
42
  return "v1/keys/users"
43
43
 
44
44
 
45
- def overview(user_id: str, stype: OverviewMetric) -> str:
45
+ def overview(user_id: str, overview_metric: OverviewMetric) -> str:
46
46
  """Build the path for ``GET /v1/overview/{user_id}/{stype}``.
47
47
 
48
48
  Args:
49
49
  user_id: Identifier of the user (``uid_...``).
50
- stype: Overview metric to retrieve.
50
+ overview_metric: Overview metric to retrieve.
51
51
 
52
52
  Returns:
53
53
  The relative request path.
54
54
  """
55
- return f"v1/overview/{user_id}/{stype}"
55
+ return f"v1/overview/{user_id}/{overview_metric}"
56
56
 
57
57
 
58
- def feelings(user_id: str, ftype: FeelingType) -> str:
58
+ def feelings(user_id: str, feeling_type: FeelingType) -> str:
59
59
  """Build the path for ``GET /v1/feelings/{user_id}/{ftype}``.
60
60
 
61
61
  Args:
62
62
  user_id: Identifier of the user (``uid_...``).
63
- ftype: Feelings category (``DAY`` or ``SLEEP``).
63
+ feeling_type: Feelings category (``DAY`` or ``SLEEP``).
64
64
 
65
65
  Returns:
66
66
  The relative request path.
67
67
  """
68
- return f"v1/feelings/{user_id}/{ftype}"
68
+ return f"v1/feelings/{user_id}/{feeling_type}"
@@ -1,13 +1,13 @@
1
1
  """Route builders for the ``v1`` timeline endpoints.
2
2
 
3
- Covers high-frequency time-series paths plus the event paths re-exported from
3
+ Covers high-frequency time-series paths plus the event paths in
4
4
  :mod:`python_polsen.api.v1.timeline.events`.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
9
  from ....types import TimeseriesType
10
- from .events import *
10
+ from . import events
11
11
 
12
12
  __all__ = [
13
13
  "events",
@@ -17,14 +17,14 @@ __all__ = [
17
17
  _BASE = "v1/timeline/timeseries"
18
18
 
19
19
 
20
- def timeseries(user_id: str, mtype: TimeseriesType) -> str:
20
+ def timeseries(user_id: str, timeseries_type: TimeseriesType) -> str:
21
21
  """Build the path for ``GET /v1/timeline/timeseries/{user_id}/{mtype}``.
22
22
 
23
23
  Args:
24
24
  user_id: Identifier of the user (``uid_...``).
25
- mtype: Measurement type to retrieve.
25
+ timeseries_type: Measurement type to retrieve.
26
26
 
27
27
  Returns:
28
28
  The relative request path.
29
29
  """
30
- return f"{_BASE}/{user_id}/{mtype}"
30
+ return f"{_BASE}/{user_id}/{timeseries_type}"
@@ -3,6 +3,19 @@
3
3
  _BASE = "v1/timeline/events"
4
4
 
5
5
 
6
+ def _path(segment: str, user_id: str) -> str:
7
+ """Build the path for ``GET /v1/timeline/events/{segment}/{user_id}``.
8
+
9
+ Args:
10
+ segment: Event segment (e.g. ``sleep``).
11
+ user_id: Identifier of the user (``uid_...``).
12
+
13
+ Returns:
14
+ The relative request path.
15
+ """
16
+ return f"{_BASE}/{segment}/{user_id}"
17
+
18
+
6
19
  def sleep(user_id: str) -> str:
7
20
  """Build the path for ``GET /v1/timeline/events/sleep/{user_id}``.
8
21
 
@@ -12,7 +25,7 @@ def sleep(user_id: str) -> str:
12
25
  Returns:
13
26
  The relative request path.
14
27
  """
15
- return f"{_BASE}/sleep/{user_id}"
28
+ return _path("sleep", user_id)
16
29
 
17
30
 
18
31
  def checkins(user_id: str) -> str:
@@ -24,7 +37,7 @@ def checkins(user_id: str) -> str:
24
37
  Returns:
25
38
  The relative request path.
26
39
  """
27
- return f"{_BASE}/checkins/{user_id}"
40
+ return _path("checkins", user_id)
28
41
 
29
42
 
30
43
  def activities(user_id: str) -> str:
@@ -36,7 +49,7 @@ def activities(user_id: str) -> str:
36
49
  Returns:
37
50
  The relative request path.
38
51
  """
39
- return f"{_BASE}/activities/{user_id}"
52
+ return _path("activities", user_id)
40
53
 
41
54
 
42
55
  def timezones(user_id: str) -> str:
@@ -48,7 +61,7 @@ def timezones(user_id: str) -> str:
48
61
  Returns:
49
62
  The relative request path.
50
63
  """
51
- return f"{_BASE}/timezones/{user_id}"
64
+ return _path("timezones", user_id)
52
65
 
53
66
 
54
67
  def crown_presses(user_id: str) -> str:
@@ -60,4 +73,4 @@ def crown_presses(user_id: str) -> str:
60
73
  Returns:
61
74
  The relative request path.
62
75
  """
63
- return f"{_BASE}/crown_presses/{user_id}"
76
+ return _path("crown_presses", user_id)
python_polsen/const.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """Package-wide constants for the Polsen Health API client."""
2
2
 
3
- API_URL = "api.polsen.health"
3
+ API_HOST = "api.polsen.health"
4
4
  """Host of the production Polsen Health API (the ``servers`` entry in the spec)."""
@@ -6,6 +6,8 @@ members (``title``, ``detail``, ``code``, ``request_id``, ...) are attached to
6
6
  the exception instance as attributes.
7
7
  """
8
8
 
9
+ from http import HTTPStatus
10
+
9
11
 
10
12
  class PolsenError(Exception):
11
13
  """Base class for all Polsen API errors.
@@ -83,3 +85,29 @@ class RateLimitError(PolsenError):
83
85
 
84
86
  class APIError(PolsenError):
85
87
  """Raised for any other non-2xx HTTP response."""
88
+
89
+
90
+ _STATUS_TO_ERROR: dict[int, type[PolsenError]] = {
91
+ HTTPStatus.UNAUTHORIZED: AuthenticationError,
92
+ HTTPStatus.FORBIDDEN: AuthenticationError,
93
+ HTTPStatus.NOT_FOUND: NotFoundError,
94
+ HTTPStatus.UNPROCESSABLE_ENTITY: ValidationError,
95
+ HTTPStatus.TOO_MANY_REQUESTS: RateLimitError,
96
+ }
97
+
98
+
99
+ def error_for_status(
100
+ status: int,
101
+ body: dict[str, int | str],
102
+ ) -> PolsenError:
103
+ """Build the :class:`PolsenError` matching an HTTP status code.
104
+
105
+ Args:
106
+ status: HTTP status code returned by the API.
107
+ body: Decoded ``ProblemDetail`` JSON body.
108
+
109
+ Returns:
110
+ The error subclass mapped to ``status``, or :class:`APIError` when no
111
+ subclass applies.
112
+ """
113
+ return _STATUS_TO_ERROR.get(status, APIError)(body, status_code=status)
python_polsen/polsen.py CHANGED
@@ -9,9 +9,10 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import ssl
12
+ from collections.abc import Callable
12
13
  from dataclasses import dataclass, field
13
14
  from http import HTTPMethod, HTTPStatus
14
- from typing import Any, Self
15
+ from typing import Any, ClassVar, Self
15
16
 
16
17
  import certifi
17
18
  from aiohttp import ClientSession, ContentTypeError, TCPConnector
@@ -20,17 +21,26 @@ from yarl import URL
20
21
  from python_polsen.types import FeelingType, OverviewMetric, TimeseriesType
21
22
 
22
23
  from .api import v1
23
- from .const import API_URL
24
- from .exceptions import (
25
- APIError,
26
- AuthenticationError,
27
- NotFoundError,
28
- RateLimitError,
29
- ValidationError,
30
- )
24
+ from .const import API_HOST
25
+ from .exceptions import RateLimitError, error_for_status
31
26
  from .utils import retry_on_rate_limit
32
27
 
33
28
 
29
+ def _query_params(
30
+ start_date: str,
31
+ end_date: str,
32
+ days: int | None = None,
33
+ cursor: str | None = None,
34
+ ) -> dict[str, str | int]:
35
+ """Build the ``start_date``/``end_date``/``days``/``cursor`` query params."""
36
+ params: dict[str, str | int] = {"start_date": start_date, "end_date": end_date}
37
+ if days is not None:
38
+ params["days"] = days
39
+ if cursor is not None:
40
+ params["cursor"] = cursor
41
+ return params
42
+
43
+
34
44
  @dataclass
35
45
  class PolsenClient:
36
46
  """Async client for the Polsen Health API.
@@ -41,28 +51,31 @@ class PolsenClient:
41
51
  Attributes:
42
52
  api_key: API key sent in the ``X-API-KEY`` header.
43
53
  session: Optional pre-existing ``aiohttp`` session to reuse.
44
- base_url: Host of the API; defaults to :data:`~python_polsen.const.API_URL`.
54
+ host: Host of the API; defaults to :data:`~python_polsen.const.API_HOST`.
45
55
  api: Route-builder namespace, defaulting to :mod:`python_polsen.api.v1`.
46
56
  _close_session: Whether this client created (and must close) the session.
47
- _max_retries: Maximum number of retries after a rate-limit response.
57
+ _max_retries: Class-level maximum retries after a rate-limit response.
48
58
  """
49
59
 
50
60
  api_key: str = field(repr=False)
51
61
  session: ClientSession | None = None
52
- base_url: str = field(default=API_URL)
62
+ host: str = field(default=API_HOST)
53
63
  api: Any = field(default=v1, repr=False)
54
64
  _close_session: bool = False
55
- _max_retries: int = 3
65
+ _max_retries: ClassVar[int] = 3
56
66
  _lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False, init=False)
57
67
 
58
- @retry_on_rate_limit
68
+ @retry_on_rate_limit(_max_retries)
59
69
  async def _request(
60
- self, uri: str, *, headers: dict[str, str] | None = None, **kwargs
70
+ self, path: str, *, headers: dict[str, str] | None = None, **kwargs
61
71
  ) -> dict[str, Any]:
62
72
  """Perform an authenticated GET request and decode the JSON response.
63
73
 
74
+ Retries rate-limited requests up to ``_max_retries`` times before the
75
+ error propagates.
76
+
64
77
  Args:
65
- uri: Path to request, relative to ``base_url``.
78
+ path: Path to request, relative to ``host``.
66
79
  headers: Optional headers merged over the default API-key header.
67
80
  **kwargs: Forwarded to the underlying ``aiohttp`` request.
68
81
 
@@ -78,9 +91,9 @@ class PolsenClient:
78
91
  """
79
92
  url = URL.build(
80
93
  scheme="https",
81
- host=self.base_url,
94
+ host=self.host,
82
95
  port=443,
83
- ).joinpath(uri)
96
+ ).joinpath(path)
84
97
 
85
98
  _headers = {
86
99
  "X-API-KEY": f"{self.api_key}",
@@ -102,20 +115,13 @@ class PolsenClient:
102
115
  if 200 <= response.status < 300:
103
116
  return body or {}
104
117
 
105
- match response.status:
106
- case HTTPStatus.UNAUTHORIZED | HTTPStatus.FORBIDDEN:
107
- raise AuthenticationError(body, status_code=response.status)
108
- case HTTPStatus.NOT_FOUND:
109
- raise NotFoundError(body, status_code=response.status)
110
- case HTTPStatus.UNPROCESSABLE_ENTITY:
111
- raise ValidationError(body, status_code=response.status)
112
- case HTTPStatus.TOO_MANY_REQUESTS:
113
- retry_after = int(response.headers.get("Retry-After", 2))
114
- raise RateLimitError(
115
- body, status_code=response.status, retry_after=retry_after
116
- )
117
- case _:
118
- raise APIError(body, status_code=response.status)
118
+ if response.status == HTTPStatus.TOO_MANY_REQUESTS:
119
+ retry_after = int(response.headers.get("Retry-After", 2))
120
+ raise RateLimitError(
121
+ body, status_code=response.status, retry_after=retry_after
122
+ )
123
+
124
+ raise error_for_status(response.status, body)
119
125
 
120
126
  async def _get_session(self) -> ClientSession:
121
127
  """Return the shared session, creating one if necessary.
@@ -134,6 +140,22 @@ class PolsenClient:
134
140
  self._close_session = True
135
141
  return self.session
136
142
 
143
+ async def _get_event_page(
144
+ self, builder: Callable[[str], str], user_id: str, **kwargs
145
+ ) -> Any:
146
+ """Fetch a paginated timeline event page for a user.
147
+
148
+ Args:
149
+ builder: Route builder for the event segment (e.g. ``events.sleep``).
150
+ user_id: Identifier of the user (``uid_...``).
151
+ **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
152
+ ``start_date``, ``end_date``, ``days`` and ``cursor``.
153
+
154
+ Returns:
155
+ The decoded ``Page[...]`` envelope with events and ``next_cursor``.
156
+ """
157
+ return await self._request(builder(user_id), **kwargs)
158
+
137
159
  async def get_user_ids(self) -> list[str]:
138
160
  """List the users accessible with the configured API key.
139
161
 
@@ -159,7 +181,14 @@ class PolsenClient:
159
181
  return await self._request(self.api.user(user_id))
160
182
 
161
183
  async def get_timeseries(
162
- self, user_id: str, timeseries_type: TimeseriesType, **kwargs
184
+ self,
185
+ user_id: str,
186
+ timeseries_type: TimeseriesType,
187
+ *,
188
+ start_date: str,
189
+ end_date: str,
190
+ days: int | None = None,
191
+ cursor: str | None = None,
163
192
  ) -> Any:
164
193
  """Fetch high-frequency time-series data for a user.
165
194
 
@@ -168,104 +197,176 @@ class PolsenClient:
168
197
  Args:
169
198
  user_id: Identifier of the user (``uid_...``).
170
199
  timeseries_type: Measurement to fetch (e.g. ``HEART_RATE``).
171
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
172
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
200
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
201
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
202
+ days: Days per page (default 7, max 30).
203
+ cursor: Opaque cursor from a previous page.
173
204
 
174
205
  Returns:
175
206
  The columnar ``MeasurementJSONPage`` envelope (time-stamped series
176
207
  plus ``next_cursor``).
177
208
  """
178
209
  return await self._request(
179
- self.api.timeline.timeseries(user_id, timeseries_type), **kwargs
210
+ self.api.timeline.timeseries(user_id, timeseries_type),
211
+ params=_query_params(start_date, end_date, days, cursor),
180
212
  )
181
213
 
182
- async def get_sleep(self, user_id: str, **kwargs) -> Any:
214
+ async def get_sleep(
215
+ self,
216
+ user_id: str,
217
+ *,
218
+ start_date: str,
219
+ end_date: str,
220
+ days: int | None = None,
221
+ cursor: str | None = None,
222
+ ) -> Any:
183
223
  """Fetch paginated sleep events for a user.
184
224
 
185
225
  Calls ``GET /v1/timeline/events/sleep/{user_id}``.
186
226
 
187
227
  Args:
188
228
  user_id: Identifier of the user (``uid_...``).
189
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
190
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
229
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
230
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
231
+ days: Days per page (default 7, max 30).
232
+ cursor: Opaque cursor from a previous page.
191
233
 
192
234
  Returns:
193
235
  A ``Page[Sleep]`` envelope with sleep events and ``next_cursor``.
194
236
  """
195
- return await self._request(self.api.timeline.events.sleep(user_id), **kwargs)
237
+ return await self._get_event_page(
238
+ self.api.timeline.events.sleep,
239
+ user_id,
240
+ params=_query_params(start_date, end_date, days, cursor),
241
+ )
196
242
 
197
- async def get_activities(self, user_id: str, **kwargs) -> Any:
243
+ async def get_activities(
244
+ self,
245
+ user_id: str,
246
+ *,
247
+ start_date: str,
248
+ end_date: str,
249
+ days: int | None = None,
250
+ cursor: str | None = None,
251
+ ) -> Any:
198
252
  """Fetch paginated activity events for a user.
199
253
 
200
254
  Calls ``GET /v1/timeline/events/activities/{user_id}``.
201
255
 
202
256
  Args:
203
257
  user_id: Identifier of the user (``uid_...``).
204
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
205
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
258
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
259
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
260
+ days: Days per page (default 7, max 30).
261
+ cursor: Opaque cursor from a previous page.
206
262
 
207
263
  Returns:
208
264
  A ``Page[ActivityModel]`` envelope with activity events and
209
265
  ``next_cursor``.
210
266
  """
211
- return await self._request(
212
- self.api.timeline.events.activities(user_id), **kwargs
267
+ return await self._get_event_page(
268
+ self.api.timeline.events.activities,
269
+ user_id,
270
+ params=_query_params(start_date, end_date, days, cursor),
213
271
  )
214
272
 
215
- async def get_checkins(self, user_id: str, **kwargs) -> Any:
273
+ async def get_checkins(
274
+ self,
275
+ user_id: str,
276
+ *,
277
+ start_date: str,
278
+ end_date: str,
279
+ days: int | None = None,
280
+ cursor: str | None = None,
281
+ ) -> Any:
216
282
  """Fetch paginated check-in events for a user.
217
283
 
218
284
  Calls ``GET /v1/timeline/events/checkins/{user_id}``.
219
285
 
220
286
  Args:
221
287
  user_id: Identifier of the user (``uid_...``).
222
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
223
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
288
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
289
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
290
+ days: Days per page (default 7, max 30).
291
+ cursor: Opaque cursor from a previous page.
224
292
 
225
293
  Returns:
226
294
  A ``Page[CheckinModel]`` envelope with check-in events and
227
295
  ``next_cursor``.
228
296
  """
229
- return await self._request(self.api.timeline.events.checkins(user_id), **kwargs)
297
+ return await self._get_event_page(
298
+ self.api.timeline.events.checkins,
299
+ user_id,
300
+ params=_query_params(start_date, end_date, days, cursor),
301
+ )
230
302
 
231
- async def get_timezone(self, user_id: str, **kwargs) -> Any:
303
+ async def get_timezone(
304
+ self,
305
+ user_id: str,
306
+ *,
307
+ start_date: str,
308
+ end_date: str,
309
+ days: int | None = None,
310
+ cursor: str | None = None,
311
+ ) -> Any:
232
312
  """Fetch paginated timezone-change events for a user.
233
313
 
234
314
  Calls ``GET /v1/timeline/events/timezones/{user_id}``.
235
315
 
236
316
  Args:
237
317
  user_id: Identifier of the user (``uid_...``).
238
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
239
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
318
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
319
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
320
+ days: Days per page (default 7, max 30).
321
+ cursor: Opaque cursor from a previous page.
240
322
 
241
323
  Returns:
242
324
  A ``Page[TimezoneResponse]`` envelope with timezone events and
243
325
  ``next_cursor``.
244
326
  """
245
- return await self._request(
246
- self.api.timeline.events.timezones(user_id), **kwargs
327
+ return await self._get_event_page(
328
+ self.api.timeline.events.timezones,
329
+ user_id,
330
+ params=_query_params(start_date, end_date, days, cursor),
247
331
  )
248
332
 
249
- async def get_crown_presses(self, user_id: str, **kwargs) -> Any:
333
+ async def get_crown_presses(
334
+ self,
335
+ user_id: str,
336
+ *,
337
+ start_date: str,
338
+ end_date: str,
339
+ days: int | None = None,
340
+ cursor: str | None = None,
341
+ ) -> Any:
250
342
  """Fetch paginated crown-press events for a user.
251
343
 
252
344
  Calls ``GET /v1/timeline/events/crown_presses/{user_id}``.
253
345
 
254
346
  Args:
255
347
  user_id: Identifier of the user (``uid_...``).
256
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
257
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
348
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
349
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
350
+ days: Days per page (default 7, max 30).
351
+ cursor: Opaque cursor from a previous page.
258
352
 
259
353
  Returns:
260
354
  A ``Page[CrownPressResponse]`` envelope with crown-press events and
261
355
  ``next_cursor``.
262
356
  """
263
- return await self._request(
264
- self.api.timeline.events.crown_presses(user_id), **kwargs
357
+ return await self._get_event_page(
358
+ self.api.timeline.events.crown_presses,
359
+ user_id,
360
+ params=_query_params(start_date, end_date, days, cursor),
265
361
  )
266
362
 
267
363
  async def get_overview(
268
- self, user_id: str, overview_metric: OverviewMetric, **kwargs
364
+ self,
365
+ user_id: str,
366
+ overview_metric: OverviewMetric,
367
+ *,
368
+ start_date: str,
369
+ end_date: str,
269
370
  ) -> Any:
270
371
  """Fetch daily/typical statistics for one overview metric.
271
372
 
@@ -274,18 +375,26 @@ class PolsenClient:
274
375
  Args:
275
376
  user_id: Identifier of the user (``uid_...``).
276
377
  overview_metric: Metric to summarise (e.g. ``HRV``, ``RHR``).
277
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
278
- ``start_date`` and ``end_date``.
378
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
379
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
279
380
 
280
381
  Returns:
281
382
  A list of per-day metric objects for the requested range.
282
383
  """
283
384
  return await self._request(
284
- self.api.overview(user_id, overview_metric), **kwargs
385
+ self.api.overview(user_id, overview_metric),
386
+ params=_query_params(start_date, end_date),
285
387
  )
286
388
 
287
389
  async def get_feelings(
288
- self, user_id: str, feeling_type: FeelingType, **kwargs
390
+ self,
391
+ user_id: str,
392
+ feeling_type: FeelingType,
393
+ *,
394
+ start_date: str,
395
+ end_date: str,
396
+ days: int | None = None,
397
+ cursor: str | None = None,
289
398
  ) -> Any:
290
399
  """Fetch self-reported feelings for a user.
291
400
 
@@ -295,14 +404,19 @@ class PolsenClient:
295
404
  user_id: Identifier of the user (``uid_...``).
296
405
  feeling_type: ``DAY`` for waking-day feelings or ``SLEEP`` for the
297
406
  preceding night.
298
- **kwargs: Forwarded to :meth:`_request`; typically ``params`` with
299
- ``start_date``, ``end_date``, ``days`` and ``cursor``.
407
+ start_date: First date to include (ISO 8601 ``YYYY-MM-DD``).
408
+ end_date: Last date to include (ISO 8601 ``YYYY-MM-DD``).
409
+ days: Days per page (default 7, max 30).
410
+ cursor: Opaque cursor from a previous page.
300
411
 
301
412
  Returns:
302
413
  A ``Page[FeelingsModel]`` envelope with dated feeling values and
303
414
  ``next_cursor``.
304
415
  """
305
- return await self._request(self.api.feelings(user_id, feeling_type), **kwargs)
416
+ return await self._request(
417
+ self.api.feelings(user_id, feeling_type),
418
+ params=_query_params(start_date, end_date, days, cursor),
419
+ )
306
420
 
307
421
  async def close(self) -> None:
308
422
  """Close the underlying session if this client created it."""
python_polsen/types.py CHANGED
@@ -28,17 +28,6 @@ class OverviewMetric(StrEnum):
28
28
  SKIN_TEMPERATURE = "SKIN_TEMPERATURE"
29
29
 
30
30
 
31
- class TimelineMetric(StrEnum):
32
- """Top-level timeline category.
33
-
34
- Distinguishes high-frequency time-series measurements (``TIMESERIES``) from
35
- discrete timeline events such as sleep or check-ins (``EVENTS``).
36
- """
37
-
38
- TIMESERIES = "TIMESERIES"
39
- EVENTS = "EVENTS"
40
-
41
-
42
31
  class TimeseriesType(StrEnum):
43
32
  """Measurement type accepted by ``/v1/timeline/timeseries/{user_id}/{mtype}``.
44
33
 
python_polsen/utils.py CHANGED
@@ -9,21 +9,36 @@ from .exceptions import RateLimitError
9
9
 
10
10
 
11
11
  def retry_on_rate_limit(
12
- func: Callable[..., Coroutine[Any, Any, Any]],
13
- ) -> Callable[..., Coroutine[Any, Any, Any]]:
14
- """Decorator that retries an async method when a RateLimitError is caught."""
15
-
16
- @functools.wraps(func)
17
- async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
18
- """Await ``func``, retrying up to ``self._max_retries`` on rate limits."""
19
- attempts = 0
20
- while True:
21
- try:
22
- return await func(self, *args, **kwargs)
23
- except RateLimitError as e:
24
- if attempts >= self._max_retries:
25
- raise
26
- attempts += 1
27
- await asyncio.sleep(e.retry_after)
28
-
29
- return wrapper
12
+ max_retries: int,
13
+ ) -> Callable[
14
+ [Callable[..., Coroutine[Any, Any, Any]]],
15
+ Callable[..., Coroutine[Any, Any, Any]],
16
+ ]:
17
+ """Build a decorator that retries an async method on ``RateLimitError``.
18
+
19
+ Args:
20
+ max_retries: Number of retries allowed before the error propagates.
21
+
22
+ Returns:
23
+ A decorator wrapping an async method so that rate-limited calls are
24
+ re-attempted up to ``max_retries`` times.
25
+ """
26
+
27
+ def decorator(
28
+ func: Callable[..., Coroutine[Any, Any, Any]],
29
+ ) -> Callable[..., Coroutine[Any, Any, Any]]:
30
+ @functools.wraps(func)
31
+ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
32
+ attempts = 0
33
+ while True:
34
+ try:
35
+ return await func(self, *args, **kwargs)
36
+ except RateLimitError as e:
37
+ if attempts >= max_retries:
38
+ raise
39
+ attempts += 1
40
+ await asyncio.sleep(e.retry_after)
41
+
42
+ return wrapper
43
+
44
+ return decorator
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-polsen
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: An asynchronous Python client for the [Polsen Health API](https://docs.research-api.nowatch.com/), the service exposing physiological and contextual data collected by Polsen devices and processed by the Polsen App for scientific and research use.
5
5
  Author: Matt Moore
6
6
  Author-email: Matt Moore <matt.d.moore@outlook.com>
@@ -58,7 +58,8 @@ async def main() -> None:
58
58
  heart_rate = await client.get_timeseries(
59
59
  user_ids[0],
60
60
  TimeseriesType.HEART_RATE,
61
- params={"start_date": "2026-10-17", "end_date": "2026-10-17"},
61
+ start_date="2026-10-17",
62
+ end_date="2026-10-17",
62
63
  )
63
64
  print(user, heart_rate)
64
65
 
@@ -76,14 +77,14 @@ argument and call `await client.close()` manually.
76
77
  | --- | --- | --- |
77
78
  | `get_user_ids()` | `GET /v1/keys/users` | User ids (`uid_...`) reachable by the key |
78
79
  | `get_user(user_id)` | `GET /v1/user/{user_id}` | User profile |
79
- | `get_timeseries(user_id, timeseries_type, **kw)` | `GET /v1/timeline/timeseries/{user_id}/{mtype}` | Columnar `MeasurementJSONPage` |
80
- | `get_sleep(user_id, **kw)` | `GET /v1/timeline/events/sleep/{user_id}` | `Page[Sleep]` |
81
- | `get_activities(user_id, **kw)` | `GET /v1/timeline/events/activities/{user_id}` | `Page[ActivityModel]` |
82
- | `get_checkins(user_id, **kw)` | `GET /v1/timeline/events/checkins/{user_id}` | `Page[CheckinModel]` |
83
- | `get_timezone(user_id, **kw)` | `GET /v1/timeline/events/timezones/{user_id}` | `Page[TimezoneResponse]` |
84
- | `get_crown_presses(user_id, **kw)` | `GET /v1/timeline/events/crown_presses/{user_id}` | `Page[CrownPressResponse]` |
85
- | `get_overview(user_id, overview_metric, **kw)` | `GET /v1/overview/{user_id}/{stype}` | List of per-day metrics |
86
- | `get_feelings(user_id, feeling_type, **kw)` | `GET /v1/feelings/{user_id}/{ftype}` | `Page[FeelingsModel]` |
80
+ | `get_timeseries(user_id, timeseries_type, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/timeseries/{user_id}/{mtype}` | Columnar `MeasurementJSONPage` |
81
+ | `get_sleep(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/sleep/{user_id}` | `Page[Sleep]` |
82
+ | `get_activities(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/activities/{user_id}` | `Page[ActivityModel]` |
83
+ | `get_checkins(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/checkins/{user_id}` | `Page[CheckinModel]` |
84
+ | `get_timezone(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/timezones/{user_id}` | `Page[TimezoneResponse]` |
85
+ | `get_crown_presses(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/crown_presses/{user_id}` | `Page[CrownPressResponse]` |
86
+ | `get_overview(user_id, overview_metric, *, start_date, end_date)` | `GET /v1/overview/{user_id}/{stype}` | List of per-day metrics |
87
+ | `get_feelings(user_id, feeling_type, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/feelings/{user_id}/{ftype}` | `Page[FeelingsModel]` |
87
88
 
88
89
  ### Enums
89
90
 
@@ -99,22 +100,22 @@ members in `python_polsen.types`:
99
100
  ### Pagination
100
101
 
101
102
  Event and feelings endpoints return a `{ "data": [...], "next_cursor": ... }`
102
- envelope. Pass dates via the request `params`:
103
+ envelope. The `start_date` and `end_date` range is required; pass it as keyword
104
+ arguments:
103
105
 
104
106
  ```python
105
107
  page = await client.get_sleep(
106
108
  user_id,
107
- params={"start_date": "2026-10-01", "end_date": "2026-10-07"},
109
+ start_date="2026-10-01",
110
+ end_date="2026-10-07",
108
111
  )
109
112
 
110
- while page["next_cursor"]:
113
+ while page.get("next_cursor"):
111
114
  page = await client.get_sleep(
112
115
  user_id,
113
- params={
114
- "start_date": "2026-10-01",
115
- "end_date": "2026-10-07",
116
- "cursor": page["next_cursor"],
117
- },
116
+ start_date="2026-10-01",
117
+ end_date="2026-10-07",
118
+ cursor=page["next_cursor"],
118
119
  )
119
120
  ```
120
121
 
@@ -0,0 +1,14 @@
1
+ python_polsen/__init__.py,sha256=aeD4iHFcWplg-_9JrtlAcvcsGZYqe2kzWjO-YtIQ3Ws,816
2
+ python_polsen/api/__init__.py,sha256=8EQSD37JgvAFkVH_DgFrsBUAU6K8KTXWDvEIrQmb5Jc,309
3
+ python_polsen/api/v1/__init__.py,sha256=iQk-CWPbKeRI1794ZhOziJZo9pXpKqjWBPuhSuxFjpA,1601
4
+ python_polsen/api/v1/timeline/__init__.py,sha256=UirRK4UpAaseVDhx6Ml1Hre5ydLuf72YRvYugm58O1c,720
5
+ python_polsen/api/v1/timeline/events.py,sha256=2356rk_NfbCzk38aspY3Km3sI2gfsbDxDj_wlWXFEb8,1804
6
+ python_polsen/const.py,sha256=AIpu88XJ6OuYddw7j-pxeVA-Oeug9GpjjyBzPTmO7DM,179
7
+ python_polsen/exceptions.py,sha256=my7PNKOPapQ4v-1f4GegeljGMlFF8H5nrFLDKdfJeNc,3516
8
+ python_polsen/polsen.py,sha256=kDHrP7TRYd0m2LLDCgsqwIdMYJf_xlgrxvWF-jnKCW4,14625
9
+ python_polsen/types.py,sha256=PvB2MlrkoBQzgPG64_571cUhZBvYD2mVcY_CMZyVRzg,1754
10
+ python_polsen/utils.py,sha256=nWfqJnNA9A490rhecvrPDQ2LAute6I78hop3shD8loE,1278
11
+ python_polsen-0.2.0.dist-info/licenses/LICENSE,sha256=iO7aXqjI1CYxQXZ7G0B-Dyeo4XT37hPzNwJbsMfctic,1067
12
+ python_polsen-0.2.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
13
+ python_polsen-0.2.0.dist-info/METADATA,sha256=PcAIsHsQPDcLjcE90B5rQNx0JS_rvdConVJbwKVyeqI,5590
14
+ python_polsen-0.2.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- python_polsen/__init__.py,sha256=Uo_bZ0X-fN0P8iUaqtaB2JAVLbqbiGDMzX7yNmRgvL4,714
2
- python_polsen/api/__init__.py,sha256=8EQSD37JgvAFkVH_DgFrsBUAU6K8KTXWDvEIrQmb5Jc,309
3
- python_polsen/api/v1/__init__.py,sha256=IifCLWAuLNi00z9ADskRSLnMPRFi4LDJAbqdUXcKXgs,1550
4
- python_polsen/api/v1/timeline/__init__.py,sha256=adyCx9L_5VEbQhTS_1Rq7jSDOzYhZuXNOeg9hjEO9ds,705
5
- python_polsen/api/v1/timeline/events.py,sha256=GUpExYjA_2ZZ4iVU4AE5jb3MC2aWGYxCJ31SjYsMAKc,1482
6
- python_polsen/const.py,sha256=rK9ybyahbwP8foUcxZrYBmSzzEGPWYlcqBrDis7tZ-M,178
7
- python_polsen/exceptions.py,sha256=K6QW8EP0NNdo-MSCocSvKc9cTJNpqNgZ1sWM7w6wlvA,2713
8
- python_polsen/polsen.py,sha256=zA4fTpRmujljTpZB4t6vA996xKuZFY3Zrx6Y1LKTPw0,11499
9
- python_polsen/types.py,sha256=FKpZtlTW9S3fXwNyaj4OPNxBIQ2Xf2yZLRbfS0_I6SM,2035
10
- python_polsen/utils.py,sha256=Z1hFyffyk_SnSEI3atG6xQP32zgeFghsEBW8-YXn9jo,908
11
- python_polsen-0.1.1.dist-info/licenses/LICENSE,sha256=iO7aXqjI1CYxQXZ7G0B-Dyeo4XT37hPzNwJbsMfctic,1067
12
- python_polsen-0.1.1.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
13
- python_polsen-0.1.1.dist-info/entry_points.txt,sha256=pgvAjwejYhQBJIFHUysTDlLPBJ-Ol9ieHmrwgJ8TT3Q,54
14
- python_polsen-0.1.1.dist-info/METADATA,sha256=sOc10wNxqJ2eNC0WTVPzNtLJLPxtmQxJxcGbYq7zRGg,5285
15
- python_polsen-0.1.1.dist-info/RECORD,,
@@ -1,3 +0,0 @@
1
- [console_scripts]
2
- python-polsen = python_polsen:main
3
-