sweatstack 0.55.0__tar.gz → 0.57.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 (32) hide show
  1. {sweatstack-0.55.0 → sweatstack-0.57.0}/CHANGELOG.md +12 -0
  2. {sweatstack-0.55.0 → sweatstack-0.57.0}/PKG-INFO +1 -1
  3. {sweatstack-0.55.0 → sweatstack-0.57.0}/docs/everything.rst +91 -2
  4. {sweatstack-0.55.0 → sweatstack-0.57.0}/pyproject.toml +1 -1
  5. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/client.py +34 -10
  6. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/streamlit.py +126 -37
  7. {sweatstack-0.55.0 → sweatstack-0.57.0}/.claude/settings.local.json +0 -0
  8. {sweatstack-0.55.0 → sweatstack-0.57.0}/.gitignore +0 -0
  9. {sweatstack-0.55.0 → sweatstack-0.57.0}/.python-version +0 -0
  10. {sweatstack-0.55.0 → sweatstack-0.57.0}/DEVELOPMENT.md +0 -0
  11. {sweatstack-0.55.0 → sweatstack-0.57.0}/Makefile +0 -0
  12. {sweatstack-0.55.0 → sweatstack-0.57.0}/README.md +0 -0
  13. {sweatstack-0.55.0 → sweatstack-0.57.0}/docs/conf.py +0 -0
  14. {sweatstack-0.55.0 → sweatstack-0.57.0}/docs/index.rst +0 -0
  15. {sweatstack-0.55.0 → sweatstack-0.57.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  16. {sweatstack-0.55.0 → sweatstack-0.57.0}/playground/README.md +0 -0
  17. {sweatstack-0.55.0 → sweatstack-0.57.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  18. {sweatstack-0.55.0 → sweatstack-0.57.0}/playground/Untitled.ipynb +0 -0
  19. {sweatstack-0.55.0 → sweatstack-0.57.0}/playground/hello.py +0 -0
  20. {sweatstack-0.55.0 → sweatstack-0.57.0}/playground/pyproject.toml +0 -0
  21. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  22. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/__init__.py +0 -0
  23. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/cli.py +0 -0
  24. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/constants.py +0 -0
  25. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/ipython_init.py +0 -0
  26. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  27. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/openapi_schemas.py +0 -0
  28. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/py.typed +0 -0
  29. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/schemas.py +0 -0
  30. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/sweatshell.py +0 -0
  31. {sweatstack-0.55.0 → sweatstack-0.57.0}/src/sweatstack/utils.py +0 -0
  32. {sweatstack-0.55.0 → sweatstack-0.57.0}/uv.lock +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.57.0] - 2025-12-04
10
+
11
+ ### Added
12
+ - Added a new "proxy mode" to the `ss.StreamlitAuth` class that allows running Streamlit apps behind a proxy. The proxy mode is enabled by calling `ss.StreamlitAuth.behind_proxy()`. The proxy should handle the OAuth callback and token exchange and pass the access token to the app via the `X-SweatStack-Token` (configurable) header. The OAuth2 flow is still initiated by the `ss.StreamlitAuth` class.
13
+
14
+
15
+ ## [0.56.0] - 2025-11-21
16
+
17
+ ### Fixed
18
+ - Fixed an issue where refreshing the token would not succeed with the Streamlit integration.
19
+
20
+
9
21
  ## [0.55.0] - 2025-10-24
10
22
 
11
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.55.0
3
+ Version: 0.57.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Author-email: Aart Goossens <aart@gssns.io>
6
6
  Requires-Python: >=3.9
@@ -163,9 +163,98 @@ Metric
163
163
  :undoc-members:
164
164
 
165
165
  sweatstack.openapi_schemas
166
- ------------------
166
+ --------------------------
167
+
168
+ Core Data Models
169
+ ~~~~~~~~~~~~~~~~
170
+
171
+ .. autoclass:: sweatstack.openapi_schemas.ActivitySummary
172
+ :members:
173
+ :undoc-members:
174
+ :show-inheritance:
175
+
176
+ .. autoclass:: sweatstack.openapi_schemas.ActivityDetails
177
+ :members:
178
+ :undoc-members:
179
+ :show-inheritance:
180
+
181
+ .. autoclass:: sweatstack.openapi_schemas.TraceDetails
182
+ :members:
183
+ :undoc-members:
184
+ :show-inheritance:
185
+
186
+ .. autoclass:: sweatstack.openapi_schemas.Lap
187
+ :members:
188
+ :undoc-members:
189
+ :show-inheritance:
190
+
191
+ Activity Summaries
192
+ ~~~~~~~~~~~~~~~~~~
193
+
194
+ .. autoclass:: sweatstack.openapi_schemas.ActivitySummarySummary
195
+ :members:
196
+ :undoc-members:
197
+ :show-inheritance:
198
+
199
+ .. autoclass:: sweatstack.openapi_schemas.PowerSummary
200
+ :members:
201
+ :undoc-members:
202
+ :show-inheritance:
203
+
204
+ .. autoclass:: sweatstack.openapi_schemas.SpeedSummary
205
+ :members:
206
+ :undoc-members:
207
+ :show-inheritance:
208
+
209
+ .. autoclass:: sweatstack.openapi_schemas.DistanceSummary
210
+ :members:
211
+ :undoc-members:
212
+ :show-inheritance:
213
+
214
+ .. autoclass:: sweatstack.openapi_schemas.ElevationSummary
215
+ :members:
216
+ :undoc-members:
217
+ :show-inheritance:
218
+
219
+ .. autoclass:: sweatstack.openapi_schemas.HeartRateSummary
220
+ :members:
221
+ :undoc-members:
222
+ :show-inheritance:
223
+
224
+ .. autoclass:: sweatstack.openapi_schemas.TemperatureSummary
225
+ :members:
226
+ :undoc-members:
227
+ :show-inheritance:
228
+
229
+ .. autoclass:: sweatstack.openapi_schemas.CoreTemperatureSummary
230
+ :members:
231
+ :undoc-members:
232
+ :show-inheritance:
233
+
234
+ .. autoclass:: sweatstack.openapi_schemas.Smo2Summary
235
+ :members:
236
+ :undoc-members:
237
+ :show-inheritance:
238
+
239
+ User & Authentication
240
+ ~~~~~~~~~~~~~~~~~~~~~
241
+
242
+ .. autoclass:: sweatstack.openapi_schemas.UserSummary
243
+ :members:
244
+ :undoc-members:
245
+ :show-inheritance:
246
+
247
+ .. autoclass:: sweatstack.openapi_schemas.UserInfoResponse
248
+ :members:
249
+ :undoc-members:
250
+ :show-inheritance:
251
+
252
+ .. autoclass:: sweatstack.openapi_schemas.TokenResponse
253
+ :members:
254
+ :undoc-members:
255
+ :show-inheritance:
167
256
 
168
- .. automodule:: sweatstack.openapi_schemas
257
+ .. autoclass:: sweatstack.openapi_schemas.BackfillStatus
169
258
  :members:
170
259
  :undoc-members:
171
260
  :show-inheritance:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack"
3
- version = "0.55.0"
3
+ version = "0.57.0"
4
4
  description = "The official Python client for SweatStack"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -669,6 +669,8 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
669
669
  refresh_token: str | None = None,
670
670
  url: str | None = None,
671
671
  streamlit_compatible: bool = False,
672
+ client_id: str | None = None,
673
+ client_secret: str | None = None,
672
674
  ):
673
675
  """Initialize a SweatStack client.
674
676
 
@@ -682,18 +684,28 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
682
684
  self.refresh_token = refresh_token
683
685
  self.url = url
684
686
  self.streamlit_compatible = streamlit_compatible
687
+ self.client_id = client_id or OAUTH2_CLIENT_ID
688
+ self.client_secret = client_secret
685
689
 
686
690
  def _do_token_refresh(self, tz: str) -> str:
687
- with self._http_client() as client:
691
+ refresh_token = self.refresh_token
692
+ if refresh_token is None:
693
+ raise ValueError(
694
+ "Cannot refresh token: no refresh_token available. "
695
+ "If using Streamlit, ensure you're using StreamlitAuth which handles token refresh automatically."
696
+ )
697
+
698
+ with self._http_client(skip_token_check=True) as client:
688
699
  response = client.post(
689
700
  "/api/v1/oauth/token",
690
- json={
701
+ data={
691
702
  "grant_type": "refresh_token",
692
- "refresh_token": self.refresh_token,
703
+ "refresh_token": refresh_token,
693
704
  "tz": tz,
705
+ "client_id": self.client_id,
706
+ "client_secret": self.client_secret,
694
707
  },
695
708
  )
696
-
697
709
  self._raise_for_status(response)
698
710
  return response.json()["access_token"]
699
711
 
@@ -701,12 +713,13 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
701
713
  try:
702
714
  body = decode_jwt_body(token)
703
715
  # Margin in seconds to account for time to token validation of the next request
704
- TOKEN_EXPIRY_MARGIN = 5
716
+ TOKEN_EXPIRY_MARGIN = 5 # 5 seconds. Meaning that if the token is within 5 seconds of expiring, it will be refreshed.
705
717
  if body["exp"] - TOKEN_EXPIRY_MARGIN < time.time():
706
718
  # Token is (almost) expired, refresh it
707
719
  token = self._do_token_refresh(body["tz"])
708
720
  self._api_key = token
709
- except Exception:
721
+ except Exception as exception:
722
+ logging.warning("Exception checking token expiry: %s", exception)
710
723
  # If token can't be decoded, just return as-is
711
724
  # @TODO: This probably should be handled differently
712
725
  pass
@@ -776,16 +789,27 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
776
789
  self._url = value
777
790
 
778
791
  @contextlib.contextmanager
779
- def _http_client(self):
792
+ def _http_client(self, skip_token_check: bool = False):
780
793
  """
781
794
  Creates an httpx client with the base URL and authentication headers pre-configured.
795
+
796
+ Args:
797
+ skip_token_check: If True, uses the raw _api_key without triggering token expiry check.
798
+ This prevents recursive token refresh attempts.
782
799
  """
783
800
  headers = {
784
801
  "User-Agent": f"python-sweatstack/{__version__}",
785
802
  }
786
- if self.api_key:
787
- headers["Authorization"] = f"Bearer {self.api_key}"
788
-
803
+ if skip_token_check:
804
+ # Use raw token without triggering expiry check (used during refresh)
805
+ token = self._api_key
806
+ else:
807
+ # Normal path: may trigger token refresh
808
+ token = self.api_key
809
+
810
+ if token:
811
+ headers["Authorization"] = f"Bearer {token}"
812
+
789
813
  with httpx.Client(base_url=self.url, headers=headers, timeout=60) as client:
790
814
  yield client
791
815
 
@@ -114,19 +114,115 @@ class StreamlitAuth:
114
114
 
115
115
  self.redirect_uri = redirect_uri or os.environ.get("SWEATSTACK_REDIRECT_URI")
116
116
 
117
+ self._proxy_mode = False
118
+ self._logout_uri = None
119
+
117
120
  self.api_key = st.session_state.get("sweatstack_api_key")
118
- self.client = Client(self.api_key, streamlit_compatible=True)
121
+ self.refresh_token = st.session_state.get("sweatstack_refresh_token")
122
+ self.client = Client(
123
+ self.api_key,
124
+ refresh_token=self.refresh_token,
125
+ streamlit_compatible=True,
126
+ client_id=self.client_id,
127
+ client_secret=self.client_secret,
128
+ )
129
+
130
+ @classmethod
131
+ def behind_proxy(
132
+ cls,
133
+ redirect_uri: str,
134
+ header_name: str = "X-SweatStack-Token",
135
+ logout_uri: str = "/logout",
136
+ ) -> "StreamlitAuth":
137
+ """Create a StreamlitAuth instance for use behind a proxy.
138
+
139
+ Use this method when your Streamlit app runs behind a proxy that handles
140
+ authentication and passes the SweatStack access token via an HTTP header.
141
+
142
+ Args:
143
+ redirect_uri: The URI to redirect to after login (used by proxy).
144
+ header_name: The HTTP header name containing the access token.
145
+ Defaults to "X-SweatStack-Token".
146
+ logout_uri: The URI to redirect to for logout.
147
+ Defaults to "/logout".
148
+
149
+ Returns:
150
+ StreamlitAuth: An instance configured for proxy mode.
151
+
152
+ Example:
153
+ auth = StreamlitAuth.behind_proxy(
154
+ redirect_uri="https://myapp.example.com/app",
155
+ )
156
+
157
+ if not auth.is_authenticated():
158
+ st.error("Missing authentication header")
159
+ st.stop()
160
+
161
+ activities = auth.client.get_activities()
162
+ """
163
+ instance = cls(redirect_uri=redirect_uri)
164
+ instance._proxy_mode = True
165
+ instance._logout_uri = logout_uri
166
+
167
+ token = st.context.headers.get(header_name)
168
+ if token:
169
+ instance.api_key = token
170
+ instance.client = Client(token, streamlit_compatible=True)
171
+
172
+ return instance
173
+
174
+ def _show_styled_link_button(self, label: str, url: str):
175
+ """Displays a styled link button with hover effects.
176
+
177
+ Args:
178
+ label: Text to display on the button.
179
+ url: The URL to navigate to when clicked.
180
+ """
181
+ st.markdown(
182
+ f"""
183
+ <style>
184
+ .animated-button {{
185
+ }}
186
+ .animated-button:hover {{
187
+ transform: scale(1.05);
188
+ }}
189
+ .animated-button:active {{
190
+ transform: scale(1);
191
+ }}
192
+ </style>
193
+ <a href="{url}"
194
+ target="_top"
195
+ class="animated-button"
196
+ style="display: inline-block;
197
+ padding: 10px 20px;
198
+ background-color: #EF2B2D;
199
+ color: white;
200
+ text-decoration: none;
201
+ border-radius: 6px;
202
+ border: none;
203
+ transition: all 0.3s ease;
204
+ cursor: pointer;"
205
+ >{label}</a>
206
+ """,
207
+ unsafe_allow_html=True,
208
+ )
119
209
 
120
210
  def logout_button(self):
121
211
  """Displays a logout button and handles user logout.
122
212
 
123
- When clicked, this button clears the stored API key from session state,
124
- resets the client, and triggers a Streamlit rerun to update the UI.
213
+ In standard mode, clears the stored API key from session state,
214
+ resets the client, and triggers a Streamlit rerun.
215
+
216
+ In proxy mode, displays a styled link that redirects to the logout URI.
125
217
  """
126
- if st.button("Logout"):
218
+ if self._proxy_mode:
219
+ self._show_styled_link_button("Logout", self._logout_uri)
220
+ elif st.button("Logout"):
127
221
  self.api_key = None
222
+ self.refresh_token = None
128
223
  self.client = Client(streamlit_compatible=True)
129
- st.session_state.pop("sweatstack_api_key")
224
+ st.session_state.pop("sweatstack_api_key", None)
225
+ st.session_state.pop("sweatstack_refresh_token", None)
130
226
  st.rerun()
131
227
 
132
228
  def _running_on_streamlit_cloud(self):
@@ -142,34 +238,7 @@ class StreamlitAuth:
142
238
  authorization_url = self.get_authorization_url()
143
239
  login_label = login_label or "Connect with SweatStack"
144
240
  if not self._running_on_streamlit_cloud():
145
- st.markdown(
146
- f"""
147
- <style>
148
- .animated-button {{
149
- }}
150
- .animated-button:hover {{
151
- transform: scale(1.05);
152
- }}
153
- .animated-button:active {{
154
- transform: scale(1);
155
- }}
156
- </style>
157
- <a href="{authorization_url}"
158
- target="_top"
159
- class="animated-button"
160
- style="display: inline-block;
161
- padding: 10px 20px;
162
- background-color: #EF2B2D;
163
- color: white;
164
- text-decoration: none;
165
- border-radius: 6px;
166
- border: none;
167
- transition: all 0.3s ease;
168
- cursor: pointer;"
169
- >{login_label}</a>
170
- """,
171
- unsafe_allow_html=True,
172
- )
241
+ self._show_styled_link_button(login_label, authorization_url)
173
242
  else:
174
243
  st.link_button(login_label, authorization_url)
175
244
 
@@ -193,15 +262,21 @@ class StreamlitAuth:
193
262
 
194
263
  return authorization_url
195
264
 
196
- def _set_api_key(self, api_key):
197
- """Sets the API key in instance and session state, then refreshes the client.
265
+ def _set_api_key(self, api_key, refresh_token=None):
266
+ """Sets the API key and refresh token in instance and session state, then refreshes the client.
198
267
 
199
268
  Args:
200
269
  api_key: The API access token to set.
270
+ refresh_token: The refresh token to set. If None, keeps the existing refresh token.
201
271
  """
202
272
  self.api_key = api_key
203
273
  st.session_state["sweatstack_api_key"] = api_key
204
- self.client = Client(self.api_key, streamlit_compatible=True)
274
+
275
+ if refresh_token is not None:
276
+ self.refresh_token = refresh_token
277
+ st.session_state["sweatstack_refresh_token"] = refresh_token
278
+
279
+ self.client = Client(self.api_key, refresh_token=self.refresh_token, streamlit_compatible=True)
205
280
 
206
281
  def _exchange_token(self, code):
207
282
  """Exchanges an authorization code for an access token.
@@ -230,7 +305,10 @@ class StreamlitAuth:
230
305
  raise Exception(f"SweatStack Python login failed. Please try again.") from e
231
306
  token_response = response.json()
232
307
 
233
- self._set_api_key(token_response.get("access_token"))
308
+ self._set_api_key(
309
+ token_response.get("access_token"),
310
+ refresh_token=token_response.get("refresh_token")
311
+ )
234
312
 
235
313
  return
236
314
 
@@ -257,12 +335,23 @@ class StreamlitAuth:
257
335
  to the Streamlit app with an authorization code, which is exchanged for an
258
336
  access token.
259
337
 
338
+ In proxy mode, this method only shows the login button if not authenticated.
339
+ The proxy handles the OAuth callback and token exchange.
340
+
260
341
  Args:
261
342
  login_label: The label to display on the login button. Defaults to "Login with SweatStack".
262
343
 
263
344
  Returns:
264
345
  None
265
346
  """
347
+ if self._proxy_mode:
348
+ if self.is_authenticated():
349
+ if show_logout:
350
+ self.logout_button()
351
+ else:
352
+ self._show_sweatstack_login(login_label)
353
+ return
354
+
266
355
  if self.is_authenticated():
267
356
  if not st.session_state.get("sweatstack_auth_toast_shown", False):
268
357
  st.toast("SweatStack authentication successful!", icon="✅")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes