sweatstack 0.81.0__tar.gz → 0.83.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.81.0 → sweatstack-0.83.0}/CHANGELOG.md +16 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/PKG-INFO +1 -1
- {sweatstack-0.81.0 → sweatstack-0.83.0}/pyproject.toml +1 -1
- sweatstack-0.83.0/src/sweatstack/cli.py +111 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/client.py +24 -5
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/openapi_schemas.py +307 -202
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_dailies.py +32 -14
- sweatstack-0.83.0/tests/test_longitudinal_mean_max_after.py +80 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_sport_ost.py +2 -4
- {sweatstack-0.81.0 → sweatstack-0.83.0}/uv.lock +1 -1
- sweatstack-0.81.0/src/sweatstack/cli.py +0 -48
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/settings.local.json +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/client.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.gitignore +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/.python-version +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/AGENTS.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/LICENSE +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/Makefile +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/README.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/docs/conf.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/docs/everything.rst +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/docs/index.rst +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/004_codebase_hygiene.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/005_ost_sport_bridge.md +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/__init__.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/config.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/dependencies.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/__init__.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_access_token_cache.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_public_surface.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_teams.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_tests.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_trace_test_linking.py +0 -0
- {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_webhooks.py +0 -0
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
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
|
+
|
|
9
|
+
## [0.83.0] - 2026-06-16
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Trace responses now include `test` and `test_match` (server-side test matching).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Fixes Dailies response schema.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## [0.82.0] - 2026-06-16
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `get_longitudinal_mean_max(after=...)`: fatigue-state mean-max. Pass one or more thresholds (kJ of work for `power`; metres of distance for `speed`, experimental) to get, per state, the mean-max over the portion of each ride after that threshold. The returned DataFrame stays metric-indexed with an added `after` column. Max 5 states; the date range is capped at 1 year when `after` is used.
|
|
23
|
+
|
|
8
24
|
## [0.81.0] - 2026-06-16
|
|
9
25
|
|
|
10
26
|
The SweatStack API has fully adopted [OpenSportTaxonomy](https://github.com/SweatStack/open-sport-taxonomy)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from datamodel_code_generator import InputFileType, generate
|
|
8
|
+
from datamodel_code_generator import DataModelType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _bind_sport_to_ost(path: Path) -> None:
|
|
12
|
+
"""Type every ``sport`` / ``sports`` model field as OpenSportTaxonomy's permissive ``SportField``.
|
|
13
|
+
|
|
14
|
+
The API exposes ``sport`` as a free-form OpenSportTaxonomy string, so datamodel-codegen types these
|
|
15
|
+
fields as plain ``str``. We retype them to ``SportField``, which validates an inbound string to an
|
|
16
|
+
``open_sport_taxonomy.Sport`` and serialises back to the canonical wire string, tolerating sports
|
|
17
|
+
newer than the bundled taxonomy. Any leftover generated ``Sport`` schema is dropped. Anchored on the
|
|
18
|
+
AST (not a text match) and idempotent, so it survives regeneration.
|
|
19
|
+
"""
|
|
20
|
+
src = path.read_text()
|
|
21
|
+
tree = ast.parse(src)
|
|
22
|
+
lines = src.splitlines(keepends=True)
|
|
23
|
+
|
|
24
|
+
drop: set[int] = set() # 0-indexed lines to remove
|
|
25
|
+
replace: dict[int, str] = {} # 0-indexed line -> new text
|
|
26
|
+
|
|
27
|
+
# Drop a leftover generated `Sport` schema (the server may still emit an unused one) ...
|
|
28
|
+
for node in tree.body:
|
|
29
|
+
if isinstance(node, ast.ClassDef) and node.name == "Sport":
|
|
30
|
+
drop.update(range(node.lineno - 1, node.end_lineno))
|
|
31
|
+
# ... and any SportField import from a previous run (re-injected cleanly below).
|
|
32
|
+
for i, line in enumerate(lines):
|
|
33
|
+
if line.startswith("from open_sport_taxonomy.pydantic import SportField"):
|
|
34
|
+
drop.add(i)
|
|
35
|
+
|
|
36
|
+
# Retype every `sport` / `sports` field annotation: str -> SportField (str | None, list[str], ...).
|
|
37
|
+
for node in ast.walk(tree):
|
|
38
|
+
if (isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)
|
|
39
|
+
and node.target.id in ("sport", "sports")):
|
|
40
|
+
annotation = ast.get_source_segment(src, node.annotation)
|
|
41
|
+
retyped = re.sub(r"\bstr\b", "SportField", annotation)
|
|
42
|
+
if retyped != annotation:
|
|
43
|
+
i = node.lineno - 1
|
|
44
|
+
replace[i] = lines[i].replace(annotation, retyped, 1)
|
|
45
|
+
|
|
46
|
+
out: list[str] = []
|
|
47
|
+
for i, line in enumerate(lines):
|
|
48
|
+
if i in drop:
|
|
49
|
+
continue
|
|
50
|
+
out.append(replace.get(i, line))
|
|
51
|
+
if line.startswith("from __future__ import annotations"):
|
|
52
|
+
out.append("from open_sport_taxonomy.pydantic import SportField # OST sport type (see schemas.py)\n")
|
|
53
|
+
path.write_text("".join(out))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _restore_naive_local_datetimes(path: Path) -> None:
|
|
57
|
+
"""Type local timestamps as ``NaiveDatetime`` rather than ``AwareDatetime``.
|
|
58
|
+
|
|
59
|
+
The API returns *local* timestamps without a timezone, but datamodel-codegen types every
|
|
60
|
+
``date-time`` field as ``AwareDatetime`` -- which rejects a naive value. Retype the local fields
|
|
61
|
+
(those whose name ends in ``_local``) back to ``NaiveDatetime``, and keep ``registered_at``
|
|
62
|
+
accepting either. AST-anchored and idempotent, so it survives regeneration (and replaces the
|
|
63
|
+
manual fixups this file has needed in the past).
|
|
64
|
+
"""
|
|
65
|
+
src = path.read_text()
|
|
66
|
+
tree = ast.parse(src)
|
|
67
|
+
lines = src.splitlines(keepends=True)
|
|
68
|
+
replace: dict[int, str] = {}
|
|
69
|
+
|
|
70
|
+
for node in ast.walk(tree):
|
|
71
|
+
if not (isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)):
|
|
72
|
+
continue
|
|
73
|
+
name = node.target.id
|
|
74
|
+
annotation = ast.get_source_segment(src, node.annotation)
|
|
75
|
+
if name.endswith("_local") and "AwareDatetime" in annotation:
|
|
76
|
+
retyped = annotation.replace("AwareDatetime", "NaiveDatetime")
|
|
77
|
+
elif name == "registered_at" and annotation == "AwareDatetime":
|
|
78
|
+
retyped = "AwareDatetime | NaiveDatetime"
|
|
79
|
+
else:
|
|
80
|
+
continue
|
|
81
|
+
i = node.lineno - 1
|
|
82
|
+
replace[i] = lines[i].replace(annotation, retyped, 1)
|
|
83
|
+
|
|
84
|
+
if not replace:
|
|
85
|
+
return # already naive (idempotent re-run)
|
|
86
|
+
|
|
87
|
+
src = "".join(replace.get(i, line) for i, line in enumerate(lines))
|
|
88
|
+
if " NaiveDatetime,\n" not in src: # ensure the import exists
|
|
89
|
+
src = src.replace(" AwareDatetime,\n", " AwareDatetime,\n NaiveDatetime,\n", 1)
|
|
90
|
+
path.write_text(src)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def generate_response_models():
|
|
94
|
+
response = httpx.get("http://localhost:8080/openapi.json")
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
output_directory = Path(__file__).parent
|
|
97
|
+
output = Path(output_directory / "openapi_schemas.py")
|
|
98
|
+
output.unlink(missing_ok=True)
|
|
99
|
+
generate(
|
|
100
|
+
response.text,
|
|
101
|
+
input_file_type=InputFileType.OpenAPI,
|
|
102
|
+
input_filename="openapi.json",
|
|
103
|
+
output=output,
|
|
104
|
+
# set up the output model types
|
|
105
|
+
output_model_type=DataModelType.PydanticV2BaseModel,
|
|
106
|
+
)
|
|
107
|
+
_bind_sport_to_ost(output)
|
|
108
|
+
_restore_naive_local_datetimes(output)
|
|
109
|
+
|
|
110
|
+
model = output.read_text()
|
|
111
|
+
print(model)
|
|
@@ -1464,6 +1464,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1464
1464
|
end: date | str | None = None,
|
|
1465
1465
|
date: date | str | None = None,
|
|
1466
1466
|
window_days: int | None = None,
|
|
1467
|
+
after: list[float] | float | None = None,
|
|
1467
1468
|
) -> pd.DataFrame:
|
|
1468
1469
|
"""Gets the mean-max curve for one or more sports and a metric.
|
|
1469
1470
|
|
|
@@ -1475,9 +1476,17 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1475
1476
|
end: End of the date range (defaults to today).
|
|
1476
1477
|
date: Deprecated since 0.70.0. Use ``start`` and ``end`` instead.
|
|
1477
1478
|
window_days: Deprecated since 0.70.0. Use ``start`` and ``end`` instead.
|
|
1479
|
+
after: One or more fatigue states. For each value, the mean-max is computed
|
|
1480
|
+
over the portion of each ride after that much accumulated work (kJ, for
|
|
1481
|
+
``power``) or distance (metres, for ``speed``; experimental), then
|
|
1482
|
+
enveloped across rides. The returned DataFrame then has an ``after``
|
|
1483
|
+
column (one curve per value). Max 5 values; the date range is capped at
|
|
1484
|
+
1 year when ``after`` is used.
|
|
1478
1485
|
|
|
1479
1486
|
Returns:
|
|
1480
|
-
pd.DataFrame: A pandas DataFrame containing the mean-max curve data
|
|
1487
|
+
pd.DataFrame: A pandas DataFrame containing the mean-max curve data, indexed
|
|
1488
|
+
by the metric value. With ``after``, an ``after`` column distinguishes
|
|
1489
|
+
the fatigue states.
|
|
1481
1490
|
|
|
1482
1491
|
Raises:
|
|
1483
1492
|
ValueError: If both ``sport`` and ``sports`` are provided, or neither is provided.
|
|
@@ -1515,12 +1524,14 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1515
1524
|
params["date"] = date
|
|
1516
1525
|
if window_days is not None:
|
|
1517
1526
|
params["window_days"] = window_days
|
|
1527
|
+
if after is not None:
|
|
1528
|
+
params["after"] = [after] if isinstance(after, (int, float)) else after
|
|
1518
1529
|
|
|
1519
1530
|
if self._cache_enabled():
|
|
1520
1531
|
cache_key = self._generate_cache_key("mean_max", **params)
|
|
1521
1532
|
cached = self._read_cache("mean_max", cache_key)
|
|
1522
1533
|
if cached is not None:
|
|
1523
|
-
return self.
|
|
1534
|
+
return self._shape_mean_max(pd.read_parquet(BytesIO(cached)), metric, after)
|
|
1524
1535
|
|
|
1525
1536
|
with self._http_client() as client:
|
|
1526
1537
|
response = client.get(
|
|
@@ -1532,8 +1543,16 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1532
1543
|
if self._cache_enabled():
|
|
1533
1544
|
self._write_cache("mean_max", cache_key, response.content)
|
|
1534
1545
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1546
|
+
return self._shape_mean_max(pd.read_parquet(BytesIO(response.content)), metric, after)
|
|
1547
|
+
|
|
1548
|
+
def _shape_mean_max(self, df: pd.DataFrame, metric: str, after) -> pd.DataFrame:
|
|
1549
|
+
"""Standard post-processing for mean-max responses. The ``after`` response is
|
|
1550
|
+
index-free on the wire; restore the metric-value index so its shape matches the
|
|
1551
|
+
no-``after`` curve (with an extra ``after`` column)."""
|
|
1552
|
+
df = self._postprocess_dataframe(df)
|
|
1553
|
+
if after is not None:
|
|
1554
|
+
df = df.set_index(metric)
|
|
1555
|
+
return df
|
|
1537
1556
|
|
|
1538
1557
|
def get_longitudinal_awd(
|
|
1539
1558
|
self,
|
|
@@ -2162,7 +2181,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
2162
2181
|
dailies = [DailyResponse.model_validate(item) for item in response.json()]
|
|
2163
2182
|
if as_dataframe:
|
|
2164
2183
|
if not dailies:
|
|
2165
|
-
df = pd.DataFrame(columns=["date", "value", "source"])
|
|
2184
|
+
df = pd.DataFrame(columns=["date", "value", "status", "source"])
|
|
2166
2185
|
df = df.set_index("date")
|
|
2167
2186
|
else:
|
|
2168
2187
|
df = pd.DataFrame([d.model_dump() for d in dailies])
|