sweatstack 0.82.0__tar.gz → 0.84.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.
Files changed (67) hide show
  1. {sweatstack-0.82.0 → sweatstack-0.84.0}/CHANGELOG.md +21 -0
  2. {sweatstack-0.82.0 → sweatstack-0.84.0}/PKG-INFO +2 -2
  3. {sweatstack-0.82.0 → sweatstack-0.84.0}/pyproject.toml +4 -2
  4. sweatstack-0.84.0/src/sweatstack/cli.py +111 -0
  5. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/client.py +16 -10
  6. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/openapi_schemas.py +307 -202
  7. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_dailies.py +32 -14
  8. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_longitudinal_mean_max_after.py +38 -0
  9. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_sport_ost.py +2 -4
  10. {sweatstack-0.82.0 → sweatstack-0.84.0}/uv.lock +41 -26
  11. sweatstack-0.82.0/src/sweatstack/cli.py +0 -48
  12. {sweatstack-0.82.0 → sweatstack-0.84.0}/.claude/settings.local.json +0 -0
  13. {sweatstack-0.82.0 → sweatstack-0.84.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  14. {sweatstack-0.82.0 → sweatstack-0.84.0}/.claude/skills/sweatstack-python/client.md +0 -0
  15. {sweatstack-0.82.0 → sweatstack-0.84.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  16. {sweatstack-0.82.0 → sweatstack-0.84.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  17. {sweatstack-0.82.0 → sweatstack-0.84.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  18. {sweatstack-0.82.0 → sweatstack-0.84.0}/.gitignore +0 -0
  19. {sweatstack-0.82.0 → sweatstack-0.84.0}/.python-version +0 -0
  20. {sweatstack-0.82.0 → sweatstack-0.84.0}/AGENTS.md +0 -0
  21. {sweatstack-0.82.0 → sweatstack-0.84.0}/CONTRIBUTING.md +0 -0
  22. {sweatstack-0.82.0 → sweatstack-0.84.0}/DEVELOPMENT.md +0 -0
  23. {sweatstack-0.82.0 → sweatstack-0.84.0}/LICENSE +0 -0
  24. {sweatstack-0.82.0 → sweatstack-0.84.0}/Makefile +0 -0
  25. {sweatstack-0.82.0 → sweatstack-0.84.0}/README.md +0 -0
  26. {sweatstack-0.82.0 → sweatstack-0.84.0}/docs/conf.py +0 -0
  27. {sweatstack-0.82.0 → sweatstack-0.84.0}/docs/everything.rst +0 -0
  28. {sweatstack-0.82.0 → sweatstack-0.84.0}/docs/index.rst +0 -0
  29. {sweatstack-0.82.0 → sweatstack-0.84.0}/examples/fastapi_webhooks_example.py +0 -0
  30. {sweatstack-0.82.0 → sweatstack-0.84.0}/examples/send_webhook.py +0 -0
  31. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/001a_tests.md +0 -0
  32. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/001b_metadata.md +0 -0
  33. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/001c_dailies.md +0 -0
  34. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  35. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/003_trace_test_linking.md +0 -0
  36. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/004_codebase_hygiene.md +0 -0
  37. {sweatstack-0.82.0 → sweatstack-0.84.0}/plans/005_ost_sport_bridge.md +0 -0
  38. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  39. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/__init__.py +0 -0
  40. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/constants.py +0 -0
  41. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/exceptions.py +0 -0
  42. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/__init__.py +0 -0
  43. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  44. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/config.py +0 -0
  45. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  46. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/models.py +0 -0
  47. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/routes.py +0 -0
  48. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/session.py +0 -0
  49. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  50. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  51. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/ipython_init.py +0 -0
  52. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  53. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/py.typed +0 -0
  54. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/schemas.py +0 -0
  55. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/streamlit.py +0 -0
  56. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/sweatshell.py +0 -0
  57. {sweatstack-0.82.0 → sweatstack-0.84.0}/src/sweatstack/utils.py +0 -0
  58. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/__init__.py +0 -0
  59. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_access_token_cache.py +0 -0
  60. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_dtype_conversion.py +0 -0
  61. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_exceptions.py +0 -0
  62. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_metadata.py +0 -0
  63. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_public_surface.py +0 -0
  64. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_teams.py +0 -0
  65. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_tests.py +0 -0
  66. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_trace_test_linking.py +0 -0
  67. {sweatstack-0.82.0 → sweatstack-0.84.0}/tests/test_webhooks.py +0 -0
@@ -5,6 +5,27 @@ 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.84.0] - 2026-06-17
10
+
11
+ ### Added
12
+
13
+ - `get_longitudinal_mean_max(by=...)`: pass `by="duration"` (with `after`, for `power`) to get the fatigue curve indexed by duration instead of by intensity. Default `by="intensity"` is unchanged.
14
+
15
+ ### Changed
16
+
17
+ - Require `pyarrow>=20`: pyarrow 18/19 fail to read the server's parquet ("Repetition level histogram size mismatch") for longitudinal and adaptive-sampling responses; pyarrow 20+ reads them correctly.
18
+
19
+
20
+ ## [0.83.0] - 2026-06-16
21
+
22
+ ### Added
23
+ - Trace responses now include `test` and `test_match` (server-side test matching).
24
+
25
+ ### Fixed
26
+ - Fixes Dailies response schema.
27
+
28
+
8
29
  ## [0.82.0] - 2026-06-16
9
30
 
10
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.82.0
3
+ Version: 0.84.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://docs.sweatstack.no/getting-started/
@@ -26,7 +26,7 @@ Requires-Dist: httpx>=0.28.1
26
26
  Requires-Dist: open-sport-taxonomy[pydantic]>=0.10.0
27
27
  Requires-Dist: pandas>=2.2.3
28
28
  Requires-Dist: platformdirs>=4.0.0
29
- Requires-Dist: pyarrow>=18.0.0
29
+ Requires-Dist: pyarrow>=20.0.0
30
30
  Requires-Dist: pydantic>=2.10.5
31
31
  Provides-Extra: fastapi
32
32
  Requires-Dist: cryptography>=41.0.0; extra == 'fastapi'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.82.0"
3
+ version = "0.84.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,7 +26,9 @@ dependencies = [
26
26
  "open-sport-taxonomy[pydantic]>=0.10.0",
27
27
  "pandas>=2.2.3",
28
28
  "platformdirs>=4.0.0",
29
- "pyarrow>=18.0.0",
29
+ # >=20: 18/19 raise "Repetition level histogram size mismatch" reading the server's
30
+ # polars-written parquet (size-statistics histograms); fixed in pyarrow 20.
31
+ "pyarrow>=20.0.0",
30
32
  "pydantic>=2.10.5",
31
33
  ]
32
34
 
@@ -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)
@@ -1465,6 +1465,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1465
1465
  date: date | str | None = None,
1466
1466
  window_days: int | None = None,
1467
1467
  after: list[float] | float | None = None,
1468
+ by: Literal["intensity", "duration"] = "intensity",
1468
1469
  ) -> pd.DataFrame:
1469
1470
  """Gets the mean-max curve for one or more sports and a metric.
1470
1471
 
@@ -1482,11 +1483,14 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1482
1483
  enveloped across rides. The returned DataFrame then has an ``after``
1483
1484
  column (one curve per value). Max 5 values; the date range is capped at
1484
1485
  1 year when ``after`` is used.
1486
+ by: Axis to index the curve on. ``"intensity"`` (default) indexes by the metric
1487
+ value (unchanged). ``"duration"`` indexes by duration via the fast segment
1488
+ kernel; currently requires ``after`` and ``metric="power"``.
1485
1489
 
1486
1490
  Returns:
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.
1491
+ pd.DataFrame: A pandas DataFrame containing the mean-max curve data, indexed by
1492
+ the metric value (``by="intensity"``) or by duration (``by="duration"``).
1493
+ With ``after``, an ``after`` column distinguishes the fatigue states.
1490
1494
 
1491
1495
  Raises:
1492
1496
  ValueError: If both ``sport`` and ``sports`` are provided, or neither is provided.
@@ -1508,6 +1512,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1508
1512
  params = {
1509
1513
  "sport": self._enums_to_strings(sports),
1510
1514
  "metric": metric,
1515
+ "by": by,
1511
1516
  }
1512
1517
  if start is not None:
1513
1518
  params["start"] = start
@@ -1531,7 +1536,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1531
1536
  cache_key = self._generate_cache_key("mean_max", **params)
1532
1537
  cached = self._read_cache("mean_max", cache_key)
1533
1538
  if cached is not None:
1534
- return self._shape_mean_max(pd.read_parquet(BytesIO(cached)), metric, after)
1539
+ return self._shape_mean_max(pd.read_parquet(BytesIO(cached)), metric, after, by)
1535
1540
 
1536
1541
  with self._http_client() as client:
1537
1542
  response = client.get(
@@ -1543,15 +1548,16 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
1543
1548
  if self._cache_enabled():
1544
1549
  self._write_cache("mean_max", cache_key, response.content)
1545
1550
 
1546
- return self._shape_mean_max(pd.read_parquet(BytesIO(response.content)), metric, after)
1551
+ return self._shape_mean_max(pd.read_parquet(BytesIO(response.content)), metric, after, by)
1547
1552
 
1548
- def _shape_mean_max(self, df: pd.DataFrame, metric: str, after) -> pd.DataFrame:
1553
+ def _shape_mean_max(self, df: pd.DataFrame, metric: str, after, by: str = "intensity") -> pd.DataFrame:
1549
1554
  """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)."""
1555
+ index-free on the wire; restore the natural index so its shape matches the
1556
+ no-``after`` curve (with an extra ``after`` column): the metric value for
1557
+ ``by="intensity"``, or ``duration`` for ``by="duration"``."""
1552
1558
  df = self._postprocess_dataframe(df)
1553
1559
  if after is not None:
1554
- df = df.set_index(metric)
1560
+ df = df.set_index("duration" if by == "duration" else metric)
1555
1561
  return df
1556
1562
 
1557
1563
  def get_longitudinal_awd(
@@ -2181,7 +2187,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
2181
2187
  dailies = [DailyResponse.model_validate(item) for item in response.json()]
2182
2188
  if as_dataframe:
2183
2189
  if not dailies:
2184
- df = pd.DataFrame(columns=["date", "value", "source"])
2190
+ df = pd.DataFrame(columns=["date", "value", "status", "source"])
2185
2191
  df = df.set_index("date")
2186
2192
  else:
2187
2193
  df = pd.DataFrame([d.model_dump() for d in dailies])