Skip to content

Commit ec6f088

Browse files
committed
Refactor curl debug functionality into CurlDebugMixin
Extract curl command generation functionality from BaseResource to a dedicated mixin in utils/curl_debug_mixin.py. This improves code organization by: 1. Moving a single-purpose feature into its own module 2. Simplifying the BaseResource class 3. Making the debug functionality more reusable Also updated tests to maintain 100% coverage. Improve type safety with specialized dictionary types Replace generic Optional[Dict[str, Any]] with specialized type aliases throughout the codebase: 1. ParamDict for query string parameters 2. FormDataDict for form data parameters 3. JSONDict for JSON data structures These changes provide better static type checking, improve code readability and maintainability, and fix all mypy type errors related to dictionary types. Add test for curl debug mixin without OAuth Added test case for the fallback path in CurlDebugMixin when OAuth token is not available, ensuring 100% test coverage for the mixin class. Standardize _make_request call formatting - Make endpoint the only positional argument - Use keyword arguments for all other parameters - Adopt consistent multi-line formatting - Maintain 100% test coverage This change improves code readability and maintainability by ensuring a consistent approach to API calls throughout the codebase. Add *,cover files to .gitignore These files are coverage artifacts that should not be tracked in the repo.
1 parent 642d7f2 commit ec6f088

File tree

17 files changed

+316
-183
lines changed

17 files changed

+316
-183
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ coverage.xml
4444
htmlcov/
4545
.pytest_cache/
4646
.mypy_cache/
47+
*,cover
4748

4849
# Mac
4950
.DS_Store

TODO.md

Lines changed: 12 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,31 @@
11
# Project TODO and Notes
22

3-
## Refactoring TODOs
3+
## TODOs:
44

5-
- Typing
5+
- PyPi deployment
66

7-
- Try to get rid of `Optional[Dict[str, Any]]` args
7+
- For all `create_...`methods, add the ID from the response to logs and maybe
8+
something human readable, like the first n characters of the name??. Right
9+
now:
10+
11+
```log
12+
[2025-02-05 06:09:34,828] INFO [fitbit_client.NutritionResource] create_food_log succeeded for foods/log.json (status 201)
13+
```
814

915
- base.py: reorganize and see what we can move out.
1016

1117
- Rename to `_base`? Files it first, makes it clearer that everything in it is
1218
private
13-
- Move the methods for building `curl` commands into a mixin? It's a lot of
14-
code for an isolated and tightly scoped feature.
15-
- refactor `_make_request`.
16-
- do we need both `data` and `json`? Also, could we simplify a lot of typing
17-
if we separated GET, POST, and DELETE methods? Maybe even a separate,
18-
second non-auth GET? Could use `@overload`
19-
- we had to makee a `ParamDict` type in `nutrition.py`. Use this everywhere?
20-
- start by looking at how many methods use which params
2119

2220
- client.py:
2321

2422
- Creat and Test that all methods have an alias in `Client` and that the
2523
signatures match
2624

27-
```python
28-
if not food_id and not (food_name and calories):
29-
raise ClientValidationException(
30-
"Must provide either food_id or (food_name and calories)"
31-
)
32-
```
33-
34-
- nutrition.py:
35-
- It doesn't seem like this should be passing tests when CALORIES is not an int:
25+
- CI:
3626

37-
```python
38-
# Handle both enum and string nutritional values
39-
for key, value in nutritional_values.items():
40-
if isinstance(key, NutritionalValue):
41-
params[key.value] = float(value)
42-
else:
43-
params[str(key)] = float(value)
44-
```
45-
46-
see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource)
27+
* Read and implement:
28+
https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/configuring-advanced-setup-for-code-scanning#configuring-advanced-setup-for-code-scanning-with-codeql
4729

4830
- exceptions.py Consider:
4931
- Add automatic token refresh for ExpiredTokenException
@@ -77,14 +59,6 @@ see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource)
7759
be reused.
7860
- We may need a public version of a generic `make_request` method.
7961

80-
- For all `create_...`methods, add the ID from the response to logs and maybe
81-
something human readable, like the first n characters of the name??. Right
82-
now:
83-
84-
```log
85-
[2025-02-05 06:09:34,828] INFO [fitbit_client.NutritionResource] create_food_log succeeded for foods/log.json (status 201)
86-
```
87-
8862
- Form to change scopes are part of OAuth flow? Maybe get rid of the cut and
8963
paste method altogether? It's less to test...
9064

@@ -101,19 +75,4 @@ see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource)
10175
one. (there might be several of these that make sense--just take an ID and
10276
then the signature of the "create" method).
10377

104-
- PyPI deployment
105-
10678
- Enum for units? (it'll be big, maybe just common ones?)
107-
108-
## CI/CD/Linting
109-
110-
- GitHub Actions Setup
111-
112-
- Linting
113-
- black
114-
- isort
115-
- mdformat
116-
- mypy
117-
- Test running (TBD)
118-
- Coverage reporting (TBD)
119-
- Automated PyPI deployment

fitbit_client/exceptions.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from typing import List
77
from typing import Optional
88

9+
# Local imports
10+
from fitbit_client.utils.types import JSONDict
11+
912

1013
class FitbitAPIException(Exception):
1114
"""Base exception for all Fitbit API errors"""
@@ -15,7 +18,7 @@ def __init__(
1518
message: str,
1619
error_type: str,
1720
status_code: Optional[int] = None,
18-
raw_response: Optional[Dict[str, Any]] = None,
21+
raw_response: Optional[JSONDict] = None,
1922
field_name: Optional[str] = None,
2023
):
2124
self.message = message

fitbit_client/resources/activity.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from fitbit_client.utils.pagination_validation import validate_pagination_params
1919
from fitbit_client.utils.types import JSONDict
2020
from fitbit_client.utils.types import JSONList
21+
from fitbit_client.utils.types import ParamDict
2122

2223

2324
class ActivityResource(BaseResource):
@@ -88,7 +89,7 @@ def create_activity_goals(
8889
field_name="value",
8990
)
9091

91-
params = {"type": type.value, "value": value}
92+
params: ParamDict = {"type": type.value, "value": value}
9293
result = self._make_request(
9394
f"activities/goals/{period.value}.json",
9495
params=params,
@@ -152,24 +153,26 @@ def create_activity_log(
152153
- Start time should be in 24-hour format (e.g., "14:30" for 2:30 PM)
153154
"""
154155
if activity_id:
155-
params = {
156+
activity_params: ParamDict = {
156157
"activityId": activity_id,
157158
"startTime": start_time,
158159
"durationMillis": duration_millis,
159160
"date": date,
160161
}
161162
if distance is not None:
162-
params["distance"] = distance
163+
activity_params["distance"] = distance
163164
if distance_unit:
164-
params["distanceUnit"] = distance_unit
165+
activity_params["distanceUnit"] = distance_unit
166+
params = activity_params
165167
elif activity_name and manual_calories:
166-
params = {
168+
name_params: ParamDict = {
167169
"activityName": activity_name,
168170
"manualCalories": manual_calories,
169171
"startTime": start_time,
170172
"durationMillis": duration_millis,
171173
"date": date,
172174
}
175+
params = name_params
173176
else:
174177
raise MissingParameterException(
175178
message="Must provide either activity_id or (activity_name and manual_calories)",
@@ -229,7 +232,7 @@ def get_activity_log_list(
229232
- The source field indicates whether the activity was logged manually by the user
230233
or automatically by a Fitbit device
231234
"""
232-
params = {"sort": sort.value, "limit": limit, "offset": offset}
235+
params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset}
233236
if before_date:
234237
params["beforeDate"] = before_date
235238
if after_date:
@@ -651,7 +654,9 @@ def get_activity_tcx(
651654
- Not all activities have TCX data available (e.g., manually logged activities)
652655
- To check if an activity has GPS data, look for hasGps=True in the activity log
653656
"""
654-
params = {"includePartialTCX": include_partial_tcx} if include_partial_tcx else None
657+
params: Optional[ParamDict] = (
658+
{"includePartialTCX": include_partial_tcx} if include_partial_tcx else None
659+
)
655660
result = self._make_request(
656661
f"activities/{log_id}.tcx", params=params, user_id=user_id, debug=debug
657662
)

fitbit_client/resources/base.py

Lines changed: 15 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Optional
1212
from typing import Set
1313
from typing import cast
14+
from typing import overload
1415
from urllib.parse import urlencode
1516

1617
# Third party imports
@@ -21,7 +22,12 @@
2122
from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS
2223
from fitbit_client.exceptions import FitbitAPIException
2324
from fitbit_client.exceptions import STATUS_CODE_EXCEPTIONS
25+
from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin
26+
from fitbit_client.utils.types import FormDataDict
27+
from fitbit_client.utils.types import JSONDict
28+
from fitbit_client.utils.types import JSONList
2429
from fitbit_client.utils.types import JSONType
30+
from fitbit_client.utils.types import ParamDict
2531

2632
# Constants for important fields to track in logging
2733
IMPORTANT_RESPONSE_FIELDS: Set[str] = {
@@ -41,7 +47,7 @@
4147
}
4248

4349

44-
class BaseResource:
50+
class BaseResource(CurlDebugMixin):
4551
"""Provides foundational functionality for all Fitbit API resource classes.
4652
4753
The BaseResource class implements core functionality that all specific resource
@@ -61,7 +67,7 @@ class BaseResource:
6167
- Request handling with comprehensive error management
6268
- Response parsing with type safety
6369
- Detailed logging of requests, responses, and errors
64-
- Debug capabilities for API troubleshooting
70+
- Debug capabilities for API troubleshooting (via CurlDebugMixin)
6571
- OAuth2 authentication management
6672
6773
Note:
@@ -134,7 +140,7 @@ def _build_url(
134140
return f"{self.API_BASE}/{api_version}/user/{user_id}/{endpoint}"
135141
return f"{self.API_BASE}/{api_version}/{endpoint}"
136142

137-
def _extract_important_fields(self, data: Dict[str, JSONType]) -> Dict[str, int | str]:
143+
def _extract_important_fields(self, data: JSONDict) -> Dict[str, JSONType]:
138144
"""
139145
Extract important fields from response data for logging.
140146
@@ -150,7 +156,7 @@ def _extract_important_fields(self, data: Dict[str, JSONType]) -> Dict[str, int
150156
"""
151157
extracted = {}
152158

153-
def extract_recursive(d: Dict[str, Any], prefix: str = "") -> None:
159+
def extract_recursive(d: JSONDict, prefix: str = "") -> None:
154160
for key, value in d.items():
155161
full_key = f"{prefix}.{key}" if prefix else key
156162

@@ -189,69 +195,6 @@ def _get_calling_method(self) -> str:
189195
frame = frame.f_back
190196
return "unknown"
191197

192-
def _build_curl_command(
193-
self,
194-
url: str,
195-
http_method: str,
196-
data: Optional[Dict[str, Any]] = None,
197-
json: Optional[Dict[str, Any]] = None,
198-
params: Optional[Dict[str, Any]] = None,
199-
) -> str:
200-
"""
201-
Build a curl command string for debugging API requests.
202-
203-
Args:
204-
url: Full API URL
205-
http_method: HTTP method (GET, POST, DELETE)
206-
data: Optional form data for POST requests
207-
json: Optional JSON data for POST requests
208-
params: Optional query parameters for GET requests
209-
210-
Returns:
211-
Complete curl command as a multi-line string
212-
213-
The generated command includes:
214-
- The HTTP method (for non-GET requests)
215-
- Authorization header with OAuth token
216-
- Request body (if data or json is provided)
217-
- Query parameters (if provided)
218-
219-
The command is formatted with line continuations for readability and
220-
can be copied directly into a terminal for testing.
221-
222-
Example output:
223-
curl \\
224-
-X POST \\
225-
-H "Authorization: Bearer <token>" \\
226-
-H "Content-Type: application/json" \\
227-
-d '{"name": "value"}' \\
228-
'https://api.fitbit.com/1/user/-/foods/log.json'
229-
"""
230-
# Start with base command
231-
cmd_parts = ["curl -v"]
232-
233-
# Add method
234-
if http_method != "GET":
235-
cmd_parts.append(f"-X {http_method}")
236-
237-
# Add auth header
238-
cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"')
239-
240-
# Add data if present
241-
if json:
242-
cmd_parts.append(f"-d '{dumps(json)}'")
243-
cmd_parts.append('-H "Content-Type: application/json"')
244-
elif data:
245-
cmd_parts.append(f"-d '{urlencode(data)}'")
246-
cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"')
247-
248-
# Add URL with parameters if present
249-
if params:
250-
url = f"{url}?{urlencode(params)}"
251-
cmd_parts.append(f"'{url}'")
252-
253-
return " \\\n ".join(cmd_parts)
254-
255198
def _log_response(
256199
self, calling_method: str, endpoint: str, response: Response, content: Optional[Dict] = None
257200
) -> None:
@@ -391,10 +334,10 @@ def _handle_error_response(self, response: Response) -> None:
391334
def _make_request(
392335
self,
393336
endpoint: str,
394-
data: Optional[Dict[str, Any]] = None,
395-
json: Optional[Dict[str, Any]] = None,
396-
params: Optional[Dict[str, Any]] = None,
397-
headers: Dict[str, Any] = {},
337+
data: Optional[FormDataDict] = None,
338+
json: Optional[JSONDict] = None,
339+
params: Optional[ParamDict] = None,
340+
headers: Dict[str, str] = {},
398341
user_id: str = "-",
399342
requires_user_id: bool = True,
400343
http_method: str = "GET",
@@ -421,7 +364,7 @@ def _make_request(
421364
422365
Returns:
423366
JSONType: The API response in one of these formats:
424-
- Dict[str, Any]: For most JSON object responses
367+
- JSONDict: For most JSON object responses
425368
- List[Any]: For endpoints that return JSON arrays
426369
- str: For XML/TCX responses
427370
- None: For successful DELETE operations or debug mode

fitbit_client/resources/body.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from fitbit_client.resources.constants import BodyGoalType
1010
from fitbit_client.utils.date_validation import validate_date_param
1111
from fitbit_client.utils.types import JSONDict
12+
from fitbit_client.utils.types import ParamDict
1213

1314

1415
class BodyResource(BaseResource):
@@ -101,7 +102,7 @@ def create_bodyfat_log(
101102
The 'source' field will be set to "API" for entries created through this endpoint.
102103
Multiple entries can be logged for the same day with different timestamps.
103104
"""
104-
params = {"fat": fat, "date": date}
105+
params: ParamDict = {"fat": fat, "date": date}
105106
if time:
106107
params["time"] = time
107108
result = self._make_request(
@@ -151,7 +152,7 @@ def create_weight_goal(
151152
- If target > start: "GAIN"
152153
- If target = start: "MAINTAIN"
153154
"""
154-
params = {"startDate": start_date, "startWeight": start_weight}
155+
params: ParamDict = {"startDate": start_date, "startWeight": start_weight}
155156
if weight is not None:
156157
params["weight"] = weight
157158
result = self._make_request(
@@ -208,7 +209,7 @@ def create_weight_log(
208209
The 'source' field will be set to "API" for entries created through this endpoint.
209210
Multiple weight entries can be logged for the same day with different timestamps.
210211
"""
211-
params = {"weight": weight, "date": date}
212+
params: ParamDict = {"weight": weight, "date": date}
212213
if time:
213214
params["time"] = time
214215
result = self._make_request(

0 commit comments

Comments
 (0)