saturday 0.1.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.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+ id-token: write
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment: pypi
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.12'
21
+
22
+ - name: Install build tools
23
+ run: pip install hatchling build
24
+
25
+ - name: Build package
26
+ run: python -m build
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ packages-dir: dist/
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ .env
11
+ .pytest_cache/
@@ -0,0 +1,20 @@
1
+ # Contributing
2
+
3
+ Thank you for your interest in contributing to the Saturday SDK.
4
+
5
+ ## Bug Reports
6
+
7
+ Please file an issue on this repository with:
8
+ - SDK version
9
+ - Language/runtime version
10
+ - Minimal reproduction steps
11
+ - Expected vs actual behavior
12
+
13
+ ## Development
14
+
15
+ This SDK is generated from Saturday's OpenAPI specification and polished for production use.
16
+ See `REGENERATION.md` in the root SDK directory for the update workflow.
17
+
18
+ ## Code of Conduct
19
+
20
+ Be kind. Be constructive. We're building tools that help athletes stay safe.
saturday-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saturday Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: saturday
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Saturday Nutrition Intelligence API
5
+ Project-URL: Documentation, https://docs.saturday.fit
6
+ Project-URL: Repository, https://github.com/SaturdayInc/saturday-python
7
+ Project-URL: Issues, https://github.com/SaturdayInc/saturday-python/issues
8
+ Author-email: "Saturday Inc." <api@saturday.fit>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,electrolytes,endurance,hydration,nutrition,saturday,sports-nutrition
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx>=0.25.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Saturday Python SDK
27
+
28
+ [![PyPI](https://img.shields.io/pypi/v/saturday)](https://pypi.org/project/saturday/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
30
+
31
+ Official Python SDK for the [Saturday Nutrition Intelligence API](https://docs.saturday.fit).
32
+
33
+ Personalized fuel, hydration, and electrolyte prescriptions for endurance athletes.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install saturday
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ from saturday import Saturday
45
+
46
+ client = Saturday(api_key="sat_live_...")
47
+
48
+ # Calculate a nutrition prescription
49
+ prescription = client.nutrition.calculate(
50
+ activity_type="bike",
51
+ duration_min=180,
52
+ athlete_weight_kg=75,
53
+ thermal_stress_level=7,
54
+ is_race=True,
55
+ )
56
+
57
+ # Safety metadata is ALWAYS included — athlete safety is never paywalled
58
+ print(prescription["safety"]["warnings"])
59
+ print(f"Carbs: {prescription['carbohydrate']['target_g_per_hr']} g/hr")
60
+ print(f"Sodium: {prescription['sodium']['target_mg_per_hr']} mg/hr")
61
+ print(f"Fluid: {prescription['fluid']['target_ml_per_hr']} mL/hr")
62
+ ```
63
+
64
+ ## Features
65
+
66
+ - Fully typed with `py.typed` marker (PEP 561)
67
+ - Automatic retry with exponential backoff (429s and 5xx)
68
+ - Typed errors (`AuthenticationError`, `RateLimitError`, `ValidationError`, `NotFoundError`)
69
+ - API key and OAuth2 Bearer token authentication
70
+ - Context manager support for clean connection handling
71
+ - Safety types prominently surfaced (`not_instructions` documented)
72
+
73
+ ## Authentication
74
+
75
+ ```python
76
+ # API key (server-to-server)
77
+ client = Saturday(api_key="sat_live_...")
78
+
79
+ # OAuth2 Bearer token (athlete-delegated access)
80
+ client = Saturday(api_key="sat_live_...", bearer_token="eyJ...")
81
+
82
+ # Context manager for automatic cleanup
83
+ with Saturday(api_key="sat_live_...") as client:
84
+ rx = client.nutrition.calculate(activity_type="run", duration_min=60)
85
+ ```
86
+
87
+ ## Error Handling
88
+
89
+ ```python
90
+ from saturday import Saturday, RateLimitError, ValidationError, NotFoundError
91
+
92
+ client = Saturday(api_key="sat_live_...")
93
+
94
+ try:
95
+ rx = client.nutrition.calculate(activity_type="swim", duration_min=60)
96
+ except ValidationError as e:
97
+ print(f"Invalid request: {e} (param: {e.param})")
98
+ except RateLimitError as e:
99
+ print(f"Rate limited. Retry after {e.retry_after}s")
100
+ except NotFoundError:
101
+ print("Resource not found")
102
+ ```
103
+
104
+ ## Resources
105
+
106
+ | Resource | Description |
107
+ |----------|-------------|
108
+ | `client.nutrition` | Calculate prescriptions, batch, compare |
109
+ | `client.athletes` | Athlete CRUD, settings, batch create, GDPR export |
110
+ | `client.activities` | Activity CRUD, prescription calculation, feedback |
111
+ | `client.products` | Product search, barcode lookup, curated list |
112
+ | `client.ai` | AI coaching conversations |
113
+ | `client.webhooks` | Webhook registration and management |
114
+ | `client.organizations` | Team/org management with members |
115
+ | `client.gear` | Athlete gear inventory |
116
+ | `client.knowledge` | Sports nutrition knowledge base search |
117
+
118
+ ## Documentation
119
+
120
+ Full API documentation: [docs.saturday.fit](https://docs.saturday.fit)
121
+
122
+ ## Requirements
123
+
124
+ - Python 3.9+
125
+ - httpx 0.25+
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,104 @@
1
+ # Saturday Python SDK
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/saturday)](https://pypi.org/project/saturday/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ Official Python SDK for the [Saturday Nutrition Intelligence API](https://docs.saturday.fit).
7
+
8
+ Personalized fuel, hydration, and electrolyte prescriptions for endurance athletes.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install saturday
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```python
19
+ from saturday import Saturday
20
+
21
+ client = Saturday(api_key="sat_live_...")
22
+
23
+ # Calculate a nutrition prescription
24
+ prescription = client.nutrition.calculate(
25
+ activity_type="bike",
26
+ duration_min=180,
27
+ athlete_weight_kg=75,
28
+ thermal_stress_level=7,
29
+ is_race=True,
30
+ )
31
+
32
+ # Safety metadata is ALWAYS included — athlete safety is never paywalled
33
+ print(prescription["safety"]["warnings"])
34
+ print(f"Carbs: {prescription['carbohydrate']['target_g_per_hr']} g/hr")
35
+ print(f"Sodium: {prescription['sodium']['target_mg_per_hr']} mg/hr")
36
+ print(f"Fluid: {prescription['fluid']['target_ml_per_hr']} mL/hr")
37
+ ```
38
+
39
+ ## Features
40
+
41
+ - Fully typed with `py.typed` marker (PEP 561)
42
+ - Automatic retry with exponential backoff (429s and 5xx)
43
+ - Typed errors (`AuthenticationError`, `RateLimitError`, `ValidationError`, `NotFoundError`)
44
+ - API key and OAuth2 Bearer token authentication
45
+ - Context manager support for clean connection handling
46
+ - Safety types prominently surfaced (`not_instructions` documented)
47
+
48
+ ## Authentication
49
+
50
+ ```python
51
+ # API key (server-to-server)
52
+ client = Saturday(api_key="sat_live_...")
53
+
54
+ # OAuth2 Bearer token (athlete-delegated access)
55
+ client = Saturday(api_key="sat_live_...", bearer_token="eyJ...")
56
+
57
+ # Context manager for automatic cleanup
58
+ with Saturday(api_key="sat_live_...") as client:
59
+ rx = client.nutrition.calculate(activity_type="run", duration_min=60)
60
+ ```
61
+
62
+ ## Error Handling
63
+
64
+ ```python
65
+ from saturday import Saturday, RateLimitError, ValidationError, NotFoundError
66
+
67
+ client = Saturday(api_key="sat_live_...")
68
+
69
+ try:
70
+ rx = client.nutrition.calculate(activity_type="swim", duration_min=60)
71
+ except ValidationError as e:
72
+ print(f"Invalid request: {e} (param: {e.param})")
73
+ except RateLimitError as e:
74
+ print(f"Rate limited. Retry after {e.retry_after}s")
75
+ except NotFoundError:
76
+ print("Resource not found")
77
+ ```
78
+
79
+ ## Resources
80
+
81
+ | Resource | Description |
82
+ |----------|-------------|
83
+ | `client.nutrition` | Calculate prescriptions, batch, compare |
84
+ | `client.athletes` | Athlete CRUD, settings, batch create, GDPR export |
85
+ | `client.activities` | Activity CRUD, prescription calculation, feedback |
86
+ | `client.products` | Product search, barcode lookup, curated list |
87
+ | `client.ai` | AI coaching conversations |
88
+ | `client.webhooks` | Webhook registration and management |
89
+ | `client.organizations` | Team/org management with members |
90
+ | `client.gear` | Athlete gear inventory |
91
+ | `client.knowledge` | Sports nutrition knowledge base search |
92
+
93
+ ## Documentation
94
+
95
+ Full API documentation: [docs.saturday.fit](https://docs.saturday.fit)
96
+
97
+ ## Requirements
98
+
99
+ - Python 3.9+
100
+ - httpx 0.25+
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,10 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you discover a security vulnerability in the Saturday SDK, please report it responsibly:
6
+
7
+ **Email**: security@saturday.fit
8
+ **Response time**: We will acknowledge within 48 hours and provide a detailed response within 5 business days.
9
+
10
+ Please do NOT file a public GitHub issue for security vulnerabilities.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "saturday"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Saturday Nutrition Intelligence API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Saturday Inc.", email = "api@saturday.fit" },
14
+ ]
15
+ keywords = [
16
+ "saturday",
17
+ "nutrition",
18
+ "endurance",
19
+ "hydration",
20
+ "electrolytes",
21
+ "sports-nutrition",
22
+ "api",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Typing :: Typed",
35
+ ]
36
+ dependencies = [
37
+ "httpx>=0.25.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Documentation = "https://docs.saturday.fit"
42
+ Repository = "https://github.com/SaturdayInc/saturday-python"
43
+ Issues = "https://github.com/SaturdayInc/saturday-python/issues"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["saturday"]
@@ -0,0 +1,43 @@
1
+ """
2
+ Saturday Nutrition Intelligence API — Official Python SDK
3
+
4
+ Personalized fuel, hydration, and electrolyte prescriptions for endurance
5
+ athletes. Calculate carbohydrate, sodium, and fluid targets based on activity
6
+ type, duration, athlete profile, and environmental conditions.
7
+
8
+ Example::
9
+
10
+ from saturday import Saturday
11
+
12
+ client = Saturday(api_key="sat_live_...")
13
+
14
+ prescription = client.nutrition.calculate(
15
+ activity_type="bike",
16
+ duration_min=180,
17
+ athlete_weight_kg=75.0,
18
+ thermal_stress_level=7,
19
+ )
20
+
21
+ # Safety metadata is ALWAYS included — athlete safety cannot be paywalled
22
+ print(prescription["safety"]["warnings"])
23
+ print(f"Carbs: {prescription['carbohydrate']['target_g_per_hr']} g/hr")
24
+ """
25
+
26
+ from saturday.client import Saturday
27
+ from saturday.errors import (
28
+ SaturdayError,
29
+ AuthenticationError,
30
+ RateLimitError,
31
+ ValidationError,
32
+ NotFoundError,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+ __all__ = [
37
+ "Saturday",
38
+ "SaturdayError",
39
+ "AuthenticationError",
40
+ "RateLimitError",
41
+ "ValidationError",
42
+ "NotFoundError",
43
+ ]
@@ -0,0 +1,419 @@
1
+ """
2
+ Saturday API client with automatic retry and typed resource accessors.
3
+
4
+ Safety metadata is ALWAYS included in nutrition responses — athlete safety
5
+ cannot be paywalled. The ``not_instructions`` field is present in every
6
+ nutrition response for AI consumers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import httpx
15
+
16
+ from saturday.errors import SaturdayError
17
+
18
+ SDK_VERSION = "0.1.0"
19
+ DEFAULT_BASE_URL = "https://api.saturday.fit"
20
+ DEFAULT_TIMEOUT = 30.0
21
+ DEFAULT_MAX_RETRIES = 3
22
+
23
+
24
+ class Saturday:
25
+ """
26
+ Saturday Nutrition Intelligence API client.
27
+
28
+ Args:
29
+ api_key: Your partner API key (sat_live_... or sat_test_...).
30
+ base_url: Base URL override. Defaults to https://api.saturday.fit.
31
+ timeout: Request timeout in seconds. Defaults to 30.
32
+ max_retries: Maximum retry attempts for transient failures. Defaults to 3.
33
+ bearer_token: OAuth2 Bearer token (alternative to API key).
34
+
35
+ Example::
36
+
37
+ client = Saturday(api_key="sat_live_...")
38
+ rx = client.nutrition.calculate(
39
+ activity_type="run",
40
+ duration_min=90,
41
+ thermal_stress_level=8,
42
+ )
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ api_key: str,
48
+ *,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ timeout: float = DEFAULT_TIMEOUT,
51
+ max_retries: int = DEFAULT_MAX_RETRIES,
52
+ bearer_token: Optional[str] = None,
53
+ ):
54
+ self._api_key = api_key
55
+ self._base_url = base_url.rstrip("/")
56
+ self._timeout = timeout
57
+ self._max_retries = max_retries
58
+ self._bearer_token = bearer_token
59
+
60
+ headers = {
61
+ "User-Agent": f"saturday-python/{SDK_VERSION}",
62
+ "X-SDK-Version": SDK_VERSION,
63
+ "Content-Type": "application/json",
64
+ }
65
+ if bearer_token:
66
+ headers["Authorization"] = f"Bearer {bearer_token}"
67
+ else:
68
+ headers["X-API-Key"] = api_key
69
+
70
+ self._client = httpx.Client(
71
+ base_url=self._base_url,
72
+ headers=headers,
73
+ timeout=timeout,
74
+ )
75
+
76
+ # Resource accessors
77
+ self.nutrition = _NutritionResource(self)
78
+ self.athletes = _AthletesResource(self)
79
+ self.activities = _ActivitiesResource(self)
80
+ self.products = _ProductsResource(self)
81
+ self.ai = _AIResource(self)
82
+ self.webhooks = _WebhooksResource(self)
83
+ self.organizations = _OrganizationsResource(self)
84
+ self.gear = _GearResource(self)
85
+ self.knowledge = _KnowledgeResource(self)
86
+
87
+ def close(self) -> None:
88
+ """Close the underlying HTTP client."""
89
+ self._client.close()
90
+
91
+ def __enter__(self) -> "Saturday":
92
+ return self
93
+
94
+ def __exit__(self, *args: Any) -> None:
95
+ self.close()
96
+
97
+ def request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ json: Optional[Dict[str, Any]] = None,
103
+ params: Optional[Dict[str, Any]] = None,
104
+ ) -> Any:
105
+ """Make an authenticated API request with automatic retry on 429/5xx."""
106
+ last_error: Optional[SaturdayError] = None
107
+
108
+ for attempt in range(self._max_retries + 1):
109
+ if attempt > 0:
110
+ delay = (2 ** (attempt - 1))
111
+ time.sleep(delay)
112
+
113
+ try:
114
+ response = self._client.request(
115
+ method,
116
+ path,
117
+ json=json,
118
+ params=params,
119
+ )
120
+
121
+ if response.is_success:
122
+ if response.status_code == 204:
123
+ return None
124
+ return response.json()
125
+
126
+ # Parse error
127
+ try:
128
+ error_body = response.json()
129
+ except Exception:
130
+ error_body = {"error": {"type": "api_error", "code": "unknown", "message": response.text}}
131
+
132
+ error = SaturdayError.from_response(response.status_code, error_body)
133
+ last_error = error
134
+
135
+ # Only retry on rate limit or server errors
136
+ if response.status_code == 429 or response.status_code >= 500:
137
+ continue
138
+
139
+ raise error
140
+
141
+ except httpx.TimeoutException:
142
+ raise SaturdayError(
143
+ message=f"Request timed out after {self._timeout}s",
144
+ code="timeout",
145
+ )
146
+ except SaturdayError:
147
+ raise
148
+ except httpx.HTTPError as e:
149
+ raise SaturdayError(message=str(e), code="connection_error")
150
+
151
+ # All retries exhausted
152
+ if last_error:
153
+ raise last_error
154
+ raise SaturdayError(message="Max retries exceeded", code="max_retries_exceeded")
155
+
156
+
157
+ # --- Resource Classes ---
158
+
159
+
160
+ class _NutritionResource:
161
+ def __init__(self, client: Saturday):
162
+ self._client = client
163
+
164
+ def calculate(self, **kwargs: Any) -> Dict[str, Any]:
165
+ """Calculate a personalized fuel/hydration/electrolyte prescription."""
166
+ return self._client.request("POST", "/v1/nutrition/calculate", json=kwargs)
167
+
168
+ def batch_calculate(self, scenarios: List[Dict[str, Any]]) -> Dict[str, Any]:
169
+ """Batch calculate prescriptions for multiple scenarios (max 50)."""
170
+ return self._client.request("POST", "/v1/nutrition/calculate/batch", json={"scenarios": scenarios})
171
+
172
+ def compare(self, scenarios: List[Dict[str, Any]]) -> Dict[str, Any]:
173
+ """Compare prescriptions across different scenarios."""
174
+ return self._client.request("POST", "/v1/nutrition/calculate/compare", json={"scenarios": scenarios})
175
+
176
+
177
+ class _AthletesResource:
178
+ def __init__(self, client: Saturday):
179
+ self._client = client
180
+
181
+ def create(self, **kwargs: Any) -> Dict[str, Any]:
182
+ """Create a new athlete under your partner account."""
183
+ return self._client.request("POST", "/v1/athletes", json=kwargs)
184
+
185
+ def get(self, athlete_id: str) -> Dict[str, Any]:
186
+ """Get an athlete by ID."""
187
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}")
188
+
189
+ def list(self, *, limit: int = 50, offset: int = 0, search: Optional[str] = None) -> Dict[str, Any]:
190
+ """List athletes for your partner account."""
191
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
192
+ if search:
193
+ params["search"] = search
194
+ return self._client.request("GET", "/v1/athletes", params=params)
195
+
196
+ def update(self, athlete_id: str, **kwargs: Any) -> Dict[str, Any]:
197
+ """Partially update an athlete's profile."""
198
+ return self._client.request("PATCH", f"/v1/athletes/{athlete_id}", json=kwargs)
199
+
200
+ def delete(self, athlete_id: str) -> None:
201
+ """Delete an athlete and all associated data."""
202
+ self._client.request("DELETE", f"/v1/athletes/{athlete_id}")
203
+
204
+ def get_settings(self, athlete_id: str) -> Dict[str, Any]:
205
+ """Get an athlete's fueling preference settings."""
206
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}/settings")
207
+
208
+ def update_settings(self, athlete_id: str, **kwargs: Any) -> Dict[str, Any]:
209
+ """Update an athlete's fueling preference settings."""
210
+ return self._client.request("PATCH", f"/v1/athletes/{athlete_id}/settings", json=kwargs)
211
+
212
+ def batch_create(self, athletes: List[Dict[str, Any]]) -> Dict[str, Any]:
213
+ """Batch create up to 100 athletes."""
214
+ return self._client.request("POST", "/v1/athletes/batch", json={"athletes": athletes})
215
+
216
+ def export(self, athlete_id: str) -> Dict[str, Any]:
217
+ """Export all athlete data (GDPR data portability)."""
218
+ return self._client.request("POST", f"/v1/athletes/{athlete_id}/export")
219
+
220
+
221
+ class _ActivitiesResource:
222
+ def __init__(self, client: Saturday):
223
+ self._client = client
224
+
225
+ def create(self, athlete_id: str, **kwargs: Any) -> Dict[str, Any]:
226
+ """Create a new activity for an athlete."""
227
+ return self._client.request("POST", f"/v1/athletes/{athlete_id}/activities", json=kwargs)
228
+
229
+ def get(self, athlete_id: str, activity_id: str) -> Dict[str, Any]:
230
+ """Get an activity by ID."""
231
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}/activities/{activity_id}")
232
+
233
+ def list(self, athlete_id: str, *, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
234
+ """List activities for an athlete."""
235
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}/activities", params={"limit": limit, "offset": offset})
236
+
237
+ def update(self, athlete_id: str, activity_id: str, **kwargs: Any) -> Dict[str, Any]:
238
+ """Partially update an activity."""
239
+ return self._client.request("PATCH", f"/v1/athletes/{athlete_id}/activities/{activity_id}", json=kwargs)
240
+
241
+ def delete(self, athlete_id: str, activity_id: str) -> None:
242
+ """Delete an activity and its prescription."""
243
+ self._client.request("DELETE", f"/v1/athletes/{athlete_id}/activities/{activity_id}")
244
+
245
+ def calculate_prescription(self, athlete_id: str, activity_id: str) -> Dict[str, Any]:
246
+ """Calculate/recalculate a nutrition prescription for this activity."""
247
+ return self._client.request("POST", f"/v1/athletes/{athlete_id}/activities/{activity_id}/prescription")
248
+
249
+ def get_prescription(self, athlete_id: str, activity_id: str) -> Dict[str, Any]:
250
+ """Get the stored prescription for an activity."""
251
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}/activities/{activity_id}/prescription")
252
+
253
+ def submit_feedback(self, athlete_id: str, activity_id: str, **kwargs: Any) -> Dict[str, Any]:
254
+ """Submit post-activity feedback on prescription quality."""
255
+ return self._client.request("POST", f"/v1/athletes/{athlete_id}/activities/{activity_id}/feedback", json=kwargs)
256
+
257
+
258
+ class _ProductsResource:
259
+ def __init__(self, client: Saturday):
260
+ self._client = client
261
+
262
+ def get_by_barcode(self, barcode: str) -> Dict[str, Any]:
263
+ """Look up a product by barcode."""
264
+ return self._client.request("GET", f"/v1/products/{barcode}")
265
+
266
+ def search(self, query: str, *, category: Optional[str] = None, limit: int = 20) -> Dict[str, Any]:
267
+ """Search the product database."""
268
+ params: Dict[str, Any] = {"q": query, "limit": limit}
269
+ if category:
270
+ params["category"] = category
271
+ return self._client.request("GET", "/v1/products/search", params=params)
272
+
273
+ def list_curated(self, *, category: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
274
+ """List Saturday's curated product database."""
275
+ params: Dict[str, Any] = {"limit": limit}
276
+ if category:
277
+ params["category"] = category
278
+ return self._client.request("GET", "/v1/products/curated", params=params)
279
+
280
+ def list_categories(self) -> Dict[str, Any]:
281
+ """List product categories."""
282
+ return self._client.request("GET", "/v1/products/categories")
283
+
284
+
285
+ class _AIResource:
286
+ def __init__(self, client: Saturday):
287
+ self._client = client
288
+
289
+ def create_conversation(self, athlete_id: str, initial_message: Optional[str] = None) -> Dict[str, Any]:
290
+ """Start a new AI coaching conversation for an athlete."""
291
+ body: Dict[str, Any] = {"athlete_id": athlete_id}
292
+ if initial_message:
293
+ body["initial_message"] = initial_message
294
+ return self._client.request("POST", "/v1/ai/conversations", json=body)
295
+
296
+ def send_message(self, conv_id: str, message: str) -> Dict[str, Any]:
297
+ """Send a message and receive the AI response."""
298
+ return self._client.request("POST", f"/v1/ai/conversations/{conv_id}/messages", json={"message": message})
299
+
300
+ def get_messages(self, conv_id: str, *, limit: int = 50) -> Dict[str, Any]:
301
+ """Get conversation history."""
302
+ return self._client.request("GET", f"/v1/ai/conversations/{conv_id}/messages", params={"limit": limit})
303
+
304
+ def get_conversation(self, conv_id: str) -> Dict[str, Any]:
305
+ """Get conversation metadata."""
306
+ return self._client.request("GET", f"/v1/ai/conversations/{conv_id}")
307
+
308
+ def delete_conversation(self, conv_id: str) -> None:
309
+ """Delete a conversation."""
310
+ self._client.request("DELETE", f"/v1/ai/conversations/{conv_id}")
311
+
312
+ def list_conversations(self, athlete_id: str, *, limit: int = 20) -> Dict[str, Any]:
313
+ """List conversations for an athlete."""
314
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}/ai/conversations", params={"limit": limit})
315
+
316
+
317
+ class _WebhooksResource:
318
+ def __init__(self, client: Saturday):
319
+ self._client = client
320
+
321
+ def create(self, url: str, events: List[str], description: Optional[str] = None) -> Dict[str, Any]:
322
+ """Register a webhook endpoint."""
323
+ body: Dict[str, Any] = {"url": url, "events": events}
324
+ if description:
325
+ body["description"] = description
326
+ return self._client.request("POST", "/v1/webhooks", json=body)
327
+
328
+ def list(self) -> Dict[str, Any]:
329
+ """List registered webhooks."""
330
+ return self._client.request("GET", "/v1/webhooks")
331
+
332
+ def get(self, webhook_id: str) -> Dict[str, Any]:
333
+ """Get webhook details."""
334
+ return self._client.request("GET", f"/v1/webhooks/{webhook_id}")
335
+
336
+ def update(self, webhook_id: str, **kwargs: Any) -> Dict[str, Any]:
337
+ """Update a webhook."""
338
+ return self._client.request("PATCH", f"/v1/webhooks/{webhook_id}", json=kwargs)
339
+
340
+ def delete(self, webhook_id: str) -> None:
341
+ """Delete a webhook."""
342
+ self._client.request("DELETE", f"/v1/webhooks/{webhook_id}")
343
+
344
+ def test(self, webhook_id: str) -> Dict[str, Any]:
345
+ """Send a test event."""
346
+ return self._client.request("POST", f"/v1/webhooks/{webhook_id}/test")
347
+
348
+
349
+ class _OrganizationsResource:
350
+ def __init__(self, client: Saturday):
351
+ self._client = client
352
+
353
+ def create(self, display_name: str, **kwargs: Any) -> Dict[str, Any]:
354
+ """Create an organization."""
355
+ return self._client.request("POST", "/v1/organizations", json={"display_name": display_name, **kwargs})
356
+
357
+ def list(self) -> Dict[str, Any]:
358
+ """List organizations."""
359
+ return self._client.request("GET", "/v1/organizations")
360
+
361
+ def get(self, org_id: str) -> Dict[str, Any]:
362
+ """Get organization details."""
363
+ return self._client.request("GET", f"/v1/organizations/{org_id}")
364
+
365
+ def add_member(self, org_id: str, email: str, role: str, athlete_id: Optional[str] = None) -> Dict[str, Any]:
366
+ """Add a member to an organization."""
367
+ body: Dict[str, Any] = {"email": email, "role": role}
368
+ if athlete_id:
369
+ body["athlete_id"] = athlete_id
370
+ return self._client.request("POST", f"/v1/organizations/{org_id}/members", json=body)
371
+
372
+ def list_members(self, org_id: str) -> Dict[str, Any]:
373
+ """List organization members."""
374
+ return self._client.request("GET", f"/v1/organizations/{org_id}/members")
375
+
376
+ def remove_member(self, org_id: str, member_id: str) -> None:
377
+ """Remove a member from an organization."""
378
+ self._client.request("DELETE", f"/v1/organizations/{org_id}/members/{member_id}")
379
+
380
+
381
+ class _GearResource:
382
+ def __init__(self, client: Saturday):
383
+ self._client = client
384
+
385
+ def list(self, athlete_id: str) -> Dict[str, Any]:
386
+ """List athlete's gear."""
387
+ return self._client.request("GET", f"/v1/athletes/{athlete_id}/gear")
388
+
389
+ def create(self, athlete_id: str, **kwargs: Any) -> Dict[str, Any]:
390
+ """Add a gear item."""
391
+ return self._client.request("POST", f"/v1/athletes/{athlete_id}/gear", json=kwargs)
392
+
393
+ def update(self, athlete_id: str, gear_id: str, **kwargs: Any) -> Dict[str, Any]:
394
+ """Update a gear item."""
395
+ return self._client.request("PATCH", f"/v1/athletes/{athlete_id}/gear/{gear_id}", json=kwargs)
396
+
397
+ def delete(self, athlete_id: str, gear_id: str) -> None:
398
+ """Delete a gear item."""
399
+ self._client.request("DELETE", f"/v1/athletes/{athlete_id}/gear/{gear_id}")
400
+
401
+
402
+ class _KnowledgeResource:
403
+ def __init__(self, client: Saturday):
404
+ self._client = client
405
+
406
+ def search(self, query: str, *, limit: int = 5, category: Optional[str] = None) -> Dict[str, Any]:
407
+ """Search the nutrition knowledge base."""
408
+ body: Dict[str, Any] = {"query": query, "limit": limit}
409
+ if category:
410
+ body["category"] = category
411
+ return self._client.request("POST", "/v1/knowledge/search", json=body)
412
+
413
+ def list_topics(self) -> Dict[str, Any]:
414
+ """List knowledge base topics."""
415
+ return self._client.request("GET", "/v1/knowledge/topics")
416
+
417
+ def get_article(self, article_id: str) -> Dict[str, Any]:
418
+ """Get a knowledge article."""
419
+ return self._client.request("GET", f"/v1/knowledge/articles/{article_id}")
@@ -0,0 +1,77 @@
1
+ """
2
+ Typed error classes for Saturday API errors.
3
+
4
+ All errors follow the Stripe-style format with type, code, message,
5
+ and optional param/documentation_url fields.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from typing import Optional
10
+
11
+
12
+ class SaturdayError(Exception):
13
+ """Base error for all Saturday API errors."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ status: int = 0,
19
+ error_type: str = "api_error",
20
+ code: str = "unknown",
21
+ param: Optional[str] = None,
22
+ request_id: Optional[str] = None,
23
+ ):
24
+ super().__init__(message)
25
+ self.status = status
26
+ self.error_type = error_type
27
+ self.code = code
28
+ self.param = param
29
+ self.request_id = request_id
30
+
31
+ @classmethod
32
+ def from_response(cls, status: int, error_body: dict) -> "SaturdayError":
33
+ """Create the appropriate error subclass from an API error response."""
34
+ detail = error_body.get("error", error_body)
35
+ msg = detail.get("message", "Unknown error")
36
+ kwargs = {
37
+ "message": msg,
38
+ "status": status,
39
+ "error_type": detail.get("type", "api_error"),
40
+ "code": detail.get("code", "unknown"),
41
+ "param": detail.get("param"),
42
+ "request_id": detail.get("request_id"),
43
+ }
44
+
45
+ if status == 401:
46
+ return AuthenticationError(**kwargs)
47
+ elif status == 404:
48
+ return NotFoundError(**kwargs)
49
+ elif status == 429:
50
+ return RateLimitError(**kwargs)
51
+ elif status in (400, 422):
52
+ return ValidationError(**kwargs)
53
+ else:
54
+ return cls(**kwargs)
55
+
56
+
57
+ class AuthenticationError(SaturdayError):
58
+ """Missing or invalid API key / Bearer token."""
59
+ pass
60
+
61
+
62
+ class RateLimitError(SaturdayError):
63
+ """Rate limit exceeded. Check retry_after for seconds to wait."""
64
+
65
+ def __init__(self, *args, retry_after: int = 60, **kwargs):
66
+ super().__init__(*args, **kwargs)
67
+ self.retry_after = retry_after
68
+
69
+
70
+ class ValidationError(SaturdayError):
71
+ """Request validation failed (400 or 422)."""
72
+ pass
73
+
74
+
75
+ class NotFoundError(SaturdayError):
76
+ """Resource not found (404)."""
77
+ pass
File without changes