sweatstack 0.58.0__py3-none-any.whl → 0.60.0__py3-none-any.whl
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/client.py +67 -27
- sweatstack/fastapi/__init__.py +43 -0
- sweatstack/fastapi/config.py +177 -0
- sweatstack/fastapi/dependencies.py +161 -0
- sweatstack/fastapi/routes.py +177 -0
- sweatstack/fastapi/session.py +102 -0
- sweatstack/schemas.py +46 -1
- {sweatstack-0.58.0.dist-info → sweatstack-0.60.0.dist-info}/METADATA +4 -1
- {sweatstack-0.58.0.dist-info → sweatstack-0.60.0.dist-info}/RECORD +11 -6
- {sweatstack-0.58.0.dist-info → sweatstack-0.60.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.58.0.dist-info → sweatstack-0.60.0.dist-info}/entry_points.txt +0 -0
sweatstack/client.py
CHANGED
|
@@ -19,6 +19,8 @@ from importlib.metadata import version
|
|
|
19
19
|
from io import BytesIO
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Any, Dict, Generator, get_type_hints, List, Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import SecretStr
|
|
22
24
|
from urllib.parse import parse_qs, urlparse
|
|
23
25
|
|
|
24
26
|
import httpx
|
|
@@ -86,7 +88,7 @@ class _LocalCacheMixin:
|
|
|
86
88
|
raise ValueError("Not authenticated. Please call authenticate() or login() first.")
|
|
87
89
|
|
|
88
90
|
try:
|
|
89
|
-
jwt_body = decode_jwt_body(self.api_key)
|
|
91
|
+
jwt_body = decode_jwt_body(self.api_key.get_secret_value())
|
|
90
92
|
user_id = jwt_body.get("sub")
|
|
91
93
|
if not user_id:
|
|
92
94
|
raise ValueError("Unable to extract user ID from token")
|
|
@@ -208,6 +210,15 @@ except ImportError:
|
|
|
208
210
|
__version__ = "unknown"
|
|
209
211
|
|
|
210
212
|
|
|
213
|
+
def _to_secret(value: str | SecretStr | None) -> SecretStr | None:
|
|
214
|
+
"""Convert a string to SecretStr, or return None if value is None."""
|
|
215
|
+
if value is None:
|
|
216
|
+
return None
|
|
217
|
+
if isinstance(value, SecretStr):
|
|
218
|
+
return value
|
|
219
|
+
return SecretStr(value)
|
|
220
|
+
|
|
221
|
+
|
|
211
222
|
class _OAuth2Mixin:
|
|
212
223
|
"""OAuth2 authentication methods for the Client class."""
|
|
213
224
|
|
|
@@ -317,7 +328,6 @@ class _OAuth2Mixin:
|
|
|
317
328
|
token_response = TokenResponse.model_validate(response.json())
|
|
318
329
|
|
|
319
330
|
self.api_key = token_response.access_token
|
|
320
|
-
self.jwt = token_response.access_token # For backward compatibility
|
|
321
331
|
self.refresh_token = token_response.refresh_token
|
|
322
332
|
|
|
323
333
|
if persist:
|
|
@@ -400,13 +410,12 @@ class _OAuth2Mixin:
|
|
|
400
410
|
|
|
401
411
|
if hasattr(server, "code"):
|
|
402
412
|
try:
|
|
403
|
-
|
|
413
|
+
self.exchange_code_for_token(
|
|
404
414
|
code=server.code,
|
|
405
415
|
client_id=OAUTH2_CLIENT_ID,
|
|
406
416
|
code_verifier=code_verifier,
|
|
407
417
|
persist=persist_api_key,
|
|
408
418
|
)
|
|
409
|
-
self.jwt = token_response.access_token
|
|
410
419
|
print("SweatStack Python login successful.")
|
|
411
420
|
except Exception as e:
|
|
412
421
|
raise Exception("SweatStack Python login failed. Please try again.") from e
|
|
@@ -665,12 +674,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
665
674
|
|
|
666
675
|
def __init__(
|
|
667
676
|
self,
|
|
668
|
-
api_key: str | None = None,
|
|
669
|
-
refresh_token: str | None = None,
|
|
677
|
+
api_key: str | SecretStr | None = None,
|
|
678
|
+
refresh_token: str | SecretStr | None = None,
|
|
670
679
|
url: str | None = None,
|
|
671
680
|
streamlit_compatible: bool = False,
|
|
672
681
|
client_id: str | None = None,
|
|
673
|
-
client_secret: str | None = None,
|
|
682
|
+
client_secret: str | SecretStr | None = None,
|
|
674
683
|
):
|
|
675
684
|
"""Initialize a SweatStack client.
|
|
676
685
|
|
|
@@ -679,16 +688,18 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
679
688
|
refresh_token: Optional refresh token for automatic token renewal.
|
|
680
689
|
url: Optional SweatStack instance URL. Defaults to production.
|
|
681
690
|
streamlit_compatible: Set to True when using in Streamlit apps.
|
|
691
|
+
client_id: Optional OAuth client ID. Defaults to the public client ID.
|
|
692
|
+
client_secret: Optional OAuth client secret for confidential clients.
|
|
682
693
|
"""
|
|
683
|
-
self.
|
|
684
|
-
self.
|
|
694
|
+
self._api_key: SecretStr | None = _to_secret(api_key)
|
|
695
|
+
self._refresh_token: SecretStr | None = _to_secret(refresh_token)
|
|
696
|
+
self._client_secret: SecretStr | None = _to_secret(client_secret)
|
|
685
697
|
self.url = url
|
|
686
698
|
self.streamlit_compatible = streamlit_compatible
|
|
687
699
|
self.client_id = client_id or OAUTH2_CLIENT_ID
|
|
688
|
-
self.client_secret = client_secret
|
|
689
700
|
|
|
690
701
|
def _do_token_refresh(self, tz: str) -> str:
|
|
691
|
-
refresh_token = self.
|
|
702
|
+
refresh_token = self._refresh_token
|
|
692
703
|
if refresh_token is None:
|
|
693
704
|
raise ValueError(
|
|
694
705
|
"Cannot refresh token: no refresh_token available. "
|
|
@@ -700,10 +711,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
700
711
|
"/api/v1/oauth/token",
|
|
701
712
|
data={
|
|
702
713
|
"grant_type": "refresh_token",
|
|
703
|
-
"refresh_token": refresh_token,
|
|
714
|
+
"refresh_token": refresh_token.get_secret_value(),
|
|
704
715
|
"tz": tz,
|
|
705
716
|
"client_id": self.client_id,
|
|
706
|
-
"client_secret": self.
|
|
717
|
+
"client_secret": self._client_secret.get_secret_value() if self._client_secret else None,
|
|
707
718
|
},
|
|
708
719
|
)
|
|
709
720
|
self._raise_for_status(response)
|
|
@@ -717,7 +728,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
717
728
|
if body["exp"] - TOKEN_EXPIRY_MARGIN < time.time():
|
|
718
729
|
# Token is (almost) expired, refresh it
|
|
719
730
|
token = self._do_token_refresh(body["tz"])
|
|
720
|
-
self._api_key = token
|
|
731
|
+
self._api_key = SecretStr(token)
|
|
721
732
|
except Exception as exception:
|
|
722
733
|
logging.warning("Exception checking token expiry: %s", exception)
|
|
723
734
|
# If token can't be decoded, just return as-is
|
|
@@ -727,14 +738,17 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
727
738
|
return token
|
|
728
739
|
|
|
729
740
|
@property
|
|
730
|
-
def api_key(self) ->
|
|
741
|
+
def api_key(self) -> SecretStr | None:
|
|
731
742
|
"""The current API access token.
|
|
732
743
|
|
|
733
744
|
Automatically loads from instance, environment (SWEATSTACK_API_KEY),
|
|
734
745
|
or persistent storage. Refreshes expired tokens automatically.
|
|
746
|
+
|
|
747
|
+
Returns a SecretStr to prevent accidental logging of the token.
|
|
748
|
+
Use .get_secret_value() to get the actual token string.
|
|
735
749
|
"""
|
|
736
750
|
if self._api_key is not None:
|
|
737
|
-
value = self._api_key
|
|
751
|
+
value = self._api_key.get_secret_value()
|
|
738
752
|
elif value := os.getenv("SWEATSTACK_API_KEY"):
|
|
739
753
|
pass
|
|
740
754
|
else:
|
|
@@ -744,30 +758,56 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
744
758
|
# A non-authenticated client is a potentially valid use-case.
|
|
745
759
|
return None
|
|
746
760
|
|
|
747
|
-
|
|
761
|
+
# Check expiry and potentially refresh (returns the string value)
|
|
762
|
+
checked_value = self._check_token_expiry(value)
|
|
763
|
+
return SecretStr(checked_value)
|
|
748
764
|
|
|
749
765
|
@api_key.setter
|
|
750
|
-
def api_key(self, value: str):
|
|
751
|
-
self._api_key = value
|
|
766
|
+
def api_key(self, value: str | SecretStr | None):
|
|
767
|
+
self._api_key = _to_secret(value)
|
|
752
768
|
|
|
753
769
|
@property
|
|
754
|
-
def refresh_token(self) ->
|
|
770
|
+
def refresh_token(self) -> SecretStr | None:
|
|
755
771
|
"""The refresh token used for automatic token renewal.
|
|
756
772
|
|
|
757
773
|
Loads from instance, environment (SWEATSTACK_REFRESH_TOKEN), or persistent storage.
|
|
774
|
+
|
|
775
|
+
Returns a SecretStr to prevent accidental logging of the token.
|
|
776
|
+
Use .get_secret_value() to get the actual token string.
|
|
758
777
|
"""
|
|
759
778
|
if self._refresh_token is not None:
|
|
760
779
|
return self._refresh_token
|
|
761
780
|
elif value := os.getenv("SWEATSTACK_REFRESH_TOKEN"):
|
|
762
|
-
|
|
781
|
+
return SecretStr(value)
|
|
763
782
|
else:
|
|
764
783
|
_, value = self._load_persistent_tokens()
|
|
765
|
-
|
|
766
|
-
return value
|
|
784
|
+
return _to_secret(value)
|
|
767
785
|
|
|
768
786
|
@refresh_token.setter
|
|
769
|
-
def refresh_token(self, value: str):
|
|
770
|
-
self._refresh_token = value
|
|
787
|
+
def refresh_token(self, value: str | SecretStr | None):
|
|
788
|
+
self._refresh_token = _to_secret(value)
|
|
789
|
+
|
|
790
|
+
@property
|
|
791
|
+
def client_secret(self) -> SecretStr | None:
|
|
792
|
+
"""The OAuth client secret for confidential clients.
|
|
793
|
+
|
|
794
|
+
Returns a SecretStr to prevent accidental logging of the secret.
|
|
795
|
+
Use .get_secret_value() to get the actual secret string.
|
|
796
|
+
"""
|
|
797
|
+
return self._client_secret
|
|
798
|
+
|
|
799
|
+
@client_secret.setter
|
|
800
|
+
def client_secret(self, value: str | SecretStr | None):
|
|
801
|
+
self._client_secret = _to_secret(value)
|
|
802
|
+
|
|
803
|
+
@property
|
|
804
|
+
def jwt(self) -> SecretStr | None:
|
|
805
|
+
"""Alias for api_key (backward compatibility)."""
|
|
806
|
+
return self.api_key
|
|
807
|
+
|
|
808
|
+
@jwt.setter
|
|
809
|
+
def jwt(self, value: str | SecretStr | None):
|
|
810
|
+
self.api_key = value
|
|
771
811
|
|
|
772
812
|
@property
|
|
773
813
|
def url(self) -> str:
|
|
@@ -808,7 +848,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
808
848
|
token = self.api_key
|
|
809
849
|
|
|
810
850
|
if token:
|
|
811
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
851
|
+
headers["Authorization"] = f"Bearer {token.get_secret_value()}"
|
|
812
852
|
|
|
813
853
|
with httpx.Client(base_url=self.url, headers=headers, timeout=60) as client:
|
|
814
854
|
yield client
|
|
@@ -1644,7 +1684,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1644
1684
|
raise ValueError("Not authenticated. Please call authenticate() or login() first.")
|
|
1645
1685
|
|
|
1646
1686
|
try:
|
|
1647
|
-
jwt_body = decode_jwt_body(self.api_key)
|
|
1687
|
+
jwt_body = decode_jwt_body(self.api_key.get_secret_value())
|
|
1648
1688
|
user_id = jwt_body.get("sub")
|
|
1649
1689
|
if not user_id:
|
|
1650
1690
|
raise ValueError("Unable to extract user ID from token")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""FastAPI integration for SweatStack authentication.
|
|
2
|
+
|
|
3
|
+
This module provides OAuth authentication for FastAPI applications using
|
|
4
|
+
SweatStack as the identity provider.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from sweatstack.fastapi import configure, instrument, AuthenticatedUser
|
|
9
|
+
|
|
10
|
+
configure() # uses environment variables
|
|
11
|
+
|
|
12
|
+
app = FastAPI()
|
|
13
|
+
instrument(app)
|
|
14
|
+
|
|
15
|
+
@app.get("/me")
|
|
16
|
+
def get_me(user: AuthenticatedUser):
|
|
17
|
+
return user.client.get_userinfo()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import fastapi # noqa: F401
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"FastAPI is required for sweatstack.fastapi. "
|
|
25
|
+
"Install it with: pip install 'sweatstack[fastapi]'"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .config import configure, urls
|
|
29
|
+
from .dependencies import (
|
|
30
|
+
AuthenticatedUser,
|
|
31
|
+
OptionalUser,
|
|
32
|
+
SweatStackUser,
|
|
33
|
+
)
|
|
34
|
+
from .routes import instrument
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"configure",
|
|
38
|
+
"instrument",
|
|
39
|
+
"AuthenticatedUser",
|
|
40
|
+
"OptionalUser",
|
|
41
|
+
"SweatStackUser",
|
|
42
|
+
"urls",
|
|
43
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Module-level configuration for the FastAPI plugin."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from cryptography.fernet import Fernet
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_fernet_key(key: str) -> None:
|
|
15
|
+
"""Validate that a string is a valid Fernet key."""
|
|
16
|
+
try:
|
|
17
|
+
Fernet(key.encode() if isinstance(key, str) else key)
|
|
18
|
+
except Exception:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"Invalid session_secret. Fernet keys must be 32 url-safe base64-encoded bytes. "
|
|
21
|
+
f"Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FastAPIConfig:
|
|
27
|
+
"""Internal configuration for the FastAPI plugin."""
|
|
28
|
+
|
|
29
|
+
client_id: str
|
|
30
|
+
client_secret: SecretStr
|
|
31
|
+
app_url: str
|
|
32
|
+
session_secret: SecretStr | list[SecretStr]
|
|
33
|
+
scopes: list[str]
|
|
34
|
+
cookie_secure: bool
|
|
35
|
+
cookie_max_age: int
|
|
36
|
+
auth_route_prefix: str
|
|
37
|
+
redirect_unauthenticated: bool
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def redirect_uri(self) -> str:
|
|
41
|
+
"""Construct the OAuth redirect URI from app_url and auth_route_prefix."""
|
|
42
|
+
return f"{self.app_url.rstrip('/')}{self.auth_route_prefix}/callback"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_config: FastAPIConfig | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_secret(value: str | SecretStr) -> SecretStr:
|
|
49
|
+
"""Convert a string to SecretStr if needed."""
|
|
50
|
+
if isinstance(value, SecretStr):
|
|
51
|
+
return value
|
|
52
|
+
return SecretStr(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def configure(
|
|
56
|
+
*,
|
|
57
|
+
client_id: str | None = None,
|
|
58
|
+
client_secret: str | SecretStr | None = None,
|
|
59
|
+
app_url: str | None = None,
|
|
60
|
+
session_secret: str | SecretStr | list[str | SecretStr] | None = None,
|
|
61
|
+
scopes: list[str] | None = None,
|
|
62
|
+
cookie_secure: bool | None = None,
|
|
63
|
+
cookie_max_age: int = 86400,
|
|
64
|
+
auth_route_prefix: str = "/auth/sweatstack",
|
|
65
|
+
redirect_unauthenticated: bool = True,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Configure the FastAPI plugin.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
client_id: OAuth client ID. Falls back to SWEATSTACK_CLIENT_ID env var.
|
|
71
|
+
client_secret: OAuth client secret. Falls back to SWEATSTACK_CLIENT_SECRET env var.
|
|
72
|
+
app_url: Base URL of the application (e.g., "http://localhost:8000").
|
|
73
|
+
Falls back to APP_URL env var.
|
|
74
|
+
The OAuth redirect URI is derived as: app_url + auth_route_prefix + "/callback"
|
|
75
|
+
session_secret: Fernet key(s) for cookie encryption. Can be a single key
|
|
76
|
+
or a list of keys for key rotation (first encrypts, all decrypt).
|
|
77
|
+
Falls back to SWEATSTACK_SESSION_SECRET env var.
|
|
78
|
+
scopes: OAuth scopes to request. Defaults to ["profile", "data:read"].
|
|
79
|
+
cookie_secure: Whether to set the Secure flag on cookies. If not specified,
|
|
80
|
+
auto-detected from app_url (True for https, False for http).
|
|
81
|
+
cookie_max_age: Session cookie lifetime in seconds. Defaults to 86400 (24h).
|
|
82
|
+
auth_route_prefix: URL prefix for auth routes. Defaults to "/auth/sweatstack".
|
|
83
|
+
redirect_unauthenticated: If True, redirect unauthenticated requests to login
|
|
84
|
+
with ?next= set to the current path. If False, return 401. Defaults to True.
|
|
85
|
+
"""
|
|
86
|
+
global _config
|
|
87
|
+
|
|
88
|
+
# Resolve from environment variables
|
|
89
|
+
client_id = client_id or os.environ.get("SWEATSTACK_CLIENT_ID")
|
|
90
|
+
client_secret = client_secret or os.environ.get("SWEATSTACK_CLIENT_SECRET")
|
|
91
|
+
app_url = app_url or os.environ.get("APP_URL")
|
|
92
|
+
session_secret = session_secret or os.environ.get("SWEATSTACK_SESSION_SECRET")
|
|
93
|
+
|
|
94
|
+
# Validate required parameters
|
|
95
|
+
if not client_id:
|
|
96
|
+
raise ValueError("client_id is required (or set SWEATSTACK_CLIENT_ID)")
|
|
97
|
+
if not client_secret:
|
|
98
|
+
raise ValueError("client_secret is required (or set SWEATSTACK_CLIENT_SECRET)")
|
|
99
|
+
if not app_url:
|
|
100
|
+
raise ValueError("app_url is required (or set APP_URL)")
|
|
101
|
+
if not session_secret:
|
|
102
|
+
raise ValueError("session_secret is required (or set SWEATSTACK_SESSION_SECRET)")
|
|
103
|
+
|
|
104
|
+
# Auto-detect cookie_secure from app_url scheme
|
|
105
|
+
if cookie_secure is None:
|
|
106
|
+
cookie_secure = app_url.startswith("https://")
|
|
107
|
+
if not cookie_secure and "localhost" not in app_url and "127.0.0.1" not in app_url:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"Using HTTP with non-localhost URL (%s) - cookies will not be secure",
|
|
110
|
+
app_url,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Validate and convert session secret(s)
|
|
114
|
+
secret_list = [session_secret] if isinstance(session_secret, (str, SecretStr)) else session_secret
|
|
115
|
+
for secret in secret_list:
|
|
116
|
+
secret_value = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
|
117
|
+
_validate_fernet_key(secret_value)
|
|
118
|
+
|
|
119
|
+
# Convert to SecretStr
|
|
120
|
+
client_secret_obj = _to_secret(client_secret)
|
|
121
|
+
if isinstance(session_secret, (str, SecretStr)):
|
|
122
|
+
session_secret_obj: SecretStr | list[SecretStr] = _to_secret(session_secret)
|
|
123
|
+
else:
|
|
124
|
+
session_secret_obj = [_to_secret(s) for s in session_secret]
|
|
125
|
+
|
|
126
|
+
if scopes is None:
|
|
127
|
+
scopes = ["profile", "data:read"]
|
|
128
|
+
|
|
129
|
+
# Normalize prefix (strip trailing slash)
|
|
130
|
+
auth_route_prefix = auth_route_prefix.rstrip("/")
|
|
131
|
+
|
|
132
|
+
_config = FastAPIConfig(
|
|
133
|
+
client_id=client_id,
|
|
134
|
+
client_secret=client_secret_obj,
|
|
135
|
+
app_url=app_url,
|
|
136
|
+
session_secret=session_secret_obj,
|
|
137
|
+
scopes=scopes,
|
|
138
|
+
cookie_secure=cookie_secure,
|
|
139
|
+
cookie_max_age=cookie_max_age,
|
|
140
|
+
auth_route_prefix=auth_route_prefix,
|
|
141
|
+
redirect_unauthenticated=redirect_unauthenticated,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_config() -> FastAPIConfig:
|
|
146
|
+
"""Get the current configuration.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
RuntimeError: If configure() has not been called.
|
|
150
|
+
"""
|
|
151
|
+
if _config is None:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"configure() must be called before instrument()"
|
|
154
|
+
)
|
|
155
|
+
return _config
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _Urls:
|
|
159
|
+
"""URL helpers for the FastAPI plugin."""
|
|
160
|
+
|
|
161
|
+
def login(self, next: str | None = None) -> str:
|
|
162
|
+
"""Get the login URL.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
next: Optional path to redirect to after login.
|
|
166
|
+
"""
|
|
167
|
+
base = f"{get_config().auth_route_prefix}/login"
|
|
168
|
+
if next:
|
|
169
|
+
return f"{base}?next={quote(next)}"
|
|
170
|
+
return base
|
|
171
|
+
|
|
172
|
+
def logout(self) -> str:
|
|
173
|
+
"""Get the logout URL."""
|
|
174
|
+
return f"{get_config().auth_route_prefix}/logout"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
urls = _Urls()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""FastAPI dependencies for authentication."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated, NoReturn, TypeAlias
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from fastapi import Depends, HTTPException, Request, Response
|
|
11
|
+
|
|
12
|
+
from ..client import Client
|
|
13
|
+
from ..constants import DEFAULT_URL
|
|
14
|
+
from ..utils import decode_jwt_body
|
|
15
|
+
from .config import get_config
|
|
16
|
+
from .session import (
|
|
17
|
+
SESSION_COOKIE_NAME,
|
|
18
|
+
clear_session_cookie,
|
|
19
|
+
decrypt_session,
|
|
20
|
+
set_session_cookie,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
TOKEN_EXPIRY_MARGIN = 5 # seconds
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SweatStackUser:
|
|
28
|
+
"""Authenticated SweatStack user.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
user_id: The user's SweatStack ID.
|
|
32
|
+
client: An authenticated Client instance for API calls.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
user_id: str
|
|
36
|
+
client: Client
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_token_expiring(token: str) -> bool:
|
|
40
|
+
"""Check if a token is within TOKEN_EXPIRY_MARGIN seconds of expiring."""
|
|
41
|
+
try:
|
|
42
|
+
body = decode_jwt_body(token)
|
|
43
|
+
return body["exp"] - TOKEN_EXPIRY_MARGIN < time.time()
|
|
44
|
+
except Exception:
|
|
45
|
+
# If we can't decode, assume it's expired
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def refresh_access_token(
|
|
50
|
+
refresh_token: str,
|
|
51
|
+
client_id: str,
|
|
52
|
+
client_secret: str,
|
|
53
|
+
tz: str,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Exchange a refresh token for a new access token."""
|
|
56
|
+
response = httpx.post(
|
|
57
|
+
f"{DEFAULT_URL}/api/v1/oauth/token",
|
|
58
|
+
data={
|
|
59
|
+
"grant_type": "refresh_token",
|
|
60
|
+
"refresh_token": refresh_token,
|
|
61
|
+
"client_id": client_id,
|
|
62
|
+
"client_secret": client_secret,
|
|
63
|
+
"tz": tz,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
return response.json()["access_token"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _raise_unauthenticated(request: Request) -> NoReturn:
|
|
71
|
+
"""Raise appropriate exception for unauthenticated requests.
|
|
72
|
+
|
|
73
|
+
If redirect_unauthenticated is True, redirects to login with ?next= set.
|
|
74
|
+
Otherwise, raises 401 Unauthorized.
|
|
75
|
+
"""
|
|
76
|
+
config = get_config()
|
|
77
|
+
if config.redirect_unauthenticated:
|
|
78
|
+
next_url = request.url.path
|
|
79
|
+
if request.url.query:
|
|
80
|
+
next_url += f"?{request.url.query}"
|
|
81
|
+
login_url = f"{config.auth_route_prefix}/login?next={quote(next_url)}"
|
|
82
|
+
raise HTTPException(status_code=303, headers={"Location": login_url})
|
|
83
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_user(request: Request, response: Response) -> SweatStackUser:
|
|
87
|
+
"""Dependency that requires an authenticated user.
|
|
88
|
+
|
|
89
|
+
Returns a SweatStackUser if the session is valid. If not authenticated,
|
|
90
|
+
behavior depends on the redirect_unauthenticated config:
|
|
91
|
+
- If True: redirects to login with ?next= set to current path
|
|
92
|
+
- If False: raises 401 Unauthorized
|
|
93
|
+
|
|
94
|
+
Automatically refreshes tokens if they are about to expire.
|
|
95
|
+
"""
|
|
96
|
+
session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
|
|
97
|
+
if not session:
|
|
98
|
+
_raise_unauthenticated(request)
|
|
99
|
+
|
|
100
|
+
access_token = session.get("access_token")
|
|
101
|
+
refresh_token = session.get("refresh_token")
|
|
102
|
+
user_id = session.get("user_id")
|
|
103
|
+
|
|
104
|
+
if not access_token or not user_id:
|
|
105
|
+
clear_session_cookie(response)
|
|
106
|
+
_raise_unauthenticated(request)
|
|
107
|
+
|
|
108
|
+
# Check if token needs refresh
|
|
109
|
+
if is_token_expiring(access_token):
|
|
110
|
+
if not refresh_token:
|
|
111
|
+
clear_session_cookie(response)
|
|
112
|
+
_raise_unauthenticated(request)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
# Extract timezone from current token
|
|
116
|
+
token_body = decode_jwt_body(access_token)
|
|
117
|
+
tz = token_body.get("tz", "UTC")
|
|
118
|
+
|
|
119
|
+
config = get_config()
|
|
120
|
+
new_access_token = refresh_access_token(
|
|
121
|
+
refresh_token=refresh_token,
|
|
122
|
+
client_id=config.client_id,
|
|
123
|
+
client_secret=config.client_secret,
|
|
124
|
+
tz=tz,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Update session with new token
|
|
128
|
+
session["access_token"] = new_access_token
|
|
129
|
+
set_session_cookie(response, session)
|
|
130
|
+
access_token = new_access_token
|
|
131
|
+
|
|
132
|
+
except Exception:
|
|
133
|
+
logging.exception("Token refresh failed for user %s", user_id)
|
|
134
|
+
clear_session_cookie(response)
|
|
135
|
+
_raise_unauthenticated(request)
|
|
136
|
+
|
|
137
|
+
config = get_config()
|
|
138
|
+
client = Client(
|
|
139
|
+
api_key=access_token,
|
|
140
|
+
refresh_token=refresh_token,
|
|
141
|
+
client_id=config.client_id,
|
|
142
|
+
client_secret=config.client_secret, # Client accepts SecretStr directly
|
|
143
|
+
)
|
|
144
|
+
return SweatStackUser(user_id=user_id, client=client)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def optional_user(request: Request, response: Response) -> SweatStackUser | None:
|
|
148
|
+
"""Dependency that optionally returns an authenticated user.
|
|
149
|
+
|
|
150
|
+
Returns a SweatStackUser if the session is valid, None otherwise.
|
|
151
|
+
Does not raise exceptions for missing or invalid sessions.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
return require_user(request, response)
|
|
155
|
+
except HTTPException:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Type aliases for use in route handlers
|
|
160
|
+
AuthenticatedUser: TypeAlias = Annotated[SweatStackUser, Depends(require_user)]
|
|
161
|
+
OptionalUser: TypeAlias = Annotated[SweatStackUser | None, Depends(optional_user)]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""OAuth routes for the FastAPI plugin."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastapi import APIRouter, FastAPI, Request, Response
|
|
10
|
+
from fastapi.responses import RedirectResponse
|
|
11
|
+
|
|
12
|
+
from ..constants import DEFAULT_URL
|
|
13
|
+
from ..utils import decode_jwt_body
|
|
14
|
+
from .config import get_config
|
|
15
|
+
from .session import (
|
|
16
|
+
STATE_COOKIE_NAME,
|
|
17
|
+
clear_session_cookie,
|
|
18
|
+
clear_state_cookie,
|
|
19
|
+
set_session_cookie,
|
|
20
|
+
set_state_cookie,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_redirect(url: str | None) -> str | None:
|
|
25
|
+
"""Validate that a redirect URL is a safe relative path.
|
|
26
|
+
|
|
27
|
+
Returns the URL if valid, None otherwise.
|
|
28
|
+
"""
|
|
29
|
+
if url and url.startswith("/") and not url.startswith("//"):
|
|
30
|
+
return url
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_state(next_url: str | None) -> str:
|
|
35
|
+
"""Create an OAuth state value with nonce and optional redirect."""
|
|
36
|
+
nonce = secrets.token_urlsafe(32)
|
|
37
|
+
state_data = {"nonce": nonce}
|
|
38
|
+
if next_url:
|
|
39
|
+
state_data["next"] = next_url
|
|
40
|
+
return base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_state(state: str) -> dict:
|
|
44
|
+
"""Parse an OAuth state value."""
|
|
45
|
+
try:
|
|
46
|
+
return json.loads(base64.urlsafe_b64decode(state.encode()))
|
|
47
|
+
except Exception:
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_router() -> APIRouter:
|
|
52
|
+
"""Create the auth router with login, callback, and logout routes."""
|
|
53
|
+
router = APIRouter()
|
|
54
|
+
|
|
55
|
+
@router.get("/login")
|
|
56
|
+
def login(request: Request, next: str | None = None) -> Response:
|
|
57
|
+
"""Redirect to SweatStack OAuth authorization."""
|
|
58
|
+
config = get_config()
|
|
59
|
+
|
|
60
|
+
# Validate and create state
|
|
61
|
+
validated_next = validate_redirect(next)
|
|
62
|
+
state = create_state(validated_next)
|
|
63
|
+
|
|
64
|
+
# Build authorization URL
|
|
65
|
+
params = {
|
|
66
|
+
"client_id": config.client_id,
|
|
67
|
+
"redirect_uri": config.redirect_uri,
|
|
68
|
+
"scope": " ".join(config.scopes),
|
|
69
|
+
"state": state,
|
|
70
|
+
"prompt": "none",
|
|
71
|
+
}
|
|
72
|
+
auth_url = f"{DEFAULT_URL}/oauth/authorize?{urlencode(params)}"
|
|
73
|
+
|
|
74
|
+
# Set state cookie and redirect
|
|
75
|
+
response = RedirectResponse(url=auth_url, status_code=302)
|
|
76
|
+
set_state_cookie(response, state)
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
@router.get("/callback")
|
|
80
|
+
def callback(
|
|
81
|
+
request: Request,
|
|
82
|
+
code: str | None = None,
|
|
83
|
+
state: str | None = None,
|
|
84
|
+
error: str | None = None,
|
|
85
|
+
) -> Response:
|
|
86
|
+
"""Handle OAuth callback from SweatStack."""
|
|
87
|
+
config = get_config()
|
|
88
|
+
|
|
89
|
+
# Get state cookie
|
|
90
|
+
state_cookie = request.cookies.get(STATE_COOKIE_NAME)
|
|
91
|
+
|
|
92
|
+
# Clear state cookie regardless of outcome
|
|
93
|
+
response = RedirectResponse(url="/", status_code=302)
|
|
94
|
+
clear_state_cookie(response)
|
|
95
|
+
|
|
96
|
+
# Handle OAuth errors
|
|
97
|
+
if error:
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
# Verify state (CSRF protection)
|
|
101
|
+
if not state or not state_cookie or state != state_cookie:
|
|
102
|
+
return Response(content="Invalid state", status_code=400)
|
|
103
|
+
|
|
104
|
+
# Exchange code for tokens
|
|
105
|
+
if not code:
|
|
106
|
+
return Response(content="Missing authorization code", status_code=400)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
token_response = httpx.post(
|
|
110
|
+
f"{DEFAULT_URL}/api/v1/oauth/token",
|
|
111
|
+
data={
|
|
112
|
+
"grant_type": "authorization_code",
|
|
113
|
+
"client_id": config.client_id,
|
|
114
|
+
"client_secret": config.client_secret.get_secret_value(),
|
|
115
|
+
"code": code,
|
|
116
|
+
"redirect_uri": config.redirect_uri,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
token_response.raise_for_status()
|
|
120
|
+
tokens = token_response.json()
|
|
121
|
+
except Exception:
|
|
122
|
+
return response # Redirect to / on token exchange failure
|
|
123
|
+
|
|
124
|
+
access_token = tokens.get("access_token")
|
|
125
|
+
refresh_token = tokens.get("refresh_token")
|
|
126
|
+
|
|
127
|
+
if not access_token:
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
# Extract user_id from JWT
|
|
131
|
+
try:
|
|
132
|
+
token_body = decode_jwt_body(access_token)
|
|
133
|
+
user_id = token_body.get("sub")
|
|
134
|
+
except Exception:
|
|
135
|
+
return response
|
|
136
|
+
|
|
137
|
+
if not user_id:
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
# Create session
|
|
141
|
+
session_data = {
|
|
142
|
+
"access_token": access_token,
|
|
143
|
+
"refresh_token": refresh_token,
|
|
144
|
+
"user_id": user_id,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Determine redirect URL from state
|
|
148
|
+
state_data = parse_state(state)
|
|
149
|
+
redirect_url = state_data.get("next", "/")
|
|
150
|
+
|
|
151
|
+
response = RedirectResponse(url=redirect_url, status_code=302)
|
|
152
|
+
clear_state_cookie(response)
|
|
153
|
+
set_session_cookie(response, session_data)
|
|
154
|
+
return response
|
|
155
|
+
|
|
156
|
+
@router.post("/logout")
|
|
157
|
+
def logout() -> Response:
|
|
158
|
+
"""Clear session and redirect to /."""
|
|
159
|
+
response = RedirectResponse(url="/", status_code=302)
|
|
160
|
+
clear_session_cookie(response)
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
return router
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def instrument(app: FastAPI) -> None:
|
|
167
|
+
"""Add SweatStack auth routes to a FastAPI application.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
app: The FastAPI application to instrument.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
RuntimeError: If configure() has not been called.
|
|
174
|
+
"""
|
|
175
|
+
config = get_config() # This will raise if not configured
|
|
176
|
+
router = create_router()
|
|
177
|
+
app.include_router(router, prefix=config.auth_route_prefix)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Session encryption and cookie helpers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
8
|
+
from fastapi import Response
|
|
9
|
+
|
|
10
|
+
from .config import get_config
|
|
11
|
+
|
|
12
|
+
SESSION_COOKIE_NAME = "sweatstack_session"
|
|
13
|
+
STATE_COOKIE_NAME = "sweatstack_oauth_state"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_fernet_instances() -> list[Fernet]:
|
|
17
|
+
"""Get Fernet instances for encryption/decryption."""
|
|
18
|
+
config = get_config()
|
|
19
|
+
secrets = config.session_secret
|
|
20
|
+
if not isinstance(secrets, list):
|
|
21
|
+
secrets = [secrets]
|
|
22
|
+
return [Fernet(secret.get_secret_value().encode()) for secret in secrets]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def encrypt_session(data: dict[str, Any]) -> str:
|
|
26
|
+
"""Encrypt session data.
|
|
27
|
+
|
|
28
|
+
Uses the first configured key for encryption.
|
|
29
|
+
"""
|
|
30
|
+
fernets = _get_fernet_instances()
|
|
31
|
+
json_data = json.dumps(data)
|
|
32
|
+
return fernets[0].encrypt(json_data.encode()).decode()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decrypt_session(encrypted: str | None) -> dict[str, Any] | None:
|
|
36
|
+
"""Decrypt session data.
|
|
37
|
+
|
|
38
|
+
Tries all configured keys for decryption (supports key rotation).
|
|
39
|
+
Returns None if decryption fails or data is missing.
|
|
40
|
+
"""
|
|
41
|
+
if not encrypted:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
fernets = _get_fernet_instances()
|
|
45
|
+
|
|
46
|
+
for fernet in fernets:
|
|
47
|
+
try:
|
|
48
|
+
decrypted = fernet.decrypt(encrypted.encode())
|
|
49
|
+
return json.loads(decrypted)
|
|
50
|
+
except InvalidToken:
|
|
51
|
+
continue
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logging.warning(f"Session decryption error: {e}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# All keys failed
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_session_cookie(response: Response, session_data: dict[str, Any]) -> None:
|
|
61
|
+
"""Set the encrypted session cookie on the response."""
|
|
62
|
+
config = get_config()
|
|
63
|
+
encrypted = encrypt_session(session_data)
|
|
64
|
+
response.set_cookie(
|
|
65
|
+
key=SESSION_COOKIE_NAME,
|
|
66
|
+
value=encrypted,
|
|
67
|
+
httponly=True,
|
|
68
|
+
secure=config.cookie_secure,
|
|
69
|
+
samesite="lax",
|
|
70
|
+
max_age=config.cookie_max_age,
|
|
71
|
+
path="/",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clear_session_cookie(response: Response) -> None:
|
|
76
|
+
"""Clear the session cookie."""
|
|
77
|
+
response.delete_cookie(
|
|
78
|
+
key=SESSION_COOKIE_NAME,
|
|
79
|
+
path="/",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def set_state_cookie(response: Response, state: str) -> None:
|
|
84
|
+
"""Set the OAuth state cookie (short-lived, 5 minutes)."""
|
|
85
|
+
config = get_config()
|
|
86
|
+
response.set_cookie(
|
|
87
|
+
key=STATE_COOKIE_NAME,
|
|
88
|
+
value=state,
|
|
89
|
+
httponly=True,
|
|
90
|
+
secure=config.cookie_secure,
|
|
91
|
+
samesite="lax",
|
|
92
|
+
max_age=300, # 5 minutes
|
|
93
|
+
path="/",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clear_state_cookie(response: Response) -> None:
|
|
98
|
+
"""Clear the OAuth state cookie."""
|
|
99
|
+
response.delete_cookie(
|
|
100
|
+
key=STATE_COOKIE_NAME,
|
|
101
|
+
path="/",
|
|
102
|
+
)
|
sweatstack/schemas.py
CHANGED
|
@@ -122,6 +122,22 @@ Sport.is_sub_sport_of.__doc__ = _is_sub_sport_of.__doc__
|
|
|
122
122
|
Sport.is_root_sport = _is_root_sport
|
|
123
123
|
Sport.is_root_sport.__doc__ = _is_root_sport.__doc__
|
|
124
124
|
|
|
125
|
+
@classmethod
|
|
126
|
+
def _sport_missing(cls, value: str):
|
|
127
|
+
"""Handle unknown sport values from newer API versions.
|
|
128
|
+
|
|
129
|
+
This allows the client to gracefully handle new sports added to the API
|
|
130
|
+
without requiring a client library update. Unknown values become dynamic
|
|
131
|
+
enum members that behave like regular Sport values.
|
|
132
|
+
"""
|
|
133
|
+
pseudo_member = object.__new__(cls)
|
|
134
|
+
pseudo_member._name_ = value
|
|
135
|
+
pseudo_member._value_ = value
|
|
136
|
+
cls._value2member_map_[value] = pseudo_member # Cache for future lookups
|
|
137
|
+
return pseudo_member
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
Sport._missing_ = _sport_missing
|
|
125
141
|
Sport.display_name = _display_name
|
|
126
142
|
Sport.display_name.__doc__ = _display_name.__doc__
|
|
127
143
|
|
|
@@ -134,5 +150,34 @@ def _metric_display_name(metric: Metric) -> str:
|
|
|
134
150
|
return metric.value.replace("_", " ")
|
|
135
151
|
|
|
136
152
|
|
|
153
|
+
@classmethod
|
|
154
|
+
def _metric_missing(cls, value: str):
|
|
155
|
+
"""Handle unknown metric values from newer API versions.
|
|
156
|
+
|
|
157
|
+
This allows the client to gracefully handle new metrics added to the API
|
|
158
|
+
without requiring a client library update. Unknown values become dynamic
|
|
159
|
+
enum members that behave like regular Metric values.
|
|
160
|
+
"""
|
|
161
|
+
pseudo_member = object.__new__(cls)
|
|
162
|
+
pseudo_member._name_ = value
|
|
163
|
+
pseudo_member._value_ = value
|
|
164
|
+
cls._value2member_map_[value] = pseudo_member # Cache for future lookups
|
|
165
|
+
return pseudo_member
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
Metric._missing_ = _metric_missing
|
|
137
169
|
Metric.display_name = _metric_display_name
|
|
138
|
-
Metric.display_name.__doc__ = _metric_display_name.__doc__
|
|
170
|
+
Metric.display_name.__doc__ = _metric_display_name.__doc__
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def _scope_missing(cls, value: str):
|
|
175
|
+
"""Handle unknown scope values from newer API versions."""
|
|
176
|
+
pseudo_member = object.__new__(cls)
|
|
177
|
+
pseudo_member._name_ = value
|
|
178
|
+
pseudo_member._value_ = value
|
|
179
|
+
cls._value2member_map_[value] = pseudo_member
|
|
180
|
+
return pseudo_member
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
Scope._missing_ = _scope_missing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sweatstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.60.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
|
|
@@ -10,6 +10,9 @@ Requires-Dist: pandas>=2.2.3
|
|
|
10
10
|
Requires-Dist: platformdirs>=4.0.0
|
|
11
11
|
Requires-Dist: pyarrow>=18.0.0
|
|
12
12
|
Requires-Dist: pydantic>=2.10.5
|
|
13
|
+
Provides-Extra: fastapi
|
|
14
|
+
Requires-Dist: cryptography>=41.0.0; extra == 'fastapi'
|
|
15
|
+
Requires-Dist: fastapi[standard]>=0.100.0; extra == 'fastapi'
|
|
13
16
|
Provides-Extra: jupyter
|
|
14
17
|
Requires-Dist: ipython>=8.31.0; extra == 'jupyter'
|
|
15
18
|
Requires-Dist: jupyterlab>=4.3.4; extra == 'jupyter'
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
sweatstack/__init__.py,sha256=tiVfgKlswRPaDMEy0gA7u8rveqEYZTA_kyB9lJ3J6Sc,21
|
|
2
2
|
sweatstack/cli.py,sha256=N1NWOgEZR2yaJvIXxo9qvp_jFlypZYb0nujpbVNYQ6A,720
|
|
3
|
-
sweatstack/client.py,sha256=
|
|
3
|
+
sweatstack/client.py,sha256=0SipHY_USSMnxEb3PdmQ4ogUZTB81nC-eAJjthwqy6g,68175
|
|
4
4
|
sweatstack/constants.py,sha256=fGO6ksOv5HeISv9lHRoYm4besW1GTveXS8YD3K0ljg0,41
|
|
5
5
|
sweatstack/ipython_init.py,sha256=OtBB9dQvyLXklD4kA2x1swaVtU9u73fG4V4-zz4YRAg,139
|
|
6
6
|
sweatstack/jupyterlab_oauth2_startup.py,sha256=YcjXvzeZ459vL_dCkFi1IxX_RNAu80ZX9rwa0OXJfTM,1023
|
|
7
7
|
sweatstack/openapi_schemas.py,sha256=iXxhnEVErlgapNnXWbb0bJRWwiGBUMXO7sdovL5q8U8,49475
|
|
8
8
|
sweatstack/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
sweatstack/schemas.py,sha256=
|
|
9
|
+
sweatstack/schemas.py,sha256=GISBS4XFFV7yOj8kPlzMWRaMBEYjkJTui7Zqppb02uY,5965
|
|
10
10
|
sweatstack/streamlit.py,sha256=wnabWhife9eMAdkECPjRKkzE82KZoi_H8YzucZl_m9s,19604
|
|
11
11
|
sweatstack/sweatshell.py,sha256=MYLNcWbOdceqKJ3S0Pe8dwHXEeYsGJNjQoYUXpMTftA,333
|
|
12
12
|
sweatstack/utils.py,sha256=AwHRdC1ziOZ5o9RBIB21Uxm-DoClVRAJSVvgsmSmvps,1801
|
|
13
13
|
sweatstack/Sweat Stack examples/Getting started.ipynb,sha256=k2hiSffWecoQ0VxjdpDcgFzBXDQiYEebhnAYlu8cgX8,6335204
|
|
14
|
-
sweatstack
|
|
15
|
-
sweatstack
|
|
16
|
-
sweatstack
|
|
17
|
-
sweatstack
|
|
14
|
+
sweatstack/fastapi/__init__.py,sha256=4_a6oqapT8Pv0sdt6OmuTA_vo4qFSiyXjN5WMLnl0rs,971
|
|
15
|
+
sweatstack/fastapi/config.py,sha256=9u_XXGpX2XdOJO8G2sL_Cx4L_hYUzktozS2MkIs7Fwk,6304
|
|
16
|
+
sweatstack/fastapi/dependencies.py,sha256=K9nuSV5Vduu4hoN94B8rj6UKtUBu9uKtSwEmECDG9vM,5060
|
|
17
|
+
sweatstack/fastapi/routes.py,sha256=ZyaowLYXlOM6a74Cog5uSzPKHNTBvOpIBruaJVzhKjE,5339
|
|
18
|
+
sweatstack/fastapi/session.py,sha256=BtRPCmIEaToJPwFyZ0fqWGlmnDHuWKy8nri9dJrPXaA,2717
|
|
19
|
+
sweatstack-0.60.0.dist-info/METADATA,sha256=XJq9khIsRFJDUVGLB9Vv_qwRutrIHlU4QwHRsLQKdeU,994
|
|
20
|
+
sweatstack-0.60.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
21
|
+
sweatstack-0.60.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
|
|
22
|
+
sweatstack-0.60.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|