python-polsen 0.1.1__tar.gz → 0.2.0__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_polsen-0.1.1 → python_polsen-0.2.0}/PKG-INFO +19 -18
- {python_polsen-0.1.1 → python_polsen-0.2.0}/README.md +18 -17
- {python_polsen-0.1.1 → python_polsen-0.2.0}/pyproject.toml +1 -4
- {python_polsen-0.1.1 → python_polsen-0.2.0}/pyproject.toml.orig +1 -4
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/__init__.py +8 -4
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/api/v1/__init__.py +6 -6
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/api/v1/timeline/__init__.py +5 -5
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/api/v1/timeline/events.py +18 -5
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/const.py +1 -1
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/exceptions.py +28 -0
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/polsen.py +181 -67
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/types.py +0 -11
- python_polsen-0.2.0/src/python_polsen/utils.py +44 -0
- python_polsen-0.1.1/src/python_polsen/utils.py +0 -29
- {python_polsen-0.1.1 → python_polsen-0.2.0}/LICENSE +0 -0
- {python_polsen-0.1.1 → python_polsen-0.2.0}/src/python_polsen/api/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-polsen
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
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,
|
|
80
|
-
| `get_sleep(user_id,
|
|
81
|
-
| `get_activities(user_id,
|
|
82
|
-
| `get_checkins(user_id,
|
|
83
|
-
| `get_timezone(user_id,
|
|
84
|
-
| `get_crown_presses(user_id,
|
|
85
|
-
| `get_overview(user_id, overview_metric,
|
|
86
|
-
| `get_feelings(user_id, feeling_type,
|
|
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.
|
|
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
|
-
|
|
109
|
+
start_date="2026-10-01",
|
|
110
|
+
end_date="2026-10-07",
|
|
108
111
|
)
|
|
109
112
|
|
|
110
|
-
while page
|
|
113
|
+
while page.get("next_cursor"):
|
|
111
114
|
page = await client.get_sleep(
|
|
112
115
|
user_id,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
|
@@ -43,7 +43,8 @@ async def main() -> None:
|
|
|
43
43
|
heart_rate = await client.get_timeseries(
|
|
44
44
|
user_ids[0],
|
|
45
45
|
TimeseriesType.HEART_RATE,
|
|
46
|
-
|
|
46
|
+
start_date="2026-10-17",
|
|
47
|
+
end_date="2026-10-17",
|
|
47
48
|
)
|
|
48
49
|
print(user, heart_rate)
|
|
49
50
|
|
|
@@ -61,14 +62,14 @@ argument and call `await client.close()` manually.
|
|
|
61
62
|
| --- | --- | --- |
|
|
62
63
|
| `get_user_ids()` | `GET /v1/keys/users` | User ids (`uid_...`) reachable by the key |
|
|
63
64
|
| `get_user(user_id)` | `GET /v1/user/{user_id}` | User profile |
|
|
64
|
-
| `get_timeseries(user_id, timeseries_type,
|
|
65
|
-
| `get_sleep(user_id,
|
|
66
|
-
| `get_activities(user_id,
|
|
67
|
-
| `get_checkins(user_id,
|
|
68
|
-
| `get_timezone(user_id,
|
|
69
|
-
| `get_crown_presses(user_id,
|
|
70
|
-
| `get_overview(user_id, overview_metric,
|
|
71
|
-
| `get_feelings(user_id, feeling_type,
|
|
65
|
+
| `get_timeseries(user_id, timeseries_type, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/timeseries/{user_id}/{mtype}` | Columnar `MeasurementJSONPage` |
|
|
66
|
+
| `get_sleep(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/sleep/{user_id}` | `Page[Sleep]` |
|
|
67
|
+
| `get_activities(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/activities/{user_id}` | `Page[ActivityModel]` |
|
|
68
|
+
| `get_checkins(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/checkins/{user_id}` | `Page[CheckinModel]` |
|
|
69
|
+
| `get_timezone(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/timezones/{user_id}` | `Page[TimezoneResponse]` |
|
|
70
|
+
| `get_crown_presses(user_id, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/timeline/events/crown_presses/{user_id}` | `Page[CrownPressResponse]` |
|
|
71
|
+
| `get_overview(user_id, overview_metric, *, start_date, end_date)` | `GET /v1/overview/{user_id}/{stype}` | List of per-day metrics |
|
|
72
|
+
| `get_feelings(user_id, feeling_type, *, start_date, end_date, days=None, cursor=None)` | `GET /v1/feelings/{user_id}/{ftype}` | `Page[FeelingsModel]` |
|
|
72
73
|
|
|
73
74
|
### Enums
|
|
74
75
|
|
|
@@ -84,22 +85,22 @@ members in `python_polsen.types`:
|
|
|
84
85
|
### Pagination
|
|
85
86
|
|
|
86
87
|
Event and feelings endpoints return a `{ "data": [...], "next_cursor": ... }`
|
|
87
|
-
envelope.
|
|
88
|
+
envelope. The `start_date` and `end_date` range is required; pass it as keyword
|
|
89
|
+
arguments:
|
|
88
90
|
|
|
89
91
|
```python
|
|
90
92
|
page = await client.get_sleep(
|
|
91
93
|
user_id,
|
|
92
|
-
|
|
94
|
+
start_date="2026-10-01",
|
|
95
|
+
end_date="2026-10-07",
|
|
93
96
|
)
|
|
94
97
|
|
|
95
|
-
while page
|
|
98
|
+
while page.get("next_cursor"):
|
|
96
99
|
page = await client.get_sleep(
|
|
97
100
|
user_id,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"cursor": page["next_cursor"],
|
|
102
|
-
},
|
|
101
|
+
start_date="2026-10-01",
|
|
102
|
+
end_date="2026-10-07",
|
|
103
|
+
cursor=page["next_cursor"],
|
|
103
104
|
)
|
|
104
105
|
```
|
|
105
106
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-polsen"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
license = "MIT"
|
|
5
5
|
license-files = ["LICENSE"]
|
|
6
6
|
description = "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."
|
|
@@ -17,9 +17,6 @@ dependencies = [
|
|
|
17
17
|
name = "Matt Moore"
|
|
18
18
|
email = "matt.d.moore@outlook.com"
|
|
19
19
|
|
|
20
|
-
[project.scripts]
|
|
21
|
-
python-polsen = "python_polsen:main"
|
|
22
|
-
|
|
23
20
|
[build-system]
|
|
24
21
|
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
25
22
|
build-backend = "uv_build"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-polsen"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
license = "MIT"
|
|
5
5
|
license-files = [
|
|
6
6
|
"LICENSE"
|
|
@@ -18,9 +18,6 @@ dependencies = [
|
|
|
18
18
|
"yarl>=1.25.1",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
-
[project.scripts]
|
|
22
|
-
python-polsen = "python_polsen:main"
|
|
23
|
-
|
|
24
21
|
[build-system]
|
|
25
22
|
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
26
23
|
build-backend = "uv_build"
|
|
@@ -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,
|
|
16
|
+
from .types import FeelingType, OverviewMetric, TimeseriesType
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
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
|
-
|
|
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}/{
|
|
55
|
+
return f"v1/overview/{user_id}/{overview_metric}"
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
def feelings(user_id: 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
|
-
|
|
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}/{
|
|
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
|
|
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 .
|
|
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,
|
|
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
|
-
|
|
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}/{
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
76
|
+
return _path("crown_presses", user_id)
|
|
@@ -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)
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
94
|
+
host=self.host,
|
|
82
95
|
port=443,
|
|
83
|
-
).joinpath(
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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
|
-
|
|
172
|
-
|
|
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),
|
|
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(
|
|
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
|
-
|
|
190
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
205
|
-
|
|
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.
|
|
212
|
-
self.api.timeline.events.activities
|
|
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(
|
|
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
|
-
|
|
223
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
239
|
-
|
|
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.
|
|
246
|
-
self.api.timeline.events.timezones
|
|
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(
|
|
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
|
-
|
|
257
|
-
|
|
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.
|
|
264
|
-
self.api.timeline.events.crown_presses
|
|
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,
|
|
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
|
-
|
|
278
|
-
|
|
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),
|
|
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,
|
|
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
|
-
|
|
299
|
-
|
|
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(
|
|
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."""
|
|
@@ -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
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Helpers shared by the Polsen API client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import RateLimitError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def retry_on_rate_limit(
|
|
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,29 +0,0 @@
|
|
|
1
|
-
"""Helpers shared by the Polsen API client."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import functools
|
|
5
|
-
from collections.abc import Callable, Coroutine
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from .exceptions import RateLimitError
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|