python-polsen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_polsen/__init__.py +32 -0
- python_polsen/api/__init__.py +12 -0
- python_polsen/api/v1/__init__.py +68 -0
- python_polsen/api/v1/timeline/__init__.py +30 -0
- python_polsen/api/v1/timeline/events.py +63 -0
- python_polsen/const.py +4 -0
- python_polsen/exceptions.py +85 -0
- python_polsen/polsen.py +318 -0
- python_polsen/types.py +67 -0
- python_polsen/utils.py +29 -0
- python_polsen-0.1.0.dist-info/METADATA +147 -0
- python_polsen-0.1.0.dist-info/RECORD +14 -0
- python_polsen-0.1.0.dist-info/WHEEL +4 -0
- python_polsen-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""An asynchronous Python client for the Polsen Health API (v1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PolsenError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from .polsen import PolsenClient
|
|
14
|
+
from .types import FeelingType, OverviewMetric, TimelineMetric, TimeseriesType
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
"""Version of the installed ``python-polsen`` distribution."""
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"APIError",
|
|
21
|
+
"AuthenticationError",
|
|
22
|
+
"FeelingType",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
"OverviewMetric",
|
|
25
|
+
"PolsenClient",
|
|
26
|
+
"PolsenError",
|
|
27
|
+
"RateLimitError",
|
|
28
|
+
"TimelineMetric",
|
|
29
|
+
"TimeseriesType",
|
|
30
|
+
"ValidationError",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Versioned route builders for the Polsen Health API.
|
|
2
|
+
|
|
3
|
+
The subpackages expose pure functions that assemble request paths and contain
|
|
4
|
+
no HTTP logic; :mod:`python_polsen.api.v1` mirrors the ``v1`` paths described in
|
|
5
|
+
``openapi.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import v1
|
|
11
|
+
|
|
12
|
+
__all__ = ["v1"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Route builders for the ``v1`` Polsen Health API endpoints.
|
|
2
|
+
|
|
3
|
+
These helpers return request paths for endpoints with simple parameters;
|
|
4
|
+
timeline-specific paths live in :mod:`python_polsen.api.v1.timeline`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...types import FeelingType, OverviewMetric
|
|
10
|
+
from . import timeline
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"VERSION",
|
|
14
|
+
"feelings",
|
|
15
|
+
"keys",
|
|
16
|
+
"overview",
|
|
17
|
+
"timeline",
|
|
18
|
+
"user",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
VERSION = "v1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user(user_id: str) -> str:
|
|
25
|
+
"""Build the path for ``GET /v1/user/{user_id}`` (Get User Data).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
user_id: Identifier of the user (``uid_...``).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The relative request path.
|
|
32
|
+
"""
|
|
33
|
+
return f"v1/user/{user_id}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def keys() -> str:
|
|
37
|
+
"""Build the path for ``GET /v1/keys/users`` (Get Key Users).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The relative request path.
|
|
41
|
+
"""
|
|
42
|
+
return "v1/keys/users"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def overview(user_id: str, stype: OverviewMetric) -> str:
|
|
46
|
+
"""Build the path for ``GET /v1/overview/{user_id}/{stype}``.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
user_id: Identifier of the user (``uid_...``).
|
|
50
|
+
stype: Overview metric to retrieve.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The relative request path.
|
|
54
|
+
"""
|
|
55
|
+
return f"v1/overview/{user_id}/{stype}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def feelings(user_id: str, ftype: FeelingType) -> str:
|
|
59
|
+
"""Build the path for ``GET /v1/feelings/{user_id}/{ftype}``.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
user_id: Identifier of the user (``uid_...``).
|
|
63
|
+
ftype: Feelings category (``DAY`` or ``SLEEP``).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The relative request path.
|
|
67
|
+
"""
|
|
68
|
+
return f"v1/feelings/{user_id}/{ftype}"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Route builders for the ``v1`` timeline endpoints.
|
|
2
|
+
|
|
3
|
+
Covers high-frequency time-series paths plus the event paths re-exported from
|
|
4
|
+
:mod:`python_polsen.api.v1.timeline.events`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ....types import TimeseriesType
|
|
10
|
+
from .events import *
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"events",
|
|
14
|
+
"timeseries",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
_BASE = "v1/timeline/timeseries"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def timeseries(user_id: str, mtype: TimeseriesType) -> str:
|
|
21
|
+
"""Build the path for ``GET /v1/timeline/timeseries/{user_id}/{mtype}``.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
user_id: Identifier of the user (``uid_...``).
|
|
25
|
+
mtype: Measurement type to retrieve.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The relative request path.
|
|
29
|
+
"""
|
|
30
|
+
return f"{_BASE}/{user_id}/{mtype}"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Route builders for the ``v1`` timeline event endpoints."""
|
|
2
|
+
|
|
3
|
+
_BASE = "v1/timeline/events"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sleep(user_id: str) -> str:
|
|
7
|
+
"""Build the path for ``GET /v1/timeline/events/sleep/{user_id}``.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
user_id: Identifier of the user (``uid_...``).
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The relative request path.
|
|
14
|
+
"""
|
|
15
|
+
return f"{_BASE}/sleep/{user_id}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def checkins(user_id: str) -> str:
|
|
19
|
+
"""Build the path for ``GET /v1/timeline/events/checkins/{user_id}``.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
user_id: Identifier of the user (``uid_...``).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The relative request path.
|
|
26
|
+
"""
|
|
27
|
+
return f"{_BASE}/checkins/{user_id}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def activities(user_id: str) -> str:
|
|
31
|
+
"""Build the path for ``GET /v1/timeline/events/activities/{user_id}``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
user_id: Identifier of the user (``uid_...``).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The relative request path.
|
|
38
|
+
"""
|
|
39
|
+
return f"{_BASE}/activities/{user_id}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def timezones(user_id: str) -> str:
|
|
43
|
+
"""Build the path for ``GET /v1/timeline/events/timezones/{user_id}``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
user_id: Identifier of the user (``uid_...``).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The relative request path.
|
|
50
|
+
"""
|
|
51
|
+
return f"{_BASE}/timezones/{user_id}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def crown_presses(user_id: str) -> str:
|
|
55
|
+
"""Build the path for ``GET /v1/timeline/events/crown_presses/{user_id}``.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
user_id: Identifier of the user (``uid_...``).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The relative request path.
|
|
62
|
+
"""
|
|
63
|
+
return f"{_BASE}/crown_presses/{user_id}"
|
python_polsen/const.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Exception hierarchy for the Polsen Health API.
|
|
2
|
+
|
|
3
|
+
The API reports errors as RFC 7807 ``ProblemDetail`` documents. Each subclass
|
|
4
|
+
maps a distinct HTTP status to an exception type, and any additional problem
|
|
5
|
+
members (``title``, ``detail``, ``code``, ``request_id``, ...) are attached to
|
|
6
|
+
the exception instance as attributes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolsenError(Exception):
|
|
11
|
+
"""Base class for all Polsen API errors.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
status_code: HTTP status code returned by the API, if any.
|
|
15
|
+
title: Short, human-readable summary from the problem document.
|
|
16
|
+
detail: Explanation specific to this occurrence, if provided.
|
|
17
|
+
code: Stable, machine-readable error code, if provided.
|
|
18
|
+
request_id: Request identifier also returned in ``X-Request-Id``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
body: dict[str, int | str],
|
|
24
|
+
status_code: int | None,
|
|
25
|
+
):
|
|
26
|
+
"""Initialise the error from a decoded problem document.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
body: Decoded ``ProblemDetail`` JSON body; each key is copied onto
|
|
30
|
+
the instance as an attribute.
|
|
31
|
+
status_code: HTTP status code that produced the error.
|
|
32
|
+
"""
|
|
33
|
+
for k, v in body.items():
|
|
34
|
+
setattr(self, k, v)
|
|
35
|
+
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
|
|
38
|
+
title = getattr(self, "title", None)
|
|
39
|
+
detail = getattr(self, "detail", None)
|
|
40
|
+
if title is not None and detail is not None:
|
|
41
|
+
message = f"{title}: {detail}"
|
|
42
|
+
else:
|
|
43
|
+
message = f"Unknown Polsen API error: {body}"
|
|
44
|
+
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthenticationError(PolsenError):
|
|
49
|
+
"""Raised for HTTP 401/403 responses (missing or invalid API key)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NotFoundError(PolsenError):
|
|
53
|
+
"""Raised for HTTP 404 responses (unknown user or resource)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ValidationError(PolsenError):
|
|
57
|
+
"""Raised for HTTP 422 responses (invalid query or path parameters)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RateLimitError(PolsenError):
|
|
61
|
+
"""Raised for HTTP 429 responses, carrying how long to wait before retrying.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
retry_after: Seconds to wait, taken from the ``Retry-After`` header.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
body: dict[str, int | str],
|
|
70
|
+
status_code: int,
|
|
71
|
+
retry_after: int = 2,
|
|
72
|
+
):
|
|
73
|
+
"""Initialise the error and record the retry delay.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
body: Decoded ``ProblemDetail`` JSON body.
|
|
77
|
+
status_code: HTTP status code (expected to be 429).
|
|
78
|
+
retry_after: Seconds to wait before retrying the request.
|
|
79
|
+
"""
|
|
80
|
+
super().__init__(body, status_code=status_code)
|
|
81
|
+
self.retry_after = retry_after
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class APIError(PolsenError):
|
|
85
|
+
"""Raised for any other non-2xx HTTP response."""
|
python_polsen/polsen.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Asynchronous client for the Polsen Health API (v1).
|
|
2
|
+
|
|
3
|
+
:class:`PolsenClient` wraps the endpoints described in ``openapi.json`` behind
|
|
4
|
+
typed coroutines. Every request is authenticated with the ``X-API-KEY`` header,
|
|
5
|
+
and URLs are assembled from the route builders in :mod:`python_polsen.api.v1`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import ssl
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from http import HTTPMethod, HTTPStatus
|
|
14
|
+
from typing import Any, Self
|
|
15
|
+
|
|
16
|
+
import certifi
|
|
17
|
+
from aiohttp import ClientSession, ContentTypeError, TCPConnector
|
|
18
|
+
from yarl import URL
|
|
19
|
+
|
|
20
|
+
from python_polsen.types import FeelingType, OverviewMetric, TimeseriesType
|
|
21
|
+
|
|
22
|
+
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
|
+
)
|
|
31
|
+
from .utils import retry_on_rate_limit
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PolsenClient:
|
|
36
|
+
"""Async client for the Polsen Health API.
|
|
37
|
+
|
|
38
|
+
The client is an async context manager; use ``async with`` to guarantee the
|
|
39
|
+
underlying session is closed.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
api_key: API key sent in the ``X-API-KEY`` header.
|
|
43
|
+
session: Optional pre-existing ``aiohttp`` session to reuse.
|
|
44
|
+
base_url: Host of the API; defaults to :data:`~python_polsen.const.API_URL`.
|
|
45
|
+
api: Route-builder namespace, defaulting to :mod:`python_polsen.api.v1`.
|
|
46
|
+
_close_session: Whether this client created (and must close) the session.
|
|
47
|
+
_max_retries: Maximum number of retries after a rate-limit response.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
api_key: str = field(repr=False)
|
|
51
|
+
session: ClientSession | None = None
|
|
52
|
+
base_url: str = field(default=API_URL)
|
|
53
|
+
api: Any = field(default=v1, repr=False)
|
|
54
|
+
_close_session: bool = False
|
|
55
|
+
_max_retries: int = 3
|
|
56
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False, init=False)
|
|
57
|
+
|
|
58
|
+
@retry_on_rate_limit
|
|
59
|
+
async def _request(
|
|
60
|
+
self, uri: str, *, headers: dict[str, str] | None = None, **kwargs
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Perform an authenticated GET request and decode the JSON response.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
uri: Path to request, relative to ``base_url``.
|
|
66
|
+
headers: Optional headers merged over the default API-key header.
|
|
67
|
+
**kwargs: Forwarded to the underlying ``aiohttp`` request.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The decoded JSON body, or an empty dict for empty/non-JSON responses.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
AuthenticationError: On 401/403 responses.
|
|
74
|
+
NotFoundError: On 404 responses.
|
|
75
|
+
ValidationError: On 422 responses.
|
|
76
|
+
RateLimitError: On 429 responses.
|
|
77
|
+
APIError: On any other non-2xx response.
|
|
78
|
+
"""
|
|
79
|
+
url = URL.build(
|
|
80
|
+
scheme="https",
|
|
81
|
+
host=self.base_url,
|
|
82
|
+
port=443,
|
|
83
|
+
).joinpath(uri)
|
|
84
|
+
|
|
85
|
+
_headers = {
|
|
86
|
+
"X-API-KEY": f"{self.api_key}",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if headers:
|
|
90
|
+
_headers.update(headers)
|
|
91
|
+
|
|
92
|
+
session = await self._get_session()
|
|
93
|
+
|
|
94
|
+
async with session.request(
|
|
95
|
+
HTTPMethod.GET, url, headers=_headers, **kwargs
|
|
96
|
+
) as response:
|
|
97
|
+
try:
|
|
98
|
+
body = await response.json()
|
|
99
|
+
except ContentTypeError:
|
|
100
|
+
body = {}
|
|
101
|
+
|
|
102
|
+
if 200 <= response.status < 300:
|
|
103
|
+
return body or {}
|
|
104
|
+
|
|
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)
|
|
119
|
+
|
|
120
|
+
async def _get_session(self) -> ClientSession:
|
|
121
|
+
"""Return the shared session, creating one if necessary.
|
|
122
|
+
|
|
123
|
+
The lazily created session uses a ``certifi`` CA bundle for TLS
|
|
124
|
+
verification.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The active ``aiohttp`` client session.
|
|
128
|
+
"""
|
|
129
|
+
if self.session is None:
|
|
130
|
+
async with self._lock:
|
|
131
|
+
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
132
|
+
connector = TCPConnector(ssl=ssl_context)
|
|
133
|
+
self.session = ClientSession(connector=connector)
|
|
134
|
+
self._close_session = True
|
|
135
|
+
return self.session
|
|
136
|
+
|
|
137
|
+
async def get_user_ids(self) -> list[str]:
|
|
138
|
+
"""List the users accessible with the configured API key.
|
|
139
|
+
|
|
140
|
+
Calls ``GET /v1/keys/users``.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Encrypted user ids (``uid_...``) the key can access.
|
|
144
|
+
"""
|
|
145
|
+
response = await self._request(self.api.keys())
|
|
146
|
+
return response.get("user_ids", [])
|
|
147
|
+
|
|
148
|
+
async def get_user(self, user_id: str) -> dict[str, str | int]:
|
|
149
|
+
"""Fetch the profile of a single user.
|
|
150
|
+
|
|
151
|
+
Calls ``GET /v1/user/{user_id}``.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
user_id: Identifier of the user (``uid_...``).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The user's profile fields (name, age, sex, height, weight, ...).
|
|
158
|
+
"""
|
|
159
|
+
return await self._request(self.api.user(user_id))
|
|
160
|
+
|
|
161
|
+
async def get_timeseries(
|
|
162
|
+
self, user_id: str, timeseries_type: TimeseriesType, **kwargs
|
|
163
|
+
) -> Any:
|
|
164
|
+
"""Fetch high-frequency time-series data for a user.
|
|
165
|
+
|
|
166
|
+
Calls ``GET /v1/timeline/timeseries/{user_id}/{mtype}``.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
user_id: Identifier of the user (``uid_...``).
|
|
170
|
+
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``.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The columnar ``MeasurementJSONPage`` envelope (time-stamped series
|
|
176
|
+
plus ``next_cursor``).
|
|
177
|
+
"""
|
|
178
|
+
return await self._request(
|
|
179
|
+
self.api.timeline.timeseries(user_id, timeseries_type), **kwargs
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def get_sleep(self, user_id: str, **kwargs) -> Any:
|
|
183
|
+
"""Fetch paginated sleep events for a user.
|
|
184
|
+
|
|
185
|
+
Calls ``GET /v1/timeline/events/sleep/{user_id}``.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
user_id: Identifier of the user (``uid_...``).
|
|
189
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
190
|
+
``start_date``, ``end_date``, ``days`` and ``cursor``.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A ``Page[Sleep]`` envelope with sleep events and ``next_cursor``.
|
|
194
|
+
"""
|
|
195
|
+
return await self._request(self.api.timeline.events.sleep(user_id), **kwargs)
|
|
196
|
+
|
|
197
|
+
async def get_activities(self, user_id: str, **kwargs) -> Any:
|
|
198
|
+
"""Fetch paginated activity events for a user.
|
|
199
|
+
|
|
200
|
+
Calls ``GET /v1/timeline/events/activities/{user_id}``.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
user_id: Identifier of the user (``uid_...``).
|
|
204
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
205
|
+
``start_date``, ``end_date``, ``days`` and ``cursor``.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
A ``Page[ActivityModel]`` envelope with activity events and
|
|
209
|
+
``next_cursor``.
|
|
210
|
+
"""
|
|
211
|
+
return await self._request(
|
|
212
|
+
self.api.timeline.events.activities(user_id), **kwargs
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def get_checkins(self, user_id: str, **kwargs) -> Any:
|
|
216
|
+
"""Fetch paginated check-in events for a user.
|
|
217
|
+
|
|
218
|
+
Calls ``GET /v1/timeline/events/checkins/{user_id}``.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
user_id: Identifier of the user (``uid_...``).
|
|
222
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
223
|
+
``start_date``, ``end_date``, ``days`` and ``cursor``.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
A ``Page[CheckinModel]`` envelope with check-in events and
|
|
227
|
+
``next_cursor``.
|
|
228
|
+
"""
|
|
229
|
+
return await self._request(self.api.timeline.events.checkins(user_id), **kwargs)
|
|
230
|
+
|
|
231
|
+
async def get_timezone(self, user_id: str, **kwargs) -> Any:
|
|
232
|
+
"""Fetch paginated timezone-change events for a user.
|
|
233
|
+
|
|
234
|
+
Calls ``GET /v1/timeline/events/timezones/{user_id}``.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
user_id: Identifier of the user (``uid_...``).
|
|
238
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
239
|
+
``start_date``, ``end_date``, ``days`` and ``cursor``.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A ``Page[TimezoneResponse]`` envelope with timezone events and
|
|
243
|
+
``next_cursor``.
|
|
244
|
+
"""
|
|
245
|
+
return await self._request(
|
|
246
|
+
self.api.timeline.events.timezones(user_id), **kwargs
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def get_crown_presses(self, user_id: str, **kwargs) -> Any:
|
|
250
|
+
"""Fetch paginated crown-press events for a user.
|
|
251
|
+
|
|
252
|
+
Calls ``GET /v1/timeline/events/crown_presses/{user_id}``.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
user_id: Identifier of the user (``uid_...``).
|
|
256
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
257
|
+
``start_date``, ``end_date``, ``days`` and ``cursor``.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
A ``Page[CrownPressResponse]`` envelope with crown-press events and
|
|
261
|
+
``next_cursor``.
|
|
262
|
+
"""
|
|
263
|
+
return await self._request(
|
|
264
|
+
self.api.timeline.events.crown_presses(user_id), **kwargs
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def get_overview(
|
|
268
|
+
self, user_id: str, overview_metric: OverviewMetric, **kwargs
|
|
269
|
+
) -> Any:
|
|
270
|
+
"""Fetch daily/typical statistics for one overview metric.
|
|
271
|
+
|
|
272
|
+
Calls ``GET /v1/overview/{user_id}/{stype}``.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
user_id: Identifier of the user (``uid_...``).
|
|
276
|
+
overview_metric: Metric to summarise (e.g. ``HRV``, ``RHR``).
|
|
277
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
278
|
+
``start_date`` and ``end_date``.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
A list of per-day metric objects for the requested range.
|
|
282
|
+
"""
|
|
283
|
+
return await self._request(
|
|
284
|
+
self.api.overview(user_id, overview_metric), **kwargs
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
async def get_feelings(
|
|
288
|
+
self, user_id: str, feeling_type: FeelingType, **kwargs
|
|
289
|
+
) -> Any:
|
|
290
|
+
"""Fetch self-reported feelings for a user.
|
|
291
|
+
|
|
292
|
+
Calls ``GET /v1/feelings/{user_id}/{ftype}``.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
user_id: Identifier of the user (``uid_...``).
|
|
296
|
+
feeling_type: ``DAY`` for waking-day feelings or ``SLEEP`` for the
|
|
297
|
+
preceding night.
|
|
298
|
+
**kwargs: Forwarded to :meth:`_request`; typically ``params`` with
|
|
299
|
+
``start_date``, ``end_date``, ``days`` and ``cursor``.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
A ``Page[FeelingsModel]`` envelope with dated feeling values and
|
|
303
|
+
``next_cursor``.
|
|
304
|
+
"""
|
|
305
|
+
return await self._request(self.api.feelings(user_id, feeling_type), **kwargs)
|
|
306
|
+
|
|
307
|
+
async def close(self) -> None:
|
|
308
|
+
"""Close the underlying session if this client created it."""
|
|
309
|
+
if self.session and self._close_session:
|
|
310
|
+
await self.session.close()
|
|
311
|
+
|
|
312
|
+
async def __aenter__(self) -> Self:
|
|
313
|
+
"""Enter the async context manager and return the client."""
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
317
|
+
"""Close the client when leaving the async context manager."""
|
|
318
|
+
await self.close()
|
python_polsen/types.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Typed enumerations for the Polsen Health API request parameters.
|
|
2
|
+
|
|
3
|
+
The members mirror the ``enum`` values declared under ``components.schemas`` in
|
|
4
|
+
``openapi.json`` for the path and query parameters that select a metric,
|
|
5
|
+
timeline or feelings type. As :class:`~enum.StrEnum` subclasses their values
|
|
6
|
+
serialise directly into the request URL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OverviewMetric(StrEnum):
|
|
13
|
+
"""Statistical overview metric accepted by ``/v1/overview/{user_id}/{stype}``.
|
|
14
|
+
|
|
15
|
+
Passed as the ``stype`` path parameter; each member maps to one of the
|
|
16
|
+
overview schemas in ``openapi.json`` (``HRV``, ``RHR``, ``SLEEP_DURATION``,
|
|
17
|
+
...).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
HRV = "HRV"
|
|
21
|
+
SLEEP_DURATION = "SLEEP_DURATION"
|
|
22
|
+
SLEEP_REGULARITY = "SLEEP_REGULARITY"
|
|
23
|
+
INTENSE_ACTIVITY = "INTENSE_ACTIVITY"
|
|
24
|
+
RHR = "RHR"
|
|
25
|
+
STRESS_DURATION = "STRESS_DURATION"
|
|
26
|
+
STRESS_RECOVERY = "STRESS_RECOVERY"
|
|
27
|
+
STRESS_FREQUENCY = "STRESS_FREQUENCY"
|
|
28
|
+
SKIN_TEMPERATURE = "SKIN_TEMPERATURE"
|
|
29
|
+
|
|
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
|
+
class TimeseriesType(StrEnum):
|
|
43
|
+
"""Measurement type accepted by ``/v1/timeline/timeseries/{user_id}/{mtype}``.
|
|
44
|
+
|
|
45
|
+
Passed as the ``mtype`` path parameter.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
HEART_RATE = "HEART_RATE"
|
|
49
|
+
ACTIVITY_COUNT = "ACTIVITY_COUNT"
|
|
50
|
+
ACTIVITY_TYPE = "ACTIVITY_TYPE"
|
|
51
|
+
STEPS = "STEPS"
|
|
52
|
+
MOTION = "MOTION"
|
|
53
|
+
SKIN_TEMPERATURE = "SKIN_TEMPERATURE"
|
|
54
|
+
REACTIVITY_MONITOR = "REACTIVITY_MONITOR"
|
|
55
|
+
SKIN_CONDUCTANCE_LEVEL = "SKIN_CONDUCTANCE_LEVEL"
|
|
56
|
+
INTERBEAT_INTERVALS = "INTERBEAT_INTERVALS"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FeelingType(StrEnum):
|
|
60
|
+
"""Feelings category accepted by ``/v1/feelings/{user_id}/{ftype}``.
|
|
61
|
+
|
|
62
|
+
``DAY`` selects self-reported feelings for the waking day and ``SLEEP``
|
|
63
|
+
those recorded for the preceding night.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
DAY = "DAY"
|
|
67
|
+
SLEEP = "SLEEP"
|
python_polsen/utils.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: python-polsen
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author: Matt Moore
|
|
6
|
+
Author-email: Matt Moore <matt.d.moore@outlook.com>
|
|
7
|
+
Requires-Dist: aiohttp>=3.14.5
|
|
8
|
+
Requires-Dist: certifi>=2026.7.22
|
|
9
|
+
Requires-Dist: dotenv>=0.9.9
|
|
10
|
+
Requires-Dist: yarl>=1.25.1
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# python-polsen
|
|
15
|
+
|
|
16
|
+
An asynchronous Python client for the [Polsen Health API](https://docs.research-api.nowatch.com/)
|
|
17
|
+
(v1), the service exposing physiological and contextual data collected by Polsen
|
|
18
|
+
devices and processed by the Polsen App for scientific and research use.
|
|
19
|
+
|
|
20
|
+
The client wraps the endpoints described in the official
|
|
21
|
+
[OpenAPI specification](https://api.polsen.health/openapi.json). Every request is
|
|
22
|
+
authenticated with an `X-API-KEY` header, and responses are decoded to plain
|
|
23
|
+
Python objects.
|
|
24
|
+
|
|
25
|
+
Requires Python 3.14 or newer.
|
|
26
|
+
|
|
27
|
+
## Getting an API key
|
|
28
|
+
|
|
29
|
+
Access is gated by groups and user-controlled sharing, both managed in the
|
|
30
|
+
Polsen Health Together Dashboard. In short:
|
|
31
|
+
|
|
32
|
+
1. Create an account.
|
|
33
|
+
2. Enable data sharing in your account settings.
|
|
34
|
+
3. Create a group.
|
|
35
|
+
4. Generate an API key linked to that group.
|
|
36
|
+
|
|
37
|
+
See the [Data sharing & access](https://docs.research-api.nowatch.com/getting-started/data-sharing/)
|
|
38
|
+
guide for the full details, including how to request access to another user's
|
|
39
|
+
data.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
|
|
46
|
+
from python_polsen.polsen import PolsenClient
|
|
47
|
+
from python_polsen.types import TimeseriesType
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def main() -> None:
|
|
51
|
+
async with PolsenClient(api_key="YOUR_API_KEY") as client:
|
|
52
|
+
user_ids = await client.get_user_ids()
|
|
53
|
+
|
|
54
|
+
user = await client.get_user(user_ids[0])
|
|
55
|
+
|
|
56
|
+
heart_rate = await client.get_timeseries(
|
|
57
|
+
user_ids[0],
|
|
58
|
+
TimeseriesType.HEART_RATE,
|
|
59
|
+
params={"start_date": "2026-10-17", "end_date": "2026-10-17"},
|
|
60
|
+
)
|
|
61
|
+
print(user, heart_rate)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`PolsenClient` is an async context manager, so the underlying `aiohttp` session
|
|
68
|
+
is always closed on exit. You may also inject your own session via the `session`
|
|
69
|
+
argument and call `await client.close()` manually.
|
|
70
|
+
|
|
71
|
+
## Endpoints
|
|
72
|
+
|
|
73
|
+
| Method | Endpoint | Returns |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `get_user_ids()` | `GET /v1/keys/users` | User ids (`uid_...`) reachable by the key |
|
|
76
|
+
| `get_user(user_id)` | `GET /v1/user/{user_id}` | User profile |
|
|
77
|
+
| `get_timeseries(user_id, timeseries_type, **kw)` | `GET /v1/timeline/timeseries/{user_id}/{mtype}` | Columnar `MeasurementJSONPage` |
|
|
78
|
+
| `get_sleep(user_id, **kw)` | `GET /v1/timeline/events/sleep/{user_id}` | `Page[Sleep]` |
|
|
79
|
+
| `get_activities(user_id, **kw)` | `GET /v1/timeline/events/activities/{user_id}` | `Page[ActivityModel]` |
|
|
80
|
+
| `get_checkins(user_id, **kw)` | `GET /v1/timeline/events/checkins/{user_id}` | `Page[CheckinModel]` |
|
|
81
|
+
| `get_timezone(user_id, **kw)` | `GET /v1/timeline/events/timezones/{user_id}` | `Page[TimezoneResponse]` |
|
|
82
|
+
| `get_crown_presses(user_id, **kw)` | `GET /v1/timeline/events/crown_presses/{user_id}` | `Page[CrownPressResponse]` |
|
|
83
|
+
| `get_overview(user_id, overview_metric, **kw)` | `GET /v1/overview/{user_id}/{stype}` | List of per-day metrics |
|
|
84
|
+
| `get_feelings(user_id, feeling_type, **kw)` | `GET /v1/feelings/{user_id}/{ftype}` | `Page[FeelingsModel]` |
|
|
85
|
+
|
|
86
|
+
### Enums
|
|
87
|
+
|
|
88
|
+
Request parameters that select a metric or category are exposed as `StrEnum`
|
|
89
|
+
members in `python_polsen.types`:
|
|
90
|
+
|
|
91
|
+
| Enum | Used by |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `TimeseriesType` | `get_timeseries` (`HEART_RATE`, `STEPS`, `MOTION`, ...) |
|
|
94
|
+
| `OverviewMetric` | `get_overview` (`HRV`, `RHR`, `SLEEP_DURATION`, ...) |
|
|
95
|
+
| `FeelingType` | `get_feelings` (`DAY`, `SLEEP`) |
|
|
96
|
+
|
|
97
|
+
### Pagination
|
|
98
|
+
|
|
99
|
+
Event and feelings endpoints return a `{ "data": [...], "next_cursor": ... }`
|
|
100
|
+
envelope. Pass dates via the request `params`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
page = await client.get_sleep(
|
|
104
|
+
user_id,
|
|
105
|
+
params={"start_date": "2026-10-01", "end_date": "2026-10-07"},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
while page["next_cursor"]:
|
|
109
|
+
page = await client.get_sleep(
|
|
110
|
+
user_id,
|
|
111
|
+
params={
|
|
112
|
+
"start_date": "2026-10-01",
|
|
113
|
+
"end_date": "2026-10-07",
|
|
114
|
+
"cursor": page["next_cursor"],
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Time-series responses use a columnar `MeasurementJSONPage`
|
|
120
|
+
(`{"data": {"datetime": [...], "value": [...]}, "next_cursor": ...}`) rather
|
|
121
|
+
than a list of rows.
|
|
122
|
+
|
|
123
|
+
## Error handling
|
|
124
|
+
|
|
125
|
+
Non-2xx responses are raised as exceptions derived from `PolsenError`:
|
|
126
|
+
|
|
127
|
+
| Exception | HTTP status |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `AuthenticationError` | 401 / 403 |
|
|
130
|
+
| `NotFoundError` | 404 |
|
|
131
|
+
| `ValidationError` | 422 |
|
|
132
|
+
| `RateLimitError` | 429 |
|
|
133
|
+
| `APIError` | any other non-2xx |
|
|
134
|
+
|
|
135
|
+
Each exception carries the RFC 7807 problem members (`title`, `detail`, `code`,
|
|
136
|
+
`request_id`, ...) as attributes. `RateLimitError` also exposes `retry_after`;
|
|
137
|
+
requests are retried automatically up to `_max_retries` (default 3) before the
|
|
138
|
+
error propagates.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from python_polsen.exceptions import NotFoundError
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
user = await client.get_user("uid_unknown")
|
|
145
|
+
except NotFoundError as exc:
|
|
146
|
+
print(exc.code, exc.request_id)
|
|
147
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
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.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
|
|
12
|
+
python_polsen-0.1.0.dist-info/entry_points.txt,sha256=pgvAjwejYhQBJIFHUysTDlLPBJ-Ol9ieHmrwgJ8TT3Q,54
|
|
13
|
+
python_polsen-0.1.0.dist-info/METADATA,sha256=FUHa3FUcUq2nOoXhMOxh21x-1UejN1Wjj8RnLkMzKeU,5239
|
|
14
|
+
python_polsen-0.1.0.dist-info/RECORD,,
|