sweatstack 0.77.0__tar.gz → 0.79.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.77.0 → sweatstack-0.79.0}/.claude/settings.local.json +3 -1
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.claude/skills/sweatstack-python/fastapi.md +78 -0
- sweatstack-0.79.0/AGENTS.md +225 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/CHANGELOG.md +29 -0
- sweatstack-0.79.0/DEVELOPMENT.md +105 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/PKG-INFO +2 -3
- sweatstack-0.79.0/plans/004_codebase_hygiene.md +204 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/pyproject.toml +3 -3
- sweatstack-0.79.0/src/sweatstack/__init__.py +26 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/client.py +167 -138
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/__init__.py +11 -0
- sweatstack-0.79.0/src/sweatstack/fastapi/access_token_cache.py +287 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/config.py +12 -1
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/dependencies.py +203 -42
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/openapi_schemas.py +22 -21
- sweatstack-0.79.0/tests/test_access_token_cache.py +577 -0
- sweatstack-0.79.0/tests/test_public_surface.py +69 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_tests.py +4 -4
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_trace_test_linking.py +1 -1
- {sweatstack-0.77.0 → sweatstack-0.79.0}/uv.lock +37 -1
- sweatstack-0.77.0/DEVELOPMENT.md +0 -13
- sweatstack-0.77.0/src/sweatstack/__init__.py +0 -12
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.claude/skills/sweatstack-python/SKILL.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.claude/skills/sweatstack-python/client.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.claude/skills/sweatstack-python/data-models.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.claude/skills/sweatstack-python/streamlit.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.gitignore +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/.python-version +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/CONTRIBUTING.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/LICENSE +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/Makefile +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/README.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/docs/conf.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/docs/everything.rst +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/docs/index.rst +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/examples/fastapi_webhooks_example.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/examples/send_webhook.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/plans/001a_tests.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/plans/001b_metadata.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/plans/001c_dailies.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/plans/002_TYPED_EXCEPTIONS.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/plans/003_trace_test_linking.md +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/exceptions.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/models.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/routes.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/session.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/token_stores.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/fastapi/webhooks.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/sweatshell.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/src/sweatstack/utils.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/__init__.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_dailies.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_dtype_conversion.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_exceptions.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_metadata.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_teams.py +0 -0
- {sweatstack-0.77.0 → sweatstack-0.79.0}/tests/test_webhooks.py +0 -0
|
@@ -11,6 +11,7 @@ Requires: `pip install sweatstack[fastapi]`
|
|
|
11
11
|
- [User Delegation](#user-delegation)
|
|
12
12
|
- [Webhooks](#webhooks)
|
|
13
13
|
- [Token Stores](#token-stores)
|
|
14
|
+
- [Access Token Cache](#access-token-cache)
|
|
14
15
|
|
|
15
16
|
---
|
|
16
17
|
|
|
@@ -55,6 +56,7 @@ configure(
|
|
|
55
56
|
redirect_unauthenticated: bool = True, # True = redirect to login, False = return 401
|
|
56
57
|
webhook_secret: str = None, # SWEATSTACK_WEBHOOK_SECRET
|
|
57
58
|
token_store: TokenStore = None, # for webhook user token persistence
|
|
59
|
+
access_token_cache: AccessTokenCache = None, # default: in-process LRU. See "Access Token Cache"
|
|
58
60
|
)
|
|
59
61
|
```
|
|
60
62
|
|
|
@@ -207,3 +209,79 @@ class RedisTokenStore(TokenStore):
|
|
|
207
209
|
```
|
|
208
210
|
|
|
209
211
|
`StoredTokens` fields: `user_id`, `access_token`, `refresh_token`, `expires_at: datetime`.
|
|
212
|
+
|
|
213
|
+
## Access Token Cache
|
|
214
|
+
|
|
215
|
+
When a single page-load fans out into many concurrent requests, the cookie's access token may be on the edge of expiry for several of them at once. Without coordination, each request independently calls `/oauth/token` with the same refresh token, producing a burst of duplicate refreshes. The access-token cache collapses this to a single call: peers serialise on a per-session lock and share the result.
|
|
216
|
+
|
|
217
|
+
The default `InMemoryAccessTokenCache` is correct for single-worker FastAPI deployments. It is **LRU-bounded** (10k entries) so memory is capped regardless of session churn, and uses **striped locking** (64 stripes) so eviction never coordinates with in-flight refreshes.
|
|
218
|
+
|
|
219
|
+
### Tuning
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from sweatstack.fastapi import configure, InMemoryAccessTokenCache
|
|
223
|
+
|
|
224
|
+
configure(
|
|
225
|
+
...,
|
|
226
|
+
access_token_cache=InMemoryAccessTokenCache(
|
|
227
|
+
max_entries=50_000, # high session-churn deployments
|
|
228
|
+
lock_stripes=128, # very high concurrent refresh counts
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Multi-worker deployments
|
|
234
|
+
|
|
235
|
+
For multiple FastAPI workers (uvicorn `--workers > 1`, Gunicorn, etc.) the in-memory cache de-duplicates **within** each worker, not across them. Cross-worker de-duplication requires a shared-state implementation. Implement the `AccessTokenCache` Protocol — `get`, `set`, `invalidate`, `migrate`, `lock` — and pass it to `configure()`.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from sweatstack.fastapi import AccessTokenCache, CachedAccessToken
|
|
239
|
+
# Sketch: Redis-backed implementation (hash the key, never store the raw refresh token).
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The Protocol surface is documented in detail in `sweatstack/fastapi/access_token_cache.py`. The call pattern (`check → lock → recheck → refresh → set/migrate`) is in the `AccessTokenCache` docstring.
|
|
243
|
+
|
|
244
|
+
### Timeouts and the `RefreshLockTimeout` exception
|
|
245
|
+
|
|
246
|
+
The refresh path has two bounds, both intended as defence-in-depth rather than knobs users normally tune:
|
|
247
|
+
|
|
248
|
+
| Constant | Default | What it bounds |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `REFRESH_HTTP_TIMEOUT` | 10s read, 5s connect | The `/oauth/token` HTTP call |
|
|
251
|
+
| `REFRESH_LOCK_TIMEOUT` | 15s | Waiting for a peer's refresh to finish |
|
|
252
|
+
|
|
253
|
+
A waiter that cannot acquire the per-session lock within 15s raises `RefreshLockTimeout` rather than pinning a threadpool worker. In the cookie-based dependency path it surfaces as a 401; in the webhook path it is wrapped as `WebhookTokenRefreshError`. You don't normally catch it; if you want to wire it to metrics:
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from sweatstack.fastapi import RefreshLockTimeout
|
|
257
|
+
|
|
258
|
+
@app.exception_handler(RefreshLockTimeout)
|
|
259
|
+
async def on_refresh_timeout(request, exc):
|
|
260
|
+
metrics.increment("oauth.refresh.lock_timeout")
|
|
261
|
+
raise # let the normal 401 / WebhookTokenRefreshError path run
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Refresh-token rotation
|
|
265
|
+
|
|
266
|
+
The current SweatStack `/oauth/token` endpoint does not rotate refresh tokens, but the cache is rotation-aware: if the server ever starts returning a new refresh token, it is captured, the cookie / token store is rewritten with it, and the cache installs the result under both the old and new keys (so in-flight peers still arriving with the old refresh token continue to hit).
|
|
267
|
+
|
|
268
|
+
### Debugging
|
|
269
|
+
|
|
270
|
+
Enable debug logs to see every cache decision keyed by a short SHA-256 fingerprint of the refresh token (never the raw secret):
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
import logging
|
|
274
|
+
logging.getLogger("sweatstack.fastapi.dependencies").setLevel(logging.DEBUG)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Typical output for a 5-way concurrent fan-out on an expiring session:
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
DEBUG access-token refresh completed (session=4f2a91c0)
|
|
281
|
+
DEBUG access-token cache hit after lock (peer refreshed; session=4f2a91c0)
|
|
282
|
+
DEBUG access-token cache hit after lock (peer refreshed; session=4f2a91c0)
|
|
283
|
+
DEBUG access-token cache hit (session=4f2a91c0)
|
|
284
|
+
DEBUG access-token cache hit (session=4f2a91c0)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
One refresh, four cache hits.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Working agreement for agents (and humans) changing this codebase. Read end
|
|
4
|
+
to end before your first change. Mechanics — install, test, regen, release —
|
|
5
|
+
live in [DEVELOPMENT.md](DEVELOPMENT.md).
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Guiding principle: mirror the REST API
|
|
9
|
+
|
|
10
|
+
This client is a thin Python projection of the SweatStack REST API. When
|
|
11
|
+
you add or change a surface, the default is to mirror the server:
|
|
12
|
+
|
|
13
|
+
| Server | Python |
|
|
14
|
+
| --------------------------------------- | --------------------------------------- |
|
|
15
|
+
| `GET /api/v1/tests/{id}` | `client.get_test(test_id)` |
|
|
16
|
+
| `POST /api/v1/traces/` | `client.create_trace(...)` |
|
|
17
|
+
| `PUT /api/v1/traces/{id}` (full-replace)| `client.update_trace(trace_id, ...)` |
|
|
18
|
+
| `DELETE /api/v1/dailies/{id}` | `client.delete_daily(daily_id)` |
|
|
19
|
+
| query param `?sport=cycling&sport=running` | `sports=[Sport.cycling, Sport.running]` |
|
|
20
|
+
| JSON body field `test_id` | kwarg `test_id` |
|
|
21
|
+
| OpenAPI enum value `"auto"` | `TraceResolution.auto` |
|
|
22
|
+
|
|
23
|
+
Match the server's field names, enum values, full-replace PUT semantics,
|
|
24
|
+
and pagination shape. Don't redesign the API in Python.
|
|
25
|
+
|
|
26
|
+
**Deliberate deviations** (these are the only ones — don't invent more):
|
|
27
|
+
|
|
28
|
+
- Path-param IDs are positional Python args (`get_test(test_id)`), not part
|
|
29
|
+
of a URL string.
|
|
30
|
+
- Enum-typed params accept `Enum | str` so callers don't need to import the
|
|
31
|
+
enum for one-off use.
|
|
32
|
+
- A `traces=` query parameter renames to `trace_resolution=` on the Python
|
|
33
|
+
side when the wire name would be ambiguous as a kwarg. Document the
|
|
34
|
+
mapping in the docstring.
|
|
35
|
+
- `as_dataframe=True` is a client-side convenience over list endpoints.
|
|
36
|
+
- Convenience composites that wrap multiple calls
|
|
37
|
+
(`get_latest_activity_data`, `get_longitudinal_*`) live alongside the
|
|
38
|
+
literal mirrors. Add new ones sparingly; only when a real workflow is
|
|
39
|
+
awkward through the literal surface.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Project shape
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
src/sweatstack/
|
|
46
|
+
├── openapi_schemas.py # AUTO-GENERATED. Never hand-edit.
|
|
47
|
+
├── schemas.py # Re-exports + Enum._missing_ / display_name helpers.
|
|
48
|
+
├── exceptions.py # Public error contract. No httpx types leak.
|
|
49
|
+
├── client.py # Single Client class + module-level singletons.
|
|
50
|
+
├── utils.py # Dataframe / JWT helpers.
|
|
51
|
+
├── streamlit.py # Streamlit integration (optional extra).
|
|
52
|
+
├── fastapi/ # FastAPI integration (optional extra).
|
|
53
|
+
└── cli.py # Entry points.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Adding a new Pydantic model from the server takes three steps:
|
|
57
|
+
|
|
58
|
+
1. Regen `openapi_schemas.py` ([DEVELOPMENT.md](DEVELOPMENT.md#regenerating-openapi_schemaspy)).
|
|
59
|
+
2. Re-export it from `schemas.py`.
|
|
60
|
+
3. Import it into `client.py` so `from sweatstack import *` exposes it.
|
|
61
|
+
|
|
62
|
+
Skipping step 3 silently breaks the public surface. Same for new enums.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## Hard rules
|
|
66
|
+
|
|
67
|
+
- **`uv`, never `pip`.** Every command goes through `uv run` / `uv add`.
|
|
68
|
+
- **Never hand-edit `openapi_schemas.py`.** Regenerate.
|
|
69
|
+
- **`Raises:` references the typed exceptions** from
|
|
70
|
+
`sweatstack.exceptions`. Don't write `HTTPStatusError` in new docstrings.
|
|
71
|
+
- **Public methods belong in `_generate_singleton_methods(...)`** at the
|
|
72
|
+
bottom of `client.py`. Forgetting is silent.
|
|
73
|
+
- **Enum-typed params accept `Enum | str`** and route through
|
|
74
|
+
`_enums_to_strings`. Don't introduce strict-enum-only parameters.
|
|
75
|
+
- **Tests are offline.** No network calls. Use `Client.__new__(Client)` to
|
|
76
|
+
bypass init when you need an instance for a helper method.
|
|
77
|
+
- **`update_*` methods are full-replace.** Document the silent-clear
|
|
78
|
+
footgun in the docstring (see below).
|
|
79
|
+
- **CHANGELOG entries are user-facing**, not dev-facing.
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Method shape
|
|
83
|
+
|
|
84
|
+
Every method on `Client` follows this shape. Match it.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
def do_thing(
|
|
88
|
+
self,
|
|
89
|
+
resource_id: str, # path params: positional
|
|
90
|
+
*, # rest: keyword-only
|
|
91
|
+
timestamp: datetime,
|
|
92
|
+
sport: Sport | str | None = None, # enum params: Enum | str
|
|
93
|
+
tags: list[str] | None = None,
|
|
94
|
+
) -> ThingDetails:
|
|
95
|
+
"""One-line summary.
|
|
96
|
+
|
|
97
|
+
Optional paragraph for non-obvious behaviour (full-replace,
|
|
98
|
+
server-side defaults, side effects).
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
ThingDetails: ...
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
SweatStackNotFoundError: If <specific condition>.
|
|
108
|
+
SweatStackAPIError: If the API request fails for any other reason.
|
|
109
|
+
"""
|
|
110
|
+
sport = self._enums_to_strings([sport])[0] if sport else None
|
|
111
|
+
with self._http_client() as client:
|
|
112
|
+
response = client.post(
|
|
113
|
+
url=f"/api/v1/things/{resource_id}",
|
|
114
|
+
json={"timestamp": timestamp.isoformat(), "sport": sport, "tags": tags},
|
|
115
|
+
)
|
|
116
|
+
self._raise_for_status(response)
|
|
117
|
+
return ThingDetails.model_validate(response.json())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Invariants baked into the template:
|
|
121
|
+
|
|
122
|
+
- Path-param IDs positional, everything else keyword-only.
|
|
123
|
+
- Datetimes serialize via `.isoformat()`.
|
|
124
|
+
- Body uses every field explicitly (full-replace contract).
|
|
125
|
+
- Request goes through `self._http_client()` context manager.
|
|
126
|
+
- Response goes through `self._raise_for_status()` before parsing.
|
|
127
|
+
- Return is a validated Pydantic model — never the raw dict.
|
|
128
|
+
- `update_*` methods are typed `-> None`. The server's
|
|
129
|
+
`{"message": "..."}` body carries no useful info.
|
|
130
|
+
|
|
131
|
+
**List endpoints** add a `_get_<resource>_generator()` that yields
|
|
132
|
+
validated objects with internal pagination, plus a `get_<resource>s(...,
|
|
133
|
+
as_dataframe=False)` wrapper. Empty-list DataFrames go through
|
|
134
|
+
`_create_empty_dataframe_from_model(Model, normalize_columns=[...])` so
|
|
135
|
+
column names stay stable. See `get_activities` and `get_tests` for
|
|
136
|
+
exemplars.
|
|
137
|
+
|
|
138
|
+
**Query parameters** are only sent when the caller supplied a non-default
|
|
139
|
+
value. For enum-typed query params with an explicit default, compare
|
|
140
|
+
against the default and skip when equal. Always pass `.value` for enums
|
|
141
|
+
into `httpx.params` — `httpx` calls `str()`, which gives
|
|
142
|
+
`"TraceResolution.linked"`, not `"linked"`.
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Full-replace `update_*` methods
|
|
146
|
+
|
|
147
|
+
PUT endpoints overwrite every field, including by setting unsent fields
|
|
148
|
+
to `null`. Two rules:
|
|
149
|
+
|
|
150
|
+
1. **Send every field in the body, even when `None`.** Don't omit.
|
|
151
|
+
2. **Document the silent-clear footgun in the docstring** whenever you
|
|
152
|
+
add a new optional field to an existing `update_*` method, *and*
|
|
153
|
+
call it out in the CHANGELOG `### Changed` section. Existing callers
|
|
154
|
+
who don't know about the new field will silently null it on their
|
|
155
|
+
next update.
|
|
156
|
+
|
|
157
|
+
Don't try to soften the contract with "preserve if omitted" sentinels;
|
|
158
|
+
that diverges from how every other field behaves on these methods.
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
## Exceptions
|
|
162
|
+
|
|
163
|
+
The hierarchy in `sweatstack/exceptions.py` is the **public** error
|
|
164
|
+
contract — consumers should never need to import `httpx`.
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
SweatStackError
|
|
168
|
+
├── SweatStackConnectionError # DNS / timeout / no response
|
|
169
|
+
├── SweatStackTokenRefreshError
|
|
170
|
+
└── SweatStackAPIError # got a response with status >= 400
|
|
171
|
+
├── SweatStackAuthError # 401, 403
|
|
172
|
+
├── SweatStackNotFoundError # 404
|
|
173
|
+
├── SweatStackRateLimitError # 429
|
|
174
|
+
├── SweatStackBadRequestError # other 4xx
|
|
175
|
+
└── SweatStackServerError # 5xx
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Docstrings: list specific subclasses for meaningful conditions (e.g. 404
|
|
179
|
+
when a path ID may not exist), then a catch-all `SweatStackAPIError`.
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## Tests
|
|
183
|
+
|
|
184
|
+
Live in `tests/`. Offline only. The workhorse pattern is schema
|
|
185
|
+
round-tripping:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
restored = TraceDetails.model_validate(original.model_dump())
|
|
189
|
+
assert restored.test_id == "test_123"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Catches missing fields, type drift, and enum-casing changes from regen.
|
|
193
|
+
Exemplars: `tests/test_trace_test_linking.py`, `tests/test_tests.py`,
|
|
194
|
+
`tests/test_dtype_conversion.py`.
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
## CHANGELOG
|
|
198
|
+
|
|
199
|
+
User-facing only. Lead with what changed for the user; skip regen
|
|
200
|
+
output, file renames, refactors. Loud-callout any behaviour change in
|
|
201
|
+
`### Changed`, especially full-replace footguns. SemVer.
|
|
202
|
+
|
|
203
|
+
Good:
|
|
204
|
+
|
|
205
|
+
> ### Changed
|
|
206
|
+
> - `update_trace()` replaces all fields, including `test_id`. Callers
|
|
207
|
+
> that omit `test_id` will clear any existing link.
|
|
208
|
+
|
|
209
|
+
Bad (belongs in the commit message, not the changelog):
|
|
210
|
+
|
|
211
|
+
> ### Changed
|
|
212
|
+
> - Local datetime fields are now `AwareDatetime`. The
|
|
213
|
+
> `BodyExpressAddEmail...` schema is renamed to
|
|
214
|
+
> `BodyConnectAddEmail...`.
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
## When in doubt
|
|
218
|
+
|
|
219
|
+
- Match the nearest existing method in `client.py`. Consistency with the
|
|
220
|
+
surroundings beats local cleverness.
|
|
221
|
+
- Reuse the helpers: `_enums_to_strings`, `_get_*_generator`,
|
|
222
|
+
`_normalize_dataframe_column`, `_create_empty_dataframe_from_model`,
|
|
223
|
+
`_set_app_metadata`. Don't reinvent them.
|
|
224
|
+
- Don't add abstractions for hypothetical future flexibility. Three
|
|
225
|
+
similar blocks is the pattern, not a smell.
|
|
@@ -6,6 +6,35 @@ 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.79.0] - 2026-05-28
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `sweatstack.fastapi`: a single page load that fans out into many concurrent requests no longer produces a burst of duplicate `/oauth/token` refreshes when the session's access token is on the edge of expiring. Concurrent requests for the same session now serialise on a per-session lock and share the resulting refresh, so an N-way race collapses to a single token endpoint call.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `sweatstack.fastapi.AccessTokenCache` (Protocol) and `InMemoryAccessTokenCache` (default), exposed via `configure(access_token_cache=...)`. The default is correct for single-worker deployments. Multi-worker deployments that want cross-worker de-duplication can plug in a shared-state implementation (e.g. Redis-backed). The default implementation is bounded by an LRU cap (10k entries) and uses striped locking, so memory growth is bounded regardless of session churn.
|
|
16
|
+
- `sweatstack.fastapi`: refresh-token rotation is now handled transparently. If a future `/oauth/token` response returns a new refresh token, the cache installs the result under both the old and new keys (so in-flight peers still hit), and the cookie / token store is rewritten with the new value. Current SweatStack servers do not rotate, so this is a forward-compatibility measure.
|
|
17
|
+
- `sweatstack.fastapi.RefreshLockTimeout`: a waiter that cannot acquire the per-session refresh lock within 15 seconds now raises this rather than blocking a FastAPI threadpool worker indefinitely. The `/oauth/token` call itself also has an explicit 10-second timeout.
|
|
18
|
+
- `sweatstack.fastapi`: debug logs (`sweatstack.fastapi.dependencies` logger) on every cache hit / seed / refresh / failure, keyed by a short SHA-256 fingerprint of the refresh token. Enable with `logging.getLogger("sweatstack.fastapi.dependencies").setLevel(logging.DEBUG)`.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## [0.78.0] - 2026-05-19
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `update_trace()` and `delete_trace()` are now exposed as module-level functions (e.g. `sweatstack.update_trace(...)`), matching the rest of the CRUD surface. They were previously only reachable via a `Client` instance.
|
|
25
|
+
- The package now declares an `__all__`, so `from sweatstack import *` and tooling that inspects the public surface (Sphinx, IDEs, type-checkers) see a well-defined list.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Minimum Python version is now declared as `>=3.10` (the code already required 3.10+ syntax; the previous `>=3.9` declaration was incorrect).
|
|
29
|
+
- Exception types in docstrings now reference the typed hierarchy introduced in 0.76.0 (`SweatStackAPIError`, `SweatStackNotFoundError`, `SweatStackAuthError`, `SweatStackBadRequestError`) instead of the now-incorrect `HTTPStatusError`.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## [0.77.1] - 2026-05-19
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Reverted unrelated OpenAPI schema drift.
|
|
36
|
+
|
|
37
|
+
|
|
9
38
|
## [0.77.0] - 2026-05-19
|
|
10
39
|
|
|
11
40
|
### Added
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Development
|
|
2
|
+
|
|
3
|
+
Mechanics for working on the SweatStack Python client locally. For the
|
|
4
|
+
conventions you must follow when changing code, see [AGENTS.md](AGENTS.md).
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## Tooling
|
|
8
|
+
|
|
9
|
+
This project uses [`uv`](https://docs.astral.sh/uv/) for everything.
|
|
10
|
+
**Never use `pip` directly.**
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv sync # install/update dependencies
|
|
14
|
+
uv run pytest # run tests
|
|
15
|
+
uv run python -c "..." # ad-hoc scripts
|
|
16
|
+
uv run generate-response-models # regenerate OpenAPI schemas (see below)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Python ≥ 3.9 is supported; develop against the version pinned in
|
|
20
|
+
`.python-version`.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Running locally
|
|
24
|
+
|
|
25
|
+
For interactive exploration (Jupyter):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uvx --with-editable "path/to/sweatstack-python[jupyterlab]" jupyter lab
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run from a scratch directory so JupyterLab does not litter the repo with
|
|
32
|
+
`Untitled` notebooks.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## Running tests
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv run pytest # full suite
|
|
39
|
+
uv run pytest tests/test_<name>.py # one file
|
|
40
|
+
uv run pytest --ignore=tests/test_webhooks.py # skip optional-dep tests
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`tests/test_webhooks.py` requires `fastapi`, which is an optional extra. If
|
|
44
|
+
you have not installed it, ignore that file or `uv sync --extra fastapi`.
|
|
45
|
+
|
|
46
|
+
All tests are offline — no test should make a network call. See
|
|
47
|
+
[AGENTS.md → Testing](AGENTS.md#testing) for how to write new ones.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
## Regenerating `openapi_schemas.py`
|
|
51
|
+
|
|
52
|
+
`src/sweatstack/openapi_schemas.py` is **fully machine-generated** from the
|
|
53
|
+
backend's OpenAPI document by `datamodel-code-generator`. Never hand-edit it.
|
|
54
|
+
|
|
55
|
+
### Procedure
|
|
56
|
+
|
|
57
|
+
1. Start the SweatStack backend so it serves `http://localhost:8080/openapi.json`.
|
|
58
|
+
2. Run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run generate-response-models
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
3. Review the diff carefully. The file is regenerated as a whole, so the
|
|
65
|
+
diff will often include **unrelated upstream changes** since the last
|
|
66
|
+
regeneration (field renames, type tightening, new endpoints).
|
|
67
|
+
|
|
68
|
+
4. If the diff contains changes outside the feature you are working on,
|
|
69
|
+
**split them into a separate commit** (`chore: regenerate openapi
|
|
70
|
+
schemas`) that lands before the feature commit. Keep the feature
|
|
71
|
+
commit minimal so reviewers can read it.
|
|
72
|
+
|
|
73
|
+
### Staging only part of the regenerated file
|
|
74
|
+
|
|
75
|
+
When the regen drift is too large to include in a feature commit but you
|
|
76
|
+
do not want to lose it, stage only the relevant hunks:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cp src/sweatstack/openapi_schemas.py /tmp/openapi.full.py
|
|
80
|
+
git checkout HEAD -- src/sweatstack/openapi_schemas.py
|
|
81
|
+
# hand-apply only the lines relevant to your feature
|
|
82
|
+
git add src/sweatstack/openapi_schemas.py
|
|
83
|
+
cp /tmp/openapi.full.py src/sweatstack/openapi_schemas.py # restore working tree
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The working tree now has the full regen (unstaged), and the index has the
|
|
87
|
+
minimal feature delta. Commit, then handle the remainder separately.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Releasing
|
|
91
|
+
|
|
92
|
+
1. Bump `version` in `pyproject.toml` (SemVer).
|
|
93
|
+
2. Add a CHANGELOG entry — see [AGENTS.md → CHANGELOG](AGENTS.md#changelog).
|
|
94
|
+
3. `make build` and `make publish` (twine to PyPI). The `Makefile` has
|
|
95
|
+
the canonical commands.
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Docs
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
make docs
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Renders Sphinx to `docs/_build/markdown/`. Most public classes are
|
|
105
|
+
documented automatically via `autoclass` directives in `docs/everything.rst`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sweatstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.79.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/
|
|
@@ -15,13 +15,12 @@ Classifier: Development Status :: 4 - Beta
|
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.10
|
|
25
24
|
Requires-Dist: email-validator>=2.2.0
|
|
26
25
|
Requires-Dist: httpx>=0.28.1
|
|
27
26
|
Requires-Dist: pandas>=2.2.3
|