sweatstack 0.87.0__tar.gz → 0.88.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.
- {sweatstack-0.87.0 → sweatstack-0.88.0}/CHANGELOG.md +12 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/PKG-INFO +1 -1
- {sweatstack-0.87.0 → sweatstack-0.88.0}/pyproject.toml +1 -1
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/client.py +50 -14
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/dependencies.py +0 -9
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/streamlit.py +2 -2
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_access_token_cache.py +5 -5
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_longitudinal_mean_max_after.py +19 -6
- sweatstack-0.88.0/tests/test_write_timezone_validation.py +72 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/uv.lock +1 -1
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/settings.local.json +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/client.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.gitignore +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/.python-version +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/AGENTS.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/LICENSE +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/Makefile +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/README.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/docs/conf.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/docs/everything.rst +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/docs/index.rst +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/__init__.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_segmentation.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_sport_ost.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_teams.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_tests.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.87.0 → sweatstack-0.88.0}/tests/test_webhooks.py +0 -0
|
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [0.88.0] - 2026-08-06
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Breaking:** `create_trace`, `update_trace`, `create_test`, and `update_test` now require timezone-aware datetimes for `timestamp`, `start`, and `end`. A naive datetime raises `ValueError` before the request is sent. Attach a zone, e.g. `datetime(..., tzinfo=ZoneInfo("Europe/Amsterdam"))` or `datetime.now(timezone.utc)`; the offset is stored alongside the instant.
|
|
14
|
+
- `start`, `end`, and `timestamp` on responses are absolute UTC instants (ISO 8601 with a `Z` suffix), no longer a fixed per-record local offset. For wall-clock display use the companion `start_local` / `end_local` / `timestamp_local` fields, which are always present. The Streamlit activity selector now labels activities by their local date.
|
|
15
|
+
- Requires a SweatStack server with offset-based timezone handling (plan 033). Against an older server the aware-datetime writes still work, but responses keep the previous fixed-offset `start`/`end`/`timestamp`.
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
|
|
19
|
+
- Token refresh no longer sends a `tz` field. The server derives all timezone information from the offsets stored with each record, so the client has nothing to pass.
|
|
20
|
+
|
|
9
21
|
## [0.87.0] - 2026-06-30
|
|
10
22
|
|
|
11
23
|
### Changed
|
|
@@ -780,11 +780,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
780
780
|
|
|
781
781
|
return access_token, refresh_token
|
|
782
782
|
|
|
783
|
-
def _do_token_refresh(self,
|
|
783
|
+
def _do_token_refresh(self, refresh_token: str) -> str:
|
|
784
784
|
"""Exchange refresh token for a new access token.
|
|
785
785
|
|
|
786
786
|
Args:
|
|
787
|
-
tz: Timezone from the expired token's JWT claims.
|
|
788
787
|
refresh_token: The refresh token to use.
|
|
789
788
|
|
|
790
789
|
Returns:
|
|
@@ -799,7 +798,6 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
799
798
|
data={
|
|
800
799
|
"grant_type": "refresh_token",
|
|
801
800
|
"refresh_token": refresh_token,
|
|
802
|
-
"tz": tz,
|
|
803
801
|
"client_id": self.client_id,
|
|
804
802
|
"client_secret": self._client_secret.get_secret_value() if self._client_secret else None,
|
|
805
803
|
},
|
|
@@ -845,8 +843,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
845
843
|
"Call client.authenticate(force=True) to re-authenticate."
|
|
846
844
|
)
|
|
847
845
|
|
|
848
|
-
|
|
849
|
-
new_access_token = self._do_token_refresh(tz, refresh_token)
|
|
846
|
+
new_access_token = self._do_token_refresh(refresh_token)
|
|
850
847
|
|
|
851
848
|
# Update instance state
|
|
852
849
|
self._api_key = SecretStr(new_access_token)
|
|
@@ -1025,6 +1022,26 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1025
1022
|
out.append(value)
|
|
1026
1023
|
return out
|
|
1027
1024
|
|
|
1025
|
+
@staticmethod
|
|
1026
|
+
def _require_aware(value: datetime, param: str) -> datetime:
|
|
1027
|
+
"""Guard a write timestamp: it must carry an explicit UTC offset.
|
|
1028
|
+
|
|
1029
|
+
The API stores each timestamp as an absolute instant paired with its
|
|
1030
|
+
local offset, so it rejects naive datetimes with HTTP 422. Failing fast
|
|
1031
|
+
here names the offending argument and shows how to fix it, which the raw
|
|
1032
|
+
server error does not.
|
|
1033
|
+
|
|
1034
|
+
Raises:
|
|
1035
|
+
ValueError: If ``value`` is timezone-naive.
|
|
1036
|
+
"""
|
|
1037
|
+
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
|
|
1038
|
+
raise ValueError(
|
|
1039
|
+
f"{param} must be timezone-aware; got naive {value!r}. "
|
|
1040
|
+
f"Attach a zone, e.g. datetime(..., tzinfo=ZoneInfo('Europe/Amsterdam')) "
|
|
1041
|
+
f"or datetime.now(timezone.utc)."
|
|
1042
|
+
)
|
|
1043
|
+
return value
|
|
1044
|
+
|
|
1028
1045
|
def _get_activities_generator(
|
|
1029
1046
|
self,
|
|
1030
1047
|
*,
|
|
@@ -1805,7 +1822,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1805
1822
|
measurement values.
|
|
1806
1823
|
|
|
1807
1824
|
Args:
|
|
1808
|
-
timestamp: The date and time when the trace was recorded.
|
|
1825
|
+
timestamp: The date and time when the trace was recorded. Must be
|
|
1826
|
+
timezone-aware; the offset is stored alongside the instant.
|
|
1809
1827
|
lactate: Optional blood lactate concentration in mmol/L.
|
|
1810
1828
|
rpe: Optional rating of perceived exertion (typically on a scale of 1-10).
|
|
1811
1829
|
notes: Optional text notes associated with this trace.
|
|
@@ -1815,7 +1833,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1815
1833
|
tags: Optional list of tags to associate with this trace.
|
|
1816
1834
|
sport: Optional sport to associate with this trace.
|
|
1817
1835
|
test_id: Optional ID of a test to explicitly link this trace to.
|
|
1818
|
-
The link is independent of timestamp
|
|
1836
|
+
The link is independent of timestamp: a linked trace appears
|
|
1819
1837
|
in the test's traces list regardless of whether its timestamp
|
|
1820
1838
|
falls inside the test window.
|
|
1821
1839
|
|
|
@@ -1823,10 +1841,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1823
1841
|
TraceDetails: The created trace object with all details.
|
|
1824
1842
|
|
|
1825
1843
|
Raises:
|
|
1844
|
+
ValueError: If ``timestamp`` is timezone-naive.
|
|
1826
1845
|
SweatStackNotFoundError: If ``test_id`` references a test that
|
|
1827
1846
|
does not exist.
|
|
1828
1847
|
SweatStackAPIError: If the API request fails for any other reason.
|
|
1829
1848
|
"""
|
|
1849
|
+
self._require_aware(timestamp, "timestamp")
|
|
1830
1850
|
sport = self._enums_to_strings([sport])[0] if sport else None
|
|
1831
1851
|
with self._http_client() as client:
|
|
1832
1852
|
response = client.post(
|
|
@@ -1874,7 +1894,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1874
1894
|
|
|
1875
1895
|
Args:
|
|
1876
1896
|
trace_id: The unique identifier of the trace to update.
|
|
1877
|
-
timestamp: The date and time when the trace was recorded.
|
|
1897
|
+
timestamp: The date and time when the trace was recorded. Must be
|
|
1898
|
+
timezone-aware; the offset is stored alongside the instant.
|
|
1878
1899
|
lactate: Optional blood lactate concentration in mmol/L.
|
|
1879
1900
|
rpe: Optional rating of perceived exertion (typically on a scale of 1-10).
|
|
1880
1901
|
notes: Optional text notes associated with this trace.
|
|
@@ -1887,10 +1908,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1887
1908
|
Pass ``None`` (or omit) to leave the trace unlinked.
|
|
1888
1909
|
|
|
1889
1910
|
Raises:
|
|
1911
|
+
ValueError: If ``timestamp`` is timezone-naive.
|
|
1890
1912
|
SweatStackNotFoundError: If ``trace_id`` does not exist, or if
|
|
1891
1913
|
``test_id`` references a test that does not exist.
|
|
1892
1914
|
SweatStackAPIError: If the API request fails for any other reason.
|
|
1893
1915
|
"""
|
|
1916
|
+
self._require_aware(timestamp, "timestamp")
|
|
1894
1917
|
sport = self._enums_to_strings([sport])[0] if sport else None
|
|
1895
1918
|
with self._http_client() as client:
|
|
1896
1919
|
response = client.put(
|
|
@@ -2082,9 +2105,11 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2082
2105
|
|
|
2083
2106
|
Args:
|
|
2084
2107
|
sport: The sport for this test. Can be a Sport enum or string ID.
|
|
2085
|
-
start: The start time of the test.
|
|
2108
|
+
start: The start time of the test. Must be timezone-aware; the
|
|
2109
|
+
offset is stored alongside the instant.
|
|
2086
2110
|
title: Optional title for the test.
|
|
2087
|
-
end: Optional end time.
|
|
2111
|
+
end: Optional end time. Must be timezone-aware when given. Defaults
|
|
2112
|
+
to start + 3 hours server-side.
|
|
2088
2113
|
results: Optional structured test results (thresholds, capacities, etc.).
|
|
2089
2114
|
tags: Optional list of tags to associate with this test.
|
|
2090
2115
|
|
|
@@ -2092,8 +2117,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2092
2117
|
TestSummary: The created test.
|
|
2093
2118
|
|
|
2094
2119
|
Raises:
|
|
2120
|
+
ValueError: If ``start`` or ``end`` is timezone-naive.
|
|
2095
2121
|
SweatStackAPIError: If the API request fails.
|
|
2096
2122
|
"""
|
|
2123
|
+
self._require_aware(start, "start")
|
|
2124
|
+
if end is not None:
|
|
2125
|
+
self._require_aware(end, "end")
|
|
2097
2126
|
sport = self._enums_to_strings([sport])[0]
|
|
2098
2127
|
with self._http_client() as client:
|
|
2099
2128
|
response = client.post(
|
|
@@ -2130,16 +2159,22 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2130
2159
|
Args:
|
|
2131
2160
|
test_id: The unique identifier of the test to update.
|
|
2132
2161
|
sport: The sport for this test. Can be a Sport enum or string ID.
|
|
2133
|
-
start: The start time of the test.
|
|
2162
|
+
start: The start time of the test. Must be timezone-aware; the
|
|
2163
|
+
offset is stored alongside the instant.
|
|
2134
2164
|
title: Optional title for the test.
|
|
2135
|
-
end: Optional end time.
|
|
2165
|
+
end: Optional end time. Must be timezone-aware when given. Defaults
|
|
2166
|
+
to start + 3 hours server-side.
|
|
2136
2167
|
results: Optional structured test results (thresholds, capacities, etc.).
|
|
2137
2168
|
tags: Optional list of tags to associate with this test.
|
|
2138
2169
|
|
|
2139
2170
|
Raises:
|
|
2171
|
+
ValueError: If ``start`` or ``end`` is timezone-naive.
|
|
2140
2172
|
SweatStackNotFoundError: If the test does not exist.
|
|
2141
2173
|
SweatStackAPIError: If the API request fails for any other reason.
|
|
2142
2174
|
"""
|
|
2175
|
+
self._require_aware(start, "start")
|
|
2176
|
+
if end is not None:
|
|
2177
|
+
self._require_aware(end, "end")
|
|
2143
2178
|
sport = self._enums_to_strings([sport])[0]
|
|
2144
2179
|
with self._http_client() as client:
|
|
2145
2180
|
response = client.put(
|
|
@@ -2613,8 +2648,9 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2613
2648
|
"""Uploads activity files (CSV or FIT).
|
|
2614
2649
|
|
|
2615
2650
|
CSV files require the ``sport`` parameter and must contain a ``timestamp``
|
|
2616
|
-
column with ISO 8601 datetimes.
|
|
2617
|
-
``
|
|
2651
|
+
column with **offset-aware** ISO 8601 datetimes (e.g. ``...+02:00`` or
|
|
2652
|
+
``...Z``); naive timestamps are rejected during processing. FIT files
|
|
2653
|
+
include sport metadata so ``sport`` is optional for them.
|
|
2618
2654
|
|
|
2619
2655
|
Args:
|
|
2620
2656
|
files: A file path, or a list of file paths, to upload.
|
|
@@ -102,17 +102,10 @@ def _extract_expiry(access_token: str) -> datetime:
|
|
|
102
102
|
return datetime.fromtimestamp(body["exp"], tz=timezone.utc)
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
def _extract_timezone(access_token: str) -> str:
|
|
106
|
-
"""Extract timezone from JWT access token."""
|
|
107
|
-
body = decode_jwt_body(access_token)
|
|
108
|
-
return body.get("tz", "UTC")
|
|
109
|
-
|
|
110
|
-
|
|
111
105
|
def _refresh_access_token(
|
|
112
106
|
refresh_token: str,
|
|
113
107
|
client_id: str,
|
|
114
108
|
client_secret: str,
|
|
115
|
-
tz: str,
|
|
116
109
|
) -> tuple[str, str | None]:
|
|
117
110
|
"""Exchange a refresh token for a new access token.
|
|
118
111
|
|
|
@@ -133,7 +126,6 @@ def _refresh_access_token(
|
|
|
133
126
|
"refresh_token": refresh_token,
|
|
134
127
|
"client_id": client_id,
|
|
135
128
|
"client_secret": client_secret,
|
|
136
|
-
"tz": tz,
|
|
137
129
|
},
|
|
138
130
|
timeout=REFRESH_HTTP_TIMEOUT,
|
|
139
131
|
)
|
|
@@ -217,7 +209,6 @@ def _resolve_access_token(
|
|
|
217
209
|
refresh_token=refresh_token,
|
|
218
210
|
client_id=config.client_id,
|
|
219
211
|
client_secret=config.client_secret.get_secret_value(),
|
|
220
|
-
tz=_extract_timezone(access_token),
|
|
221
212
|
)
|
|
222
213
|
except Exception:
|
|
223
214
|
# Don't let a failed refresh strand a stale token in the cache.
|
|
@@ -74,7 +74,7 @@ class StreamlitAuth:
|
|
|
74
74
|
# Use the authenticated client
|
|
75
75
|
st.write("Welcome to SweatStack")
|
|
76
76
|
latest_activity = auth.client.get_latest_activity()
|
|
77
|
-
st.write(f"Latest activity: {latest_activity.sport} on {latest_activity.
|
|
77
|
+
st.write(f"Latest activity: {latest_activity.sport} on {latest_activity.start_local}")
|
|
78
78
|
|
|
79
79
|
# Switch between accessible users (admin feature)
|
|
80
80
|
with st.sidebar:
|
|
@@ -466,7 +466,7 @@ class StreamlitAuth:
|
|
|
466
466
|
selected_activity = st.selectbox(
|
|
467
467
|
"Select an activity",
|
|
468
468
|
activities,
|
|
469
|
-
format_func=lambda activity: f"{activity.
|
|
469
|
+
format_func=lambda activity: f"{activity.start_local.date().isoformat()} {activity.sport.label}",
|
|
470
470
|
)
|
|
471
471
|
return selected_activity
|
|
472
472
|
|
|
@@ -69,14 +69,14 @@ def configure_app():
|
|
|
69
69
|
# ---------------------------------------------------------------------------
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def _jwt(*, exp: float,
|
|
72
|
+
def _jwt(*, exp: float, sub: str = "user_1") -> str:
|
|
73
73
|
"""Build a JWT-shaped string whose body decodes to the given claims.
|
|
74
74
|
|
|
75
|
-
The signature segment is unused
|
|
76
|
-
body
|
|
75
|
+
The signature segment is unused (``decode_jwt_body`` only reads the
|
|
76
|
+
body), but we include a non-empty value so the token shape is
|
|
77
77
|
realistic.
|
|
78
78
|
"""
|
|
79
|
-
body = {"exp": int(exp), "
|
|
79
|
+
body = {"exp": int(exp), "sub": sub}
|
|
80
80
|
encoded = base64.urlsafe_b64encode(json.dumps(body).encode()).rstrip(b"=").decode()
|
|
81
81
|
return f"header.{encoded}.signature"
|
|
82
82
|
|
|
@@ -360,7 +360,7 @@ class TestResolveAccessToken:
|
|
|
360
360
|
call_count = 0
|
|
361
361
|
call_count_lock = threading.Lock()
|
|
362
362
|
|
|
363
|
-
def slow_refresh(*, refresh_token, client_id, client_secret
|
|
363
|
+
def slow_refresh(*, refresh_token, client_id, client_secret):
|
|
364
364
|
nonlocal call_count
|
|
365
365
|
with call_count_lock:
|
|
366
366
|
call_count += 1
|
|
@@ -47,16 +47,16 @@ def _call(client, **kwargs):
|
|
|
47
47
|
return result, http.get.call_args.kwargs["params"]
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def test_after_list_is_sent_and_default_power_is_duration_indexed(client):
|
|
51
51
|
result, params = _call(client, after=[0, 500])
|
|
52
52
|
# repeated query param
|
|
53
53
|
assert params["after"] == [0, 500]
|
|
54
|
-
#
|
|
55
|
-
|
|
54
|
+
# `by` unset for after+power resolves to `duration` server-side (0.85.0+): the frame is
|
|
55
|
+
# duration-indexed, with `after` a column (mirroring the no-after curve's index).
|
|
56
|
+
assert result.index.name == "duration"
|
|
57
|
+
assert "power" in result.columns
|
|
56
58
|
assert "after" in result.columns
|
|
57
59
|
assert set(result["after"].unique()) == {0.0, 500.0}
|
|
58
|
-
# postprocessed to standard dtypes
|
|
59
|
-
assert result["duration"].dtype == "timedelta64[ns]"
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def test_single_after_is_normalised_to_a_list(client):
|
|
@@ -94,9 +94,22 @@ def _index_free_by_duration_response() -> bytes:
|
|
|
94
94
|
return buf.getvalue()
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
def
|
|
97
|
+
def test_by_defaults_to_none_and_is_omitted(client):
|
|
98
|
+
# `by` unset -> omitted from the request so the server picks the orientation (0.85.0+),
|
|
99
|
+
# rather than the client forcing the deprecated value-indexed path.
|
|
98
100
|
_result, params = _call(client, after=[0, 500])
|
|
101
|
+
assert "by" not in params
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_after_with_explicit_intensity_is_metric_indexed_and_warns(client):
|
|
105
|
+
# The deprecated value-indexed orientation stays metric-indexed, is sent explicitly,
|
|
106
|
+
# and warns (preserves the coverage the old default-intensity test used to give).
|
|
107
|
+
with pytest.warns(DeprecationWarning):
|
|
108
|
+
result, params = _call(client, after=[0, 500], by="intensity")
|
|
99
109
|
assert params["by"] == "intensity"
|
|
110
|
+
assert result.index.name == "power"
|
|
111
|
+
assert "after" in result.columns
|
|
112
|
+
assert result["duration"].dtype == "timedelta64[ns]"
|
|
100
113
|
|
|
101
114
|
|
|
102
115
|
def test_by_duration_is_sent_and_duration_indexed(client):
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The write methods reject timezone-naive datetimes before hitting the API.
|
|
2
|
+
|
|
3
|
+
The server stores every timestamp as an absolute instant plus its local offset
|
|
4
|
+
and rejects naive datetimes with HTTP 422. The client guards the same contract
|
|
5
|
+
at the call site (``Client._require_aware``) so callers get a clear, immediate
|
|
6
|
+
error naming the offending argument instead of an opaque server response.
|
|
7
|
+
|
|
8
|
+
All offline: the guard runs before ``_http_client`` is ever touched, so a bare
|
|
9
|
+
``Client.__new__(Client)`` instance is enough.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from zoneinfo import ZoneInfo
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from sweatstack import Sport
|
|
18
|
+
from sweatstack.client import Client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
NAIVE = datetime(2026, 3, 15, 9, 0)
|
|
22
|
+
AWARE_UTC = datetime(2026, 3, 15, 9, 0, tzinfo=timezone.utc)
|
|
23
|
+
AWARE_ZONE = datetime(2026, 3, 15, 9, 0, tzinfo=ZoneInfo("Europe/Amsterdam"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestRequireAware:
|
|
27
|
+
def test_naive_rejected_with_helpful_message(self):
|
|
28
|
+
with pytest.raises(ValueError) as excinfo:
|
|
29
|
+
Client._require_aware(NAIVE, "timestamp")
|
|
30
|
+
message = str(excinfo.value)
|
|
31
|
+
assert "timestamp" in message
|
|
32
|
+
assert "timezone-aware" in message
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("value", [AWARE_UTC, AWARE_ZONE])
|
|
35
|
+
def test_aware_passes_through_unchanged(self, value: datetime):
|
|
36
|
+
assert Client._require_aware(value, "timestamp") is value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestTraceWritesRejectNaive:
|
|
40
|
+
def test_create_trace_rejects_naive_timestamp(self):
|
|
41
|
+
client = Client.__new__(Client)
|
|
42
|
+
with pytest.raises(ValueError, match="timestamp must be timezone-aware"):
|
|
43
|
+
client.create_trace(timestamp=NAIVE, lactate=2.0)
|
|
44
|
+
|
|
45
|
+
def test_update_trace_rejects_naive_timestamp(self):
|
|
46
|
+
client = Client.__new__(Client)
|
|
47
|
+
with pytest.raises(ValueError, match="timestamp must be timezone-aware"):
|
|
48
|
+
client.update_trace("trace_1", timestamp=NAIVE, lactate=2.0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestTestWritesRejectNaive:
|
|
52
|
+
def test_create_test_rejects_naive_start(self):
|
|
53
|
+
client = Client.__new__(Client)
|
|
54
|
+
with pytest.raises(ValueError, match="start must be timezone-aware"):
|
|
55
|
+
client.create_test(sport=Sport("cycling"), start=NAIVE)
|
|
56
|
+
|
|
57
|
+
def test_create_test_rejects_naive_end(self):
|
|
58
|
+
client = Client.__new__(Client)
|
|
59
|
+
with pytest.raises(ValueError, match="end must be timezone-aware"):
|
|
60
|
+
client.create_test(sport=Sport("cycling"), start=AWARE_UTC, end=NAIVE)
|
|
61
|
+
|
|
62
|
+
def test_update_test_rejects_naive_start(self):
|
|
63
|
+
client = Client.__new__(Client)
|
|
64
|
+
with pytest.raises(ValueError, match="start must be timezone-aware"):
|
|
65
|
+
client.update_test("test_1", sport=Sport("cycling"), start=NAIVE)
|
|
66
|
+
|
|
67
|
+
def test_update_test_rejects_naive_end(self):
|
|
68
|
+
client = Client.__new__(Client)
|
|
69
|
+
with pytest.raises(ValueError, match="end must be timezone-aware"):
|
|
70
|
+
client.update_test(
|
|
71
|
+
"test_1", sport=Sport("cycling"), start=AWARE_UTC, end=NAIVE
|
|
72
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack-0.87.0 → sweatstack-0.88.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|