From ec6f0889f7f7d74fa8009f132b9debd5af40d72c Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Tue, 4 Mar 2025 22:00:38 -0500 Subject: [PATCH] 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. --- .gitignore | 1 + TODO.md | 65 ++------- fitbit_client/exceptions.py | 5 +- fitbit_client/resources/activity.py | 19 ++- fitbit_client/resources/base.py | 87 ++---------- fitbit_client/resources/body.py | 7 +- fitbit_client/resources/electrocardiogram.py | 5 +- .../resources/heartrate_timeseries.py | 5 +- .../irregular_rhythm_notifications.py | 3 +- fitbit_client/resources/nutrition.py | 22 ++- fitbit_client/resources/sleep.py | 5 +- fitbit_client/resources/user.py | 4 +- fitbit_client/utils/curl_debug_mixin.py | 94 ++++++++++++ fitbit_client/utils/types.py | 4 + tests/resources/test_base.py | 32 +---- tests/utils/test_curl_debug_mixin.py | 134 ++++++++++++++++++ tests/utils/test_date_validation.py | 7 +- 17 files changed, 316 insertions(+), 183 deletions(-) create mode 100644 fitbit_client/utils/curl_debug_mixin.py create mode 100644 tests/utils/test_curl_debug_mixin.py diff --git a/.gitignore b/.gitignore index 5d37faf..7a24f86 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ coverage.xml htmlcov/ .pytest_cache/ .mypy_cache/ +*,cover # Mac .DS_Store diff --git a/TODO.md b/TODO.md index 222ccd8..0cff1f3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,49 +1,31 @@ # Project TODO and Notes -## Refactoring TODOs +## TODOs: -- Typing +- PyPi deployment - - Try to get rid of `Optional[Dict[str, Any]]` args +- For all `create_...`methods, add the ID from the response to logs and maybe + something human readable, like the first n characters of the name??. Right + now: + +```log +[2025-02-05 06:09:34,828] INFO [fitbit_client.NutritionResource] create_food_log succeeded for foods/log.json (status 201) +``` - base.py: reorganize and see what we can move out. - Rename to `_base`? Files it first, makes it clearer that everything in it is private - - Move the methods for building `curl` commands into a mixin? It's a lot of - code for an isolated and tightly scoped feature. - - refactor `_make_request`. - - do we need both `data` and `json`? Also, could we simplify a lot of typing - if we separated GET, POST, and DELETE methods? Maybe even a separate, - second non-auth GET? Could use `@overload` - - we had to makee a `ParamDict` type in `nutrition.py`. Use this everywhere? - - start by looking at how many methods use which params - client.py: - Creat and Test that all methods have an alias in `Client` and that the signatures match -```python -if not food_id and not (food_name and calories): - raise ClientValidationException( - "Must provide either food_id or (food_name and calories)" - ) -``` - -- nutrition.py: -- It doesn't seem like this should be passing tests when CALORIES is not an int: +- CI: -```python - # Handle both enum and string nutritional values - for key, value in nutritional_values.items(): - if isinstance(key, NutritionalValue): - params[key.value] = float(value) - else: - params[str(key)] = float(value) -``` - -see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) +* Read and implement: + 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 - exceptions.py Consider: - Add automatic token refresh for ExpiredTokenException @@ -77,14 +59,6 @@ see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) be reused. - We may need a public version of a generic `make_request` method. -- For all `create_...`methods, add the ID from the response to logs and maybe - something human readable, like the first n characters of the name??. Right - now: - -```log -[2025-02-05 06:09:34,828] INFO [fitbit_client.NutritionResource] create_food_log succeeded for foods/log.json (status 201) -``` - - Form to change scopes are part of OAuth flow? Maybe get rid of the cut and paste method altogether? It's less to test... @@ -101,19 +75,4 @@ see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) one. (there might be several of these that make sense--just take an ID and then the signature of the "create" method). -- PyPI deployment - - Enum for units? (it'll be big, maybe just common ones?) - -## CI/CD/Linting - -- GitHub Actions Setup - - - Linting - - black - - isort - - mdformat - - mypy - - Test running (TBD) - - Coverage reporting (TBD) - - Automated PyPI deployment diff --git a/fitbit_client/exceptions.py b/fitbit_client/exceptions.py index 708df35..721848c 100644 --- a/fitbit_client/exceptions.py +++ b/fitbit_client/exceptions.py @@ -6,6 +6,9 @@ from typing import List from typing import Optional +# Local imports +from fitbit_client.utils.types import JSONDict + class FitbitAPIException(Exception): """Base exception for all Fitbit API errors""" @@ -15,7 +18,7 @@ def __init__( message: str, error_type: str, status_code: Optional[int] = None, - raw_response: Optional[Dict[str, Any]] = None, + raw_response: Optional[JSONDict] = None, field_name: Optional[str] = None, ): self.message = message diff --git a/fitbit_client/resources/activity.py b/fitbit_client/resources/activity.py index 9114cd9..83f9f29 100644 --- a/fitbit_client/resources/activity.py +++ b/fitbit_client/resources/activity.py @@ -18,6 +18,7 @@ from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict from fitbit_client.utils.types import JSONList +from fitbit_client.utils.types import ParamDict class ActivityResource(BaseResource): @@ -88,7 +89,7 @@ def create_activity_goals( field_name="value", ) - params = {"type": type.value, "value": value} + params: ParamDict = {"type": type.value, "value": value} result = self._make_request( f"activities/goals/{period.value}.json", params=params, @@ -152,24 +153,26 @@ def create_activity_log( - Start time should be in 24-hour format (e.g., "14:30" for 2:30 PM) """ if activity_id: - params = { + activity_params: ParamDict = { "activityId": activity_id, "startTime": start_time, "durationMillis": duration_millis, "date": date, } if distance is not None: - params["distance"] = distance + activity_params["distance"] = distance if distance_unit: - params["distanceUnit"] = distance_unit + activity_params["distanceUnit"] = distance_unit + params = activity_params elif activity_name and manual_calories: - params = { + name_params: ParamDict = { "activityName": activity_name, "manualCalories": manual_calories, "startTime": start_time, "durationMillis": duration_millis, "date": date, } + params = name_params else: raise MissingParameterException( message="Must provide either activity_id or (activity_name and manual_calories)", @@ -229,7 +232,7 @@ def get_activity_log_list( - The source field indicates whether the activity was logged manually by the user or automatically by a Fitbit device """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date if after_date: @@ -651,7 +654,9 @@ def get_activity_tcx( - Not all activities have TCX data available (e.g., manually logged activities) - To check if an activity has GPS data, look for hasGps=True in the activity log """ - params = {"includePartialTCX": include_partial_tcx} if include_partial_tcx else None + params: Optional[ParamDict] = ( + {"includePartialTCX": include_partial_tcx} if include_partial_tcx else None + ) result = self._make_request( f"activities/{log_id}.tcx", params=params, user_id=user_id, debug=debug ) diff --git a/fitbit_client/resources/base.py b/fitbit_client/resources/base.py index e423e26..4bf1be0 100644 --- a/fitbit_client/resources/base.py +++ b/fitbit_client/resources/base.py @@ -11,6 +11,7 @@ from typing import Optional from typing import Set from typing import cast +from typing import overload from urllib.parse import urlencode # Third party imports @@ -21,7 +22,12 @@ from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS from fitbit_client.exceptions import FitbitAPIException from fitbit_client.exceptions import STATUS_CODE_EXCEPTIONS +from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin +from fitbit_client.utils.types import FormDataDict +from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import JSONList from fitbit_client.utils.types import JSONType +from fitbit_client.utils.types import ParamDict # Constants for important fields to track in logging IMPORTANT_RESPONSE_FIELDS: Set[str] = { @@ -41,7 +47,7 @@ } -class BaseResource: +class BaseResource(CurlDebugMixin): """Provides foundational functionality for all Fitbit API resource classes. The BaseResource class implements core functionality that all specific resource @@ -61,7 +67,7 @@ class BaseResource: - Request handling with comprehensive error management - Response parsing with type safety - Detailed logging of requests, responses, and errors - - Debug capabilities for API troubleshooting + - Debug capabilities for API troubleshooting (via CurlDebugMixin) - OAuth2 authentication management Note: @@ -134,7 +140,7 @@ def _build_url( return f"{self.API_BASE}/{api_version}/user/{user_id}/{endpoint}" return f"{self.API_BASE}/{api_version}/{endpoint}" - def _extract_important_fields(self, data: Dict[str, JSONType]) -> Dict[str, int | str]: + def _extract_important_fields(self, data: JSONDict) -> Dict[str, JSONType]: """ Extract important fields from response data for logging. @@ -150,7 +156,7 @@ def _extract_important_fields(self, data: Dict[str, JSONType]) -> Dict[str, int """ extracted = {} - def extract_recursive(d: Dict[str, Any], prefix: str = "") -> None: + def extract_recursive(d: JSONDict, prefix: str = "") -> None: for key, value in d.items(): full_key = f"{prefix}.{key}" if prefix else key @@ -189,69 +195,6 @@ def _get_calling_method(self) -> str: frame = frame.f_back return "unknown" - def _build_curl_command( - self, - url: str, - http_method: str, - data: Optional[Dict[str, Any]] = None, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> str: - """ - Build a curl command string for debugging API requests. - - Args: - url: Full API URL - http_method: HTTP method (GET, POST, DELETE) - data: Optional form data for POST requests - json: Optional JSON data for POST requests - params: Optional query parameters for GET requests - - Returns: - Complete curl command as a multi-line string - - The generated command includes: - - The HTTP method (for non-GET requests) - - Authorization header with OAuth token - - Request body (if data or json is provided) - - Query parameters (if provided) - - The command is formatted with line continuations for readability and - can be copied directly into a terminal for testing. - - Example output: - curl \\ - -X POST \\ - -H "Authorization: Bearer " \\ - -H "Content-Type: application/json" \\ - -d '{"name": "value"}' \\ - 'https://api.fitbit.com/1/user/-/foods/log.json' - """ - # Start with base command - cmd_parts = ["curl -v"] - - # Add method - if http_method != "GET": - cmd_parts.append(f"-X {http_method}") - - # Add auth header - cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"') - - # Add data if present - if json: - cmd_parts.append(f"-d '{dumps(json)}'") - cmd_parts.append('-H "Content-Type: application/json"') - elif data: - cmd_parts.append(f"-d '{urlencode(data)}'") - cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"') - - # Add URL with parameters if present - if params: - url = f"{url}?{urlencode(params)}" - cmd_parts.append(f"'{url}'") - - return " \\\n ".join(cmd_parts) - def _log_response( self, calling_method: str, endpoint: str, response: Response, content: Optional[Dict] = None ) -> None: @@ -391,10 +334,10 @@ def _handle_error_response(self, response: Response) -> None: def _make_request( self, endpoint: str, - data: Optional[Dict[str, Any]] = None, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - headers: Dict[str, Any] = {}, + data: Optional[FormDataDict] = None, + json: Optional[JSONDict] = None, + params: Optional[ParamDict] = None, + headers: Dict[str, str] = {}, user_id: str = "-", requires_user_id: bool = True, http_method: str = "GET", @@ -421,7 +364,7 @@ def _make_request( Returns: JSONType: The API response in one of these formats: - - Dict[str, Any]: For most JSON object responses + - JSONDict: For most JSON object responses - List[Any]: For endpoints that return JSON arrays - str: For XML/TCX responses - None: For successful DELETE operations or debug mode diff --git a/fitbit_client/resources/body.py b/fitbit_client/resources/body.py index 42cc63b..fceae50 100644 --- a/fitbit_client/resources/body.py +++ b/fitbit_client/resources/body.py @@ -9,6 +9,7 @@ from fitbit_client.resources.constants import BodyGoalType from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class BodyResource(BaseResource): @@ -101,7 +102,7 @@ def create_bodyfat_log( The 'source' field will be set to "API" for entries created through this endpoint. Multiple entries can be logged for the same day with different timestamps. """ - params = {"fat": fat, "date": date} + params: ParamDict = {"fat": fat, "date": date} if time: params["time"] = time result = self._make_request( @@ -151,7 +152,7 @@ def create_weight_goal( - If target > start: "GAIN" - If target = start: "MAINTAIN" """ - params = {"startDate": start_date, "startWeight": start_weight} + params: ParamDict = {"startDate": start_date, "startWeight": start_weight} if weight is not None: params["weight"] = weight result = self._make_request( @@ -208,7 +209,7 @@ def create_weight_log( The 'source' field will be set to "API" for entries created through this endpoint. Multiple weight entries can be logged for the same day with different timestamps. """ - params = {"weight": weight, "date": date} + params: ParamDict = {"weight": weight, "date": date} if time: params["time"] = time result = self._make_request( diff --git a/fitbit_client/resources/electrocardiogram.py b/fitbit_client/resources/electrocardiogram.py index dc058d3..df4de3f 100644 --- a/fitbit_client/resources/electrocardiogram.py +++ b/fitbit_client/resources/electrocardiogram.py @@ -12,6 +12,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class ElectrocardiogramResource(BaseResource): @@ -56,7 +57,7 @@ def get_ecg_log_list( offset: int = 0, user_id: str = "-", debug: bool = False, - ) -> Dict[str, Any]: + ) -> JSONDict: """Returns a list of user's ECG log entries before or after a given day. API Reference: https://dev.fitbit.com/build/reference/web-api/electrocardiogram/get-ecg-log-list/ @@ -92,7 +93,7 @@ def get_ecg_log_list( - resultClassification indicates the assessment outcome (normal, afib, inconclusive) - For research purposes only, not for clinical or diagnostic use """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date diff --git a/fitbit_client/resources/heartrate_timeseries.py b/fitbit_client/resources/heartrate_timeseries.py index aa7d8e7..2d89c48 100644 --- a/fitbit_client/resources/heartrate_timeseries.py +++ b/fitbit_client/resources/heartrate_timeseries.py @@ -12,6 +12,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.date_validation import validate_date_range_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class HeartrateTimeSeriesResource(BaseResource): @@ -105,7 +106,7 @@ def get_heartrate_timeseries_by_date( message="Only 'UTC' timezone is supported", field_name="timezone" ) - params = {"timezone": timezone} if timezone else None + params: Optional[ParamDict] = {"timezone": timezone} if timezone else None result = self._make_request( f"activities/heart/date/{date}/{period.value}.json", params=params, @@ -169,7 +170,7 @@ def get_heartrate_timeseries_by_date_range( message="Only 'UTC' timezone is supported", field_name="timezone" ) - params = {"timezone": timezone} if timezone else None + params: Optional[ParamDict] = {"timezone": timezone} if timezone else None result = self._make_request( f"activities/heart/date/{start_date}/{end_date}.json", params=params, diff --git a/fitbit_client/resources/irregular_rhythm_notifications.py b/fitbit_client/resources/irregular_rhythm_notifications.py index 5b072b8..db2ef9c 100644 --- a/fitbit_client/resources/irregular_rhythm_notifications.py +++ b/fitbit_client/resources/irregular_rhythm_notifications.py @@ -10,6 +10,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class IrregularRhythmNotificationsResource(BaseResource): @@ -93,7 +94,7 @@ def get_irn_alerts_list( - The alertTime is when the notification was generated, while detectedTime is when the irregular rhythm was detected (usually during sleep) """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date diff --git a/fitbit_client/resources/nutrition.py b/fitbit_client/resources/nutrition.py index 695c432..ed70e09 100644 --- a/fitbit_client/resources/nutrition.py +++ b/fitbit_client/resources/nutrition.py @@ -384,10 +384,15 @@ def create_meal( Food IDs can be obtained from food search results or the user's custom foods. """ # snakes to camels - foods = [{to_camel_case(k): v for k, v in d.items()} for d in foods] - data = {"name": name, "description": description, "mealFoods": foods} + foods_list = [{to_camel_case(k): v for k, v in d.items()} for d in foods] + # Use cast to handle the complex structure + data_dict = {"name": name, "description": description, "mealFoods": foods_list} result = self._make_request( - "meals.json", json=data, user_id=user_id, http_method="POST", debug=debug + "meals.json", + json=cast(JSONDict, data_dict), + user_id=user_id, + http_method="POST", + debug=debug, ) return cast(JSONDict, result) @@ -1179,10 +1184,15 @@ def update_meal( Meal IDs can be obtained from the get_meals method. Updating a meal does not affect any food logs that were previously created using this meal. """ - foods = [{to_camel_case(k): v for k, v in d.items()} for d in foods] - data = {"name": name, "description": description, "mealFoods": foods} + foods_list = [{to_camel_case(k): v for k, v in d.items()} for d in foods] + # Use cast to handle the complex structure + data_dict = {"name": name, "description": description, "mealFoods": foods_list} result = self._make_request( - f"meals/{meal_id}.json", json=data, user_id=user_id, http_method="POST", debug=debug + f"meals/{meal_id}.json", + json=cast(JSONDict, data_dict), + user_id=user_id, + http_method="POST", + debug=debug, ) return cast(JSONDict, result) diff --git a/fitbit_client/resources/sleep.py b/fitbit_client/resources/sleep.py index 3d4457c..02fe6f2 100644 --- a/fitbit_client/resources/sleep.py +++ b/fitbit_client/resources/sleep.py @@ -14,6 +14,7 @@ from fitbit_client.utils.date_validation import validate_date_range_params from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class SleepResource(BaseResource): @@ -124,7 +125,7 @@ def create_sleep_log( message="duration_millis must be positive", field_name="duration_millis" ) - params = {"startTime": start_time, "duration": duration_millis, "date": date} + params: ParamDict = {"startTime": start_time, "duration": duration_millis, "date": date} result = self._make_request( "sleep.json", params=params, @@ -355,7 +356,7 @@ def get_sleep_log_list( This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date if after_date: diff --git a/fitbit_client/resources/user.py b/fitbit_client/resources/user.py index 2a6efaf..cb4b13f 100644 --- a/fitbit_client/resources/user.py +++ b/fitbit_client/resources/user.py @@ -11,6 +11,7 @@ from fitbit_client.resources.constants import StartDayOfWeek from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class UserResource(BaseResource): @@ -173,7 +174,8 @@ def update_profile( "strideLengthRunning": stride_length_running, } - params = {key: value for key, value in updates.items() if value is not None} + # Create a ParamDict from the non-None values + params: ParamDict = {key: value for key, value in updates.items() if value is not None} result = self._make_request( "profile.json", params=params, user_id=user_id, http_method="POST", debug=debug ) diff --git a/fitbit_client/utils/curl_debug_mixin.py b/fitbit_client/utils/curl_debug_mixin.py new file mode 100644 index 0000000..43855e5 --- /dev/null +++ b/fitbit_client/utils/curl_debug_mixin.py @@ -0,0 +1,94 @@ +# fitbit_client/utils/curl_debug_mixin.py + +""" +Mixin for generating curl commands for API debugging. +""" + +# Standard library imports +from json import dumps +from typing import Optional +from urllib.parse import urlencode + +# Local imports +from fitbit_client.utils.types import FormDataDict +from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict + + +class CurlDebugMixin: + """Mixin that provides curl command generation for debugging API requests. + + This mixin can be used with API client classes to add the ability to generate + equivalent curl commands for debugging purposes. It helps with: + - Testing API endpoints directly + - Debugging authentication/scope issues + - Verifying request structure + - Troubleshooting permission problems + """ + + def _build_curl_command( + self, + url: str, + http_method: str, + data: Optional[FormDataDict] = None, + json: Optional[JSONDict] = None, + params: Optional[ParamDict] = None, + ) -> str: + """ + Build a curl command string for debugging API requests. + + Args: + url: Full API URL + http_method: HTTP method (GET, POST, DELETE) + data: Optional form data for POST requests + json: Optional JSON data for POST requests + params: Optional query parameters for GET requests + + Returns: + Complete curl command as a multi-line string + + The generated command includes: + - The HTTP method (for non-GET requests) + - Authorization header with OAuth token + - Request body (if data or json is provided) + - Query parameters (if provided) + + The command is formatted with line continuations for readability and + can be copied directly into a terminal for testing. + + Example output: + curl \\ + -X POST \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"name": "value"}' \\ + 'https://api.fitbit.com/1/user/-/foods/log.json' + """ + # Start with base command + cmd_parts = ["curl -v"] + + # Add method + if http_method != "GET": + cmd_parts.append(f"-X {http_method}") + + # Add auth header + if hasattr(self, "oauth") and hasattr(self.oauth, "token"): + cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"') + else: + # Fallback for tests or when not properly initialized + cmd_parts.append('-H "Authorization: Bearer TOKEN"') + + # Add data if present + if json: + cmd_parts.append(f"-d '{dumps(json)}'") + cmd_parts.append('-H "Content-Type: application/json"') + elif data: + cmd_parts.append(f"-d '{urlencode(data)}'") + cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"') + + # Add URL with parameters if present + if params: + url = f"{url}?{urlencode(params)}" + cmd_parts.append(f"'{url}'") + + return " \\\n ".join(cmd_parts) diff --git a/fitbit_client/utils/types.py b/fitbit_client/utils/types.py index 19efd44..bbce1f7 100644 --- a/fitbit_client/utils/types.py +++ b/fitbit_client/utils/types.py @@ -18,6 +18,10 @@ ParamValue = Union[str, int, float, bool, None] ParamDict = Dict[str, ParamValue] +# Type for form data (typically used with data parameter in requests) +FormDataValue = Union[str, int, float, bool, None] +FormDataDict = Dict[str, FormDataValue] + # Type definitions for token structure class TokenDict(TypedDict, total=False): diff --git a/tests/resources/test_base.py b/tests/resources/test_base.py index 88d517f..37eeb1a 100644 --- a/tests/resources/test_base.py +++ b/tests/resources/test_base.py @@ -247,38 +247,10 @@ def test_log_response_for_error_without_content(base_resource, mock_logger): # ----------------------------------------------------------------------------- -# 6. Debug Curl Command Generation +# 6. Debug Mode Testing # ----------------------------------------------------------------------------- - -def test_build_curl_command_with_json_data(base_resource): - """Test generating curl command with JSON data""" - # This tests lines 217-218 in base.py - base_resource.oauth.token = {"access_token": "test_token"} - - json_data = {"name": "Test Activity", "type": "run", "duration": 3600} - result = base_resource._build_curl_command( - url="https://api.fitbit.com/1/user/-/activities.json", http_method="POST", json=json_data - ) - - # Assert command contains JSON data and correct header - assert '-d \'{"name": "Test Activity", "type": "run", "duration": 3600}\'' in result - assert '-H "Content-Type: application/json"' in result - - -def test_build_curl_command_with_form_data(base_resource): - """Test generating curl command with form data""" - # This tests lines 220-221 in base.py - base_resource.oauth.token = {"access_token": "test_token"} - - form_data = {"date": "2023-01-01", "foodId": "12345", "amount": "1", "mealTypeId": "1"} - result = base_resource._build_curl_command( - url="https://api.fitbit.com/1/user/-/foods/log.json", http_method="POST", data=form_data - ) - - # Assert command contains form data and correct header - assert "-d 'date=2023-01-01&foodId=12345&amount=1&mealTypeId=1'" in result - assert '-H "Content-Type: application/x-www-form-urlencoded"' in result +# Debug mode tests are now in tests/utils/test_curl_debug_mixin.py # ----------------------------------------------------------------------------- diff --git a/tests/utils/test_curl_debug_mixin.py b/tests/utils/test_curl_debug_mixin.py new file mode 100644 index 0000000..d93d5d3 --- /dev/null +++ b/tests/utils/test_curl_debug_mixin.py @@ -0,0 +1,134 @@ +# tests/utils/test_curl_debug_mixin.py + +"""Tests for CurlDebugMixin""" + +# Standard library imports +from unittest.mock import Mock +from unittest.mock import patch + +# Third party imports +from pytest import fixture + +# Local imports +from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin + + +# Create a test class that uses the mixin for debug testing +class TestResource(CurlDebugMixin): + """Test class that uses CurlDebugMixin""" + + def __init__(self): + self.oauth = Mock() + self.oauth.token = {"access_token": "test_token"} + + def make_debug_request(self, debug=False): + """Test method that simulates _make_request with debug mode""" + url = "https://api.fitbit.com/1/user/-/test/endpoint" + + if debug: + curl_command = self._build_curl_command( + url=url, http_method="GET", params={"param1": "value1"} + ) + print(f"\n# Debug curl command:") + print(curl_command) + print() + return None + + return {"success": True} + + +@fixture +def curl_debug_mixin(): + """Fixture for CurlDebugMixin with mocked OAuth session""" + mixin = CurlDebugMixin() + mixin.oauth = Mock() + mixin.oauth.token = {"access_token": "test_token"} + return mixin + + +def test_build_curl_command_with_json_data(curl_debug_mixin): + """Test generating curl command with JSON data""" + json_data = {"name": "Test Activity", "type": "run", "duration": 3600} + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/activities.json", http_method="POST", json=json_data + ) + + # Assert command contains JSON data and correct header + assert '-d \'{"name": "Test Activity", "type": "run", "duration": 3600}\'' in result + assert '-H "Content-Type: application/json"' in result + assert "-X POST" in result + assert "curl -v" in result + assert '-H "Authorization: Bearer test_token"' in result + assert "'https://api.fitbit.com/1/user/-/activities.json'" in result + + +def test_build_curl_command_with_form_data(curl_debug_mixin): + """Test generating curl command with form data""" + form_data = {"date": "2023-01-01", "foodId": "12345", "amount": "1", "mealTypeId": "1"} + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/foods/log.json", http_method="POST", data=form_data + ) + + # Assert command contains form data and correct header + assert "-d 'date=2023-01-01&foodId=12345&amount=1&mealTypeId=1'" in result + assert '-H "Content-Type: application/x-www-form-urlencoded"' in result + assert "-X POST" in result + + +def test_build_curl_command_with_get_params(curl_debug_mixin): + """Test generating curl command with GET parameters""" + params = {"date": "2023-01-01", "offset": "0", "limit": "10"} + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/activities/list.json", http_method="GET", params=params + ) + + # Assert command doesn't have -X GET but has parameters in URL + assert "-X GET" not in result + assert "?date=2023-01-01&offset=0&limit=10" in result + + +def test_build_curl_command_with_delete(curl_debug_mixin): + """Test generating curl command for DELETE request""" + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/foods/log/123456.json", http_method="DELETE" + ) + + # Assert command has DELETE method + assert "-X DELETE" in result + assert "curl -v" in result + assert '-H "Authorization: Bearer test_token"' in result + + +def test_debug_mode_integration(capsys): + """Test debug mode integration with a resource class""" + # Create test resource + resource = TestResource() + + # Call make_debug_request with debug=True + result = resource.make_debug_request(debug=True) + + # Capture stdout + captured = capsys.readouterr() + + # Verify results + assert result is None + assert "curl" in captured.out + assert "test/endpoint" in captured.out + assert "param1=value1" in captured.out + assert "test_token" in captured.out + + +def test_build_curl_command_without_oauth(): + """Test curl command generation when oauth is not available""" + # Create a bare mixin instance without oauth + mixin = CurlDebugMixin() + + # Generate a curl command + result = mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/test.json", http_method="GET" + ) + + # Verify that the fallback auth header is used + assert '-H "Authorization: Bearer TOKEN"' in result + assert "curl -v" in result + assert "'https://api.fitbit.com/1/user/-/test.json'" in result diff --git a/tests/utils/test_date_validation.py b/tests/utils/test_date_validation.py index 232b548..18c036b 100644 --- a/tests/utils/test_date_validation.py +++ b/tests/utils/test_date_validation.py @@ -15,6 +15,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.date_validation import validate_date_range from fitbit_client.utils.date_validation import validate_date_range_params +from fitbit_client.utils.types import JSONDict class TestDateValidation: @@ -106,7 +107,7 @@ def test_validate_date_range_params_decorator(self): """Test the validate_date_range_params decorator""" @validate_date_range_params(max_days=30) - def dummy_func(start_date: str, end_date: str) -> Dict[str, Any]: + def dummy_func(start_date: str, end_date: str) -> JSONDict: return {"start": start_date, "end": end_date} result = dummy_func("2024-02-01", "2024-02-13") @@ -148,7 +149,7 @@ def test_validate_date_range_params_decorator_with_optional(self): @validate_date_range_params(max_days=30) def dummy_func( start_date: Optional[str] = None, end_date: Optional[str] = None - ) -> Dict[str, Any]: + ) -> JSONDict: return {"start": start_date, "end": end_date} # Should work with no parameters @@ -169,7 +170,7 @@ def test_validate_date_range_params_custom_field_names(self): """Test that validate_date_range_params respects custom field names""" @validate_date_range_params(start_field="begin_date", end_field="finish_date", max_days=30) - def dummy_func(begin_date: str, finish_date: str) -> Dict[str, Any]: + def dummy_func(begin_date: str, finish_date: str) -> JSONDict: return {"start": begin_date, "end": finish_date} # Test valid dates