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.
Files changed (67) hide show
  1. {sweatstack-0.81.0 → sweatstack-0.83.0}/CHANGELOG.md +16 -0
  2. {sweatstack-0.81.0 → sweatstack-0.83.0}/PKG-INFO +1 -1
  3. {sweatstack-0.81.0 → sweatstack-0.83.0}/pyproject.toml +1 -1
  4. sweatstack-0.83.0/src/sweatstack/cli.py +111 -0
  5. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/client.py +24 -5
  6. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/openapi_schemas.py +307 -202
  7. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_dailies.py +32 -14
  8. sweatstack-0.83.0/tests/test_longitudinal_mean_max_after.py +80 -0
  9. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_sport_ost.py +2 -4
  10. {sweatstack-0.81.0 → sweatstack-0.83.0}/uv.lock +1 -1
  11. sweatstack-0.81.0/src/sweatstack/cli.py +0 -48
  12. {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/settings.local.json +0 -0
  13. {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
  14. {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/client.md +0 -0
  15. {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
  16. {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/fastapi.md +0 -0
  17. {sweatstack-0.81.0 → sweatstack-0.83.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
  18. {sweatstack-0.81.0 → sweatstack-0.83.0}/.gitignore +0 -0
  19. {sweatstack-0.81.0 → sweatstack-0.83.0}/.python-version +0 -0
  20. {sweatstack-0.81.0 → sweatstack-0.83.0}/AGENTS.md +0 -0
  21. {sweatstack-0.81.0 → sweatstack-0.83.0}/CONTRIBUTING.md +0 -0
  22. {sweatstack-0.81.0 → sweatstack-0.83.0}/DEVELOPMENT.md +0 -0
  23. {sweatstack-0.81.0 → sweatstack-0.83.0}/LICENSE +0 -0
  24. {sweatstack-0.81.0 → sweatstack-0.83.0}/Makefile +0 -0
  25. {sweatstack-0.81.0 → sweatstack-0.83.0}/README.md +0 -0
  26. {sweatstack-0.81.0 → sweatstack-0.83.0}/docs/conf.py +0 -0
  27. {sweatstack-0.81.0 → sweatstack-0.83.0}/docs/everything.rst +0 -0
  28. {sweatstack-0.81.0 → sweatstack-0.83.0}/docs/index.rst +0 -0
  29. {sweatstack-0.81.0 → sweatstack-0.83.0}/examples/fastapi_webhooks_example.py +0 -0
  30. {sweatstack-0.81.0 → sweatstack-0.83.0}/examples/send_webhook.py +0 -0
  31. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/001a_tests.md +0 -0
  32. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/001b_metadata.md +0 -0
  33. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/001c_dailies.md +0 -0
  34. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
  35. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/003_trace_test_linking.md +0 -0
  36. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/004_codebase_hygiene.md +0 -0
  37. {sweatstack-0.81.0 → sweatstack-0.83.0}/plans/005_ost_sport_bridge.md +0 -0
  38. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  39. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/__init__.py +0 -0
  40. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/constants.py +0 -0
  41. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/exceptions.py +0 -0
  42. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/__init__.py +0 -0
  43. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/access_token_cache.py +0 -0
  44. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/config.py +0 -0
  45. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/dependencies.py +0 -0
  46. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/models.py +0 -0
  47. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/routes.py +0 -0
  48. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/session.py +0 -0
  49. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/token_stores.py +0 -0
  50. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/fastapi/webhooks.py +0 -0
  51. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/ipython_init.py +0 -0
  52. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  53. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/py.typed +0 -0
  54. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/schemas.py +0 -0
  55. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/streamlit.py +0 -0
  56. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/sweatshell.py +0 -0
  57. {sweatstack-0.81.0 → sweatstack-0.83.0}/src/sweatstack/utils.py +0 -0
  58. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/__init__.py +0 -0
  59. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_access_token_cache.py +0 -0
  60. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_dtype_conversion.py +0 -0
  61. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_exceptions.py +0 -0
  62. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_metadata.py +0 -0
  63. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_public_surface.py +0 -0
  64. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_teams.py +0 -0
  65. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_tests.py +0 -0
  66. {sweatstack-0.81.0 → sweatstack-0.83.0}/tests/test_trace_test_linking.py +0 -0
  67. {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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.81.0
3
+ Version: 0.83.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.81.0"
3
+ version = "0.83.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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._postprocess_dataframe(pd.read_parquet(BytesIO(cached)))
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
- df = pd.read_parquet(BytesIO(response.content))
1536
- return self._postprocess_dataframe(df)
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])